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" }, diff --git a/.github/actions/sample-validation-setup/action.yml b/.github/actions/sample-validation-setup/action.yml index 3736348579..2920aaa5bd 100644 --- a/.github/actions/sample-validation-setup/action.yml +++ b/.github/actions/sample-validation-setup/action.yml @@ -24,7 +24,9 @@ runs: using: "composite" steps: - name: Set up Node.js environment - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 + with: + node-version: 22 - name: Install Copilot CLI shell: bash diff --git a/.github/actions/setup-local-mcp-server/action.yml b/.github/actions/setup-local-mcp-server/action.yml new file mode 100644 index 0000000000..7bb9ca652a --- /dev/null +++ b/.github/actions/setup-local-mcp-server/action.yml @@ -0,0 +1,166 @@ +name: Setup Local MCP Server +description: Start and validate a local streamable HTTP MCP server for integration tests + +inputs: + fallback_url: + description: Existing LOCAL_MCP_URL value to keep as a fallback if local startup fails + required: false + default: '' + host: + description: Host interface to bind the local MCP server + required: false + default: '127.0.0.1' + port: + description: Port to bind the local MCP server + required: false + default: '8011' + mount_path: + description: Mount path for the local streamable HTTP MCP endpoint + required: false + default: '/mcp' + +outputs: + effective_url: + description: Local MCP URL when startup succeeds, otherwise the provided fallback URL + value: ${{ steps.start.outputs.effective_url }} + local_url: + description: URL of the local MCP server + value: ${{ steps.start.outputs.local_url }} + started: + description: Whether the local MCP server started and passed validation + value: ${{ steps.start.outputs.started }} + pid: + description: PID of the local MCP server process when startup succeeded + value: ${{ steps.start.outputs.pid }} + +runs: + using: composite + steps: + - name: Start and validate local MCP server + id: start + shell: bash + run: | + set -euo pipefail + + host="${{ inputs.host }}" + port="${{ inputs.port }}" + mount_path="${{ inputs.mount_path }}" + fallback_url="${{ inputs.fallback_url }}" + + if [[ ! "$mount_path" =~ ^/ ]]; then + mount_path="/$mount_path" + fi + + local_url="http://${host}:${port}${mount_path}" + health_url="http://${host}:${port}/healthz" + log_file="$RUNNER_TEMP/local-mcp-server.log" + pid_file="$RUNNER_TEMP/local-mcp-server.pid" + rm -f "$log_file" "$pid_file" + + server_pid="$( + python3 - "$GITHUB_WORKSPACE/python" "$log_file" "$host" "$port" "$mount_path" <<'PY' + from __future__ import annotations + + import subprocess + import sys + + workspace, log_file, host, port, mount_path = sys.argv[1:] + + with open(log_file, "w", encoding="utf-8") as log: + process = subprocess.Popen( + [ + "uv", + "run", + "python", + "scripts/local_mcp_streamable_http_server.py", + "--host", + host, + "--port", + port, + "--mount-path", + mount_path, + ], + cwd=workspace, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + print(process.pid) + PY + )" + echo "$server_pid" > "$pid_file" + + started=false + for _ in $(seq 1 30); do + if curl --silent --fail "$health_url" >/dev/null; then + started=true + break + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + break + fi + sleep 1 + done + + if [[ "$started" == "true" ]]; then + if ! ( + cd "$GITHUB_WORKSPACE/python" + LOCAL_MCP_URL="$local_url" uv run python - <<'PY' + from __future__ import annotations + + import asyncio + import os + + from agent_framework import Content, MCPStreamableHTTPTool + + + def result_to_text(result: str | list[Content]) -> str: + if isinstance(result, str): + return result + return "\n".join(content.text for content in result if content.type == "text" and content.text) + + + async def main() -> None: + tool = MCPStreamableHTTPTool( + name="local_ci_mcp", + url=os.environ["LOCAL_MCP_URL"], + approval_mode="never_require", + ) + + async with tool: + assert tool.functions, "Local MCP server did not expose any tools." + result = result_to_text(await tool.functions[0].invoke(query="What is Agent Framework?")) + assert result, "Local MCP server returned an empty response." + + + asyncio.run(main()) + PY + ); then + started=false + fi + fi + + effective_url="$local_url" + pid="$server_pid" + + if [[ "$started" != "true" ]]; then + effective_url="$fallback_url" + pid="" + if kill -0 "$server_pid" 2>/dev/null; then + kill -TERM -- "-$server_pid" 2>/dev/null || kill -TERM "$server_pid" || true + sleep 1 + kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" || true + fi + echo "Local MCP server was unavailable; continuing with fallback LOCAL_MCP_URL." + if [[ -f "$log_file" ]]; then + tail -n 100 "$log_file" || true + fi + else + echo "Using local MCP server at $local_url" + fi + + echo "started=$started" >> "$GITHUB_OUTPUT" + echo "local_url=$local_url" >> "$GITHUB_OUTPUT" + echo "effective_url=$effective_url" >> "$GITHUB_OUTPUT" + echo "pid=$pid" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/stale_issue_pr_ping.py b/.github/scripts/stale_issue_pr_ping.py new file mode 100644 index 0000000000..9c865213ad --- /dev/null +++ b/.github/scripts/stale_issue_pr_ping.py @@ -0,0 +1,216 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Scan open issues and PRs labeled 'waiting-for-author' for stale follow-ups. + +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 + +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.)" +) +TRIGGER_LABEL = "waiting-for-author" +PINGED_LABEL = "requested-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. + + 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 pinged + if any(label.name == PINGED_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 'requested-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(PINGED_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 labeled '{TRIGGER_LABEL}' (threshold: {days_threshold} days)...\n") + + for issue in repo.get_issues(state="open", labels=[TRIGGER_LABEL]): + 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..b9a7ad5d43 --- /dev/null +++ b/.github/tests/test_stale_issue_pr_ping.py @@ -0,0 +1,297 @@ +# 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 ( + PINGED_LABEL, + PING_COMMENT, + TRIGGER_LABEL, + 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 + # 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: + 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", labels=[TRIGGER_LABEL], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + 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): + 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(PINGED_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/python-check-coverage.py b/.github/workflows/python-check-coverage.py index 84cd500b94..af6d38ffea 100644 --- a/.github/workflows/python-check-coverage.py +++ b/.github/workflows/python-check-coverage.py @@ -41,8 +41,7 @@ ENFORCED_TARGETS: set[str] = { "packages.purview.agent_framework_purview", "packages.anthropic.agent_framework_anthropic", "packages.azure-ai-search.agent_framework_azure_ai_search", - "packages.core.agent_framework.azure", - "packages.core.agent_framework.openai", + "packages.openai.agent_framework_openai", # Individual files (if you want to enforce specific files instead of whole packages) "packages/core/agent_framework/observability.py", # Add more targets here as coverage improves diff --git a/.github/workflows/python-code-quality.yml b/.github/workflows/python-code-quality.yml index ada8d23738..ef75293f0c 100644 --- a/.github/workflows/python-code-quality.yml +++ b/.github/workflows/python-code-quality.yml @@ -75,7 +75,7 @@ jobs: os: ${{ runner.os }} env: UV_CACHE_DIR: /tmp/.uv-cache - - name: Run fmt, lint, pyright in parallel across packages + - name: Run syntax and pyright across packages run: uv run poe check-packages samples-markdown: @@ -104,10 +104,8 @@ jobs: os: ${{ runner.os }} env: UV_CACHE_DIR: /tmp/.uv-cache - - name: Run samples lint - run: uv run poe samples-lint - - name: Run samples syntax check - run: uv run poe samples-syntax + - name: Run samples checks + run: uv run poe check -S - name: Run markdown code lint run: uv run poe markdown-code-lint @@ -140,4 +138,4 @@ jobs: - name: Run Mypy env: GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }} - run: uv run poe ci-mypy + run: uv run python scripts/workspace_poe_tasks.py ci-mypy diff --git a/.github/workflows/python-dependency-range-validation.yml b/.github/workflows/python-dependency-range-validation.yml index 2e33693aa2..692c94101e 100644 --- a/.github/workflows/python-dependency-range-validation.yml +++ b/.github/workflows/python-dependency-range-validation.yml @@ -38,7 +38,7 @@ jobs: id: validate_ranges # Keep workflow running so we can still publish diagnostics from this run. continue-on-error: true - run: uv run poe validate-dependency-bounds-project --mode upper --project "*" + run: uv run poe validate-dependency-bounds-project --mode upper --package "*" working-directory: ./python - name: Upload dependency range report @@ -203,7 +203,7 @@ jobs: cat > "${PR_BODY_FILE}" <<'EOF' This PR was generated by the dependency range validation workflow. - - Ran `uv run poe validate-dependency-bounds-project --mode upper --project "*"` + - Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"` - Updated package dependency bounds - Refreshed `python/uv.lock` with `uv lock --upgrade` EOF diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 913b4325b4..1b1c8066c6 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -48,9 +48,8 @@ jobs: os: ${{ runner.os }} - name: Test with pytest (unit tests only) run: > - uv run poe all-tests + uv run poe test -A -m "not integration" - -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 @@ -64,6 +63,8 @@ jobs: OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} defaults: run: @@ -82,8 +83,8 @@ jobs: - name: Test with pytest (OpenAI integration) run: > uv run pytest --import-mode=importlib - packages/core/tests/openai - -m integration + packages/openai/tests + -m "integration and not azure" -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 @@ -95,8 +96,9 @@ jobs: environment: integration timeout-minutes: 60 env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: @@ -122,7 +124,9 @@ jobs: - name: Test with pytest (Azure OpenAI integration) run: > uv run pytest --import-mode=importlib - packages/core/tests/azure + packages/openai/tests/openai/test_openai_chat_completion_client_azure.py + packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/azure-ai/tests/azure_openai -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -152,6 +156,13 @@ jobs: with: python-version: ${{ env.UV_PYTHON }} os: ${{ runner.os }} + - name: Start local MCP server + id: local-mcp + uses: ./.github/actions/setup-local-mcp-server + with: + fallback_url: ${{ env.LOCAL_MCP_URL }} + - name: Prefer local MCP URL when available + run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV" - name: Test with pytest (Anthropic, Ollama, MCP integration) run: > uv run pytest --import-mode=importlib @@ -162,6 +173,26 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + - name: Stop local MCP server + if: always() + shell: bash + run: | + set -euo pipefail + server_pid="${{ steps.local-mcp.outputs.pid }}" + if [[ -z "$server_pid" ]]; then + exit 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + exit 0 + fi + kill -TERM -- "-$server_pid" 2>/dev/null || kill -TERM "$server_pid" 2>/dev/null || true + for _ in $(seq 1 10); do + if ! kill -0 "$server_pid" 2>/dev/null; then + exit 0 + fi + sleep 1 + done + kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true # Azure Functions + Durable Task integration tests python-tests-functions: @@ -173,10 +204,13 @@ jobs: UV_PYTHON: "3.11" OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -210,7 +244,8 @@ jobs: packages/durabletask/tests/integration_tests -m integration -n logical --dist worksteal - --timeout=120 --session-timeout=900 --timeout_method thread + -x + --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 # Azure AI integration tests @@ -222,6 +257,8 @@ jobs: env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -245,7 +282,9 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: | + uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 # Azure Cosmos integration tests python-tests-cosmos: diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 0e070463d4..a46beb40cb 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -47,6 +47,9 @@ jobs: filters: | python: - 'python/**' + - '.github/actions/setup-local-mcp-server/**' + - '.github/workflows/python-merge-tests.yml' + - '.github/workflows/python-integration-tests.yml' core: - 'python/packages/core/agent_framework/_*.py' - 'python/packages/core/agent_framework/_workflows/**' @@ -54,20 +57,30 @@ jobs: - 'python/packages/core/agent_framework/observability.py' openai: - 'python/packages/core/agent_framework/openai/**' - - 'python/packages/core/tests/openai/**' + - 'python/packages/openai/**' + - 'python/samples/**/providers/openai/**' azure: + - 'python/packages/openai/**' - 'python/packages/core/agent_framework/azure/**' - - 'python/packages/core/tests/azure/**' + - 'python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py' + - 'python/packages/azure-ai/tests/azure_openai/**' + - 'python/samples/**/providers/azure/openai_chat_completion_client_azure*.py' misc: - 'python/packages/anthropic/**' - 'python/packages/ollama/**' - 'python/packages/core/agent_framework/_mcp.py' - 'python/packages/core/tests/core/test_mcp.py' + - 'python/scripts/local_mcp_streamable_http_server.py' + - '.github/actions/setup-local-mcp-server/**' + - '.github/workflows/python-merge-tests.yml' + - '.github/workflows/python-integration-tests.yml' functions: - 'python/packages/azurefunctions/**' - 'python/packages/durabletask/**' azure-ai: - 'python/packages/azure-ai/**' + - 'python/packages/foundry/**' + - 'python/samples/**/providers/foundry/**' cosmos: - 'python/packages/azure-cosmos/**' # run only if 'python' files were changed @@ -100,9 +113,8 @@ jobs: os: ${{ runner.os }} - name: Test with pytest (unit tests only) run: > - uv run poe all-tests + uv run poe test -A -m "not integration" - -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 working-directory: ./python @@ -132,6 +144,8 @@ jobs: OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} defaults: run: @@ -147,8 +161,8 @@ jobs: - name: Test with pytest (OpenAI integration) run: > uv run pytest --import-mode=importlib - packages/core/tests/openai - -m integration + packages/openai/tests + -m "integration and not azure" -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 @@ -181,8 +195,9 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: @@ -206,7 +221,9 @@ jobs: - name: Test with pytest (Azure OpenAI integration) run: > uv run pytest --import-mode=importlib - packages/core/tests/azure + packages/openai/tests/openai/test_openai_chat_completion_client_azure.py + packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/azure-ai/tests/azure_openai -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -254,6 +271,13 @@ jobs: with: python-version: ${{ env.UV_PYTHON }} os: ${{ runner.os }} + - name: Start local MCP server + id: local-mcp + uses: ./.github/actions/setup-local-mcp-server + with: + fallback_url: ${{ env.LOCAL_MCP_URL }} + - name: Prefer local MCP URL when available + run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV" - name: Test with pytest (Anthropic, Ollama, MCP integration) run: > uv run pytest --import-mode=importlib @@ -265,6 +289,26 @@ jobs: --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 working-directory: ./python + - name: Stop local MCP server + if: always() + shell: bash + run: | + set -euo pipefail + server_pid="${{ steps.local-mcp.outputs.pid }}" + if [[ -z "$server_pid" ]]; then + exit 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + exit 0 + fi + kill -TERM -- "-$server_pid" 2>/dev/null || kill -TERM "$server_pid" 2>/dev/null || true + for _ in $(seq 1 10); do + if ! kill -0 "$server_pid" 2>/dev/null; then + exit 0 + fi + sleep 1 + done + kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 @@ -291,10 +335,13 @@ jobs: UV_PYTHON: "3.11" OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -326,7 +373,8 @@ jobs: packages/durabletask/tests/integration_tests -m integration -n logical --dist worksteal - --timeout=120 --session-timeout=900 --timeout_method thread + -x + --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 working-directory: ./python - name: Surface failing tests @@ -353,6 +401,8 @@ jobs: env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -374,7 +424,9 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: | + uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 working-directory: ./python - name: Test Azure AI samples timeout-minutes: 10 diff --git a/.github/workflows/python-sample-validation.yml b/.github/workflows/python-sample-validation.yml index 4a14e6b41b..63f95a78c3 100644 --- a/.github/workflows/python-sample-validation.yml +++ b/.github/workflows/python-sample-validation.yml @@ -41,6 +41,13 @@ jobs: azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env + echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + - name: Run sample validation run: | cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started @@ -50,7 +57,7 @@ jobs: if: always() with: name: validation-report-01-get-started - path: python/scripts/sample_validation/reports/ + path: python/samples/sample_validation/reports/ validate-02-agents: name: Validate 02-agents @@ -64,10 +71,14 @@ jobs: AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + # GitHub MCP + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} # Observability ENABLE_INSTRUMENTATION: "true" defaults: @@ -84,16 +95,420 @@ jobs: azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env + echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=$AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME" >> .env + echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env + echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env + echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "GITHUB_PAT=$GITHUB_PAT" >> .env + - name: Run sample validation run: | - cd scripts && uv run python -m sample_validation --subdir 02-agents --save-report --report-name 02-agents + cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents - name: Upload validation report uses: actions/upload-artifact@v7 if: always() with: name: validation-report-02-agents - path: python/scripts/sample_validation/reports/ + path: python/samples/sample_validation/reports/ + + validate-02-agents-openai: + name: Validate 02-agents/providers/openai + runs-on: ubuntu-latest + environment: integration + env: + OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} + OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Create .env for samples + run: | + echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env + echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env + echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-openai + path: python/samples/sample_validation/reports/ + + validate-02-agents-azure-openai: + name: Validate 02-agents/providers/azure_openai + runs-on: ubuntu-latest + environment: integration + env: + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env + echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure_openai --save-report --report-name 02-agents-azure-openai + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-azure-openai + path: python/samples/sample_validation/reports/ + + validate-02-agents-azure-ai: + name: Validate 02-agents/providers/azure_ai + runs-on: ubuntu-latest + environment: integration + env: + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + BING_CONNECTION_ID: ${{ secrets.BING_CONNECTION_ID }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME=$AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME" >> .env + echo "AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME=$AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME" >> .env + echo "BING_CONNECTION_ID=$BING_CONNECTION_ID" >> .env + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure_ai --save-report --report-name 02-agents-azure-ai + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-azure-ai + path: python/samples/sample_validation/reports/ + + validate-02-agents-azure-ai-agent: + name: Validate 02-agents/providers/azure_ai_agent + runs-on: ubuntu-latest + environment: integration + env: + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure_ai_agent --save-report --report-name 02-agents-azure-ai-agent + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-azure-ai-agent + path: python/samples/sample_validation/reports/ + + validate-02-agents-anthropic: + name: Validate 02-agents/providers/anthropic + runs-on: ubuntu-latest + environment: integration + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Create .env for samples + run: | + echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> .env + echo "ANTHROPIC_CHAT_MODEL_ID=$ANTHROPIC_CHAT_MODEL_ID" >> .env + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-anthropic + path: python/samples/sample_validation/reports/ + + validate-02-agents-github-copilot: + name: Validate 02-agents/providers/github_copilot + runs-on: ubuntu-latest + environment: integration + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-github-copilot + path: python/samples/sample_validation/reports/ + + validate-02-agents-amazon: + name: Validate 02-agents/providers/amazon + if: false # Temporarily disabled - requires AWS credentials + runs-on: ubuntu-latest + environment: integration + env: + BEDROCK_CHAT_MODEL_ID: ${{ vars.BEDROCK__CHATMODELID }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-amazon + path: python/samples/sample_validation/reports/ + + validate-02-agents-ollama: + name: Validate 02-agents/providers/ollama + if: false # Temporarily disabled - requires local Ollama server + runs-on: ubuntu-latest + environment: integration + env: + OLLAMA_MODEL: ${{ vars.OLLAMA__MODEL }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-ollama + path: python/samples/sample_validation/reports/ + + validate-02-agents-foundry-local: + name: Validate 02-agents/providers/foundry_local + if: false # Temporarily disabled - requires local Foundry setup + runs-on: ubuntu-latest + environment: integration + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry_local --save-report --report-name 02-agents-foundry-local + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-foundry-local + path: python/samples/sample_validation/reports/ + + validate-02-agents-copilotstudio: + name: Validate 02-agents/providers/copilotstudio + if: false # Temporarily disabled - requires Copilot Studio setup + runs-on: ubuntu-latest + environment: integration + env: + COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }} + COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }} + COPILOTSTUDIOAGENT__TENANTID: ${{ secrets.COPILOTSTUDIOAGENT__TENANTID }} + COPILOTSTUDIOAGENT__AGENTAPPID: ${{ secrets.COPILOTSTUDIOAGENT__AGENTAPPID }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Create .env for samples + run: | + echo "COPILOTSTUDIOAGENT__ENVIRONMENTID=$COPILOTSTUDIOAGENT__ENVIRONMENTID" >> .env + echo "COPILOTSTUDIOAGENT__SCHEMANAME=$COPILOTSTUDIOAGENT__SCHEMANAME" >> .env + echo "COPILOTSTUDIOAGENT__TENANTID=$COPILOTSTUDIOAGENT__TENANTID" >> .env + echo "COPILOTSTUDIOAGENT__AGENTAPPID=$COPILOTSTUDIOAGENT__AGENTAPPID" >> .env + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-copilotstudio + path: python/samples/sample_validation/reports/ + + validate-02-agents-custom: + name: Validate 02-agents/providers/custom + runs-on: ubuntu-latest + environment: integration + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/sample-validation-setup + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + os: ${{ runner.os }} + + - name: Run sample validation + run: | + cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom + + - name: Upload validation report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-report-02-agents-custom + path: python/samples/sample_validation/reports/ validate-03-workflows: name: Validate 03-workflows @@ -121,6 +536,14 @@ jobs: azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env + echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + - name: Run sample validation run: | cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows @@ -130,11 +553,11 @@ jobs: if: always() with: name: validation-report-03-workflows - path: python/scripts/sample_validation/reports/ + path: python/samples/sample_validation/reports/ validate-04-hosting: name: Validate 04-hosting - if: false # Temporarily disabled because of sample complexity + if: false # Temporarily disabled because of sample complexity runs-on: ubuntu-latest environment: integration env: @@ -169,11 +592,11 @@ jobs: if: always() with: name: validation-report-04-hosting - path: python/scripts/sample_validation/reports/ + path: python/samples/sample_validation/reports/ validate-05-end-to-end: name: Validate 05-end-to-end - if: false # Temporarily disabled because of sample complexity + if: false # Temporarily disabled because of sample complexity runs-on: ubuntu-latest environment: integration env: @@ -213,7 +636,7 @@ jobs: if: always() with: name: validation-report-05-end-to-end - path: python/scripts/sample_validation/reports/ + path: python/samples/sample_validation/reports/ validate-autogen-migration: name: Validate autogen-migration @@ -230,6 +653,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} defaults: run: working-directory: python @@ -244,6 +668,16 @@ jobs: azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env + echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env + echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env + echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + - name: Run sample validation run: | cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration @@ -253,7 +687,7 @@ jobs: if: always() with: name: validation-report-autogen-migration - path: python/scripts/sample_validation/reports/ + path: python/samples/sample_validation/reports/ validate-semantic-kernel-migration: name: Validate semantic-kernel-migration @@ -271,6 +705,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} # Copilot Studio COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }} COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }} @@ -290,6 +725,21 @@ jobs: azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} os: ${{ runner.os }} + - name: Create .env for samples + run: | + echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env + echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env + echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env + echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env + echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env + echo "COPILOTSTUDIOAGENT__ENVIRONMENTID=$COPILOTSTUDIOAGENT__ENVIRONMENTID" >> .env + echo "COPILOTSTUDIOAGENT__SCHEMANAME=$COPILOTSTUDIOAGENT__SCHEMANAME" >> .env + echo "COPILOTSTUDIOAGENT__TENANTID=$COPILOTSTUDIOAGENT__TENANTID" >> .env + echo "COPILOTSTUDIOAGENT__AGENTAPPID=$COPILOTSTUDIOAGENT__AGENTAPPID" >> .env + - name: Run sample validation run: | cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration @@ -299,4 +749,69 @@ jobs: if: always() with: name: validation-report-semantic-kernel-migration - path: python/scripts/sample_validation/reports/ + path: python/samples/sample_validation/reports/ + + aggregate-results: + name: Aggregate Results + runs-on: ubuntu-latest + if: always() + needs: + - validate-01-get-started + - validate-02-agents + - validate-02-agents-openai + - validate-02-agents-azure-openai + - validate-02-agents-azure-ai + - validate-02-agents-azure-ai-agent + - validate-02-agents-anthropic + - validate-02-agents-github-copilot + - validate-02-agents-amazon + - validate-02-agents-ollama + - validate-02-agents-foundry-local + - validate-02-agents-copilotstudio + - validate-02-agents-custom + - validate-03-workflows + - validate-04-hosting + - validate-05-end-to-end + - validate-autogen-migration + - validate-semantic-kernel-migration + steps: + - uses: actions/checkout@v6 + + - name: Download all validation reports + uses: actions/download-artifact@v7 + with: + pattern: validation-report-* + path: reports/ + merge-multiple: true + + - name: Restore validation history + id: cache-restore + uses: actions/cache/restore@v4 + with: + path: validation-history/ + key: validation-history-${{ github.run_id }} + restore-keys: | + validation-history- + + - name: Aggregate results and generate trend report + run: | + python3 python/scripts/sample_validation/aggregate.py \ + reports/ \ + validation-history/history.json \ + trend-report.md + + - name: Write trend report to job summary + run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Save validation history + uses: actions/cache/save@v4 + with: + path: validation-history/ + key: validation-history-${{ github.run_id }} + + - name: Upload trend report + uses: actions/upload-artifact@v7 + if: always() + with: + name: validation-trend-report + path: trend-report.md diff --git a/.github/workflows/python-test-coverage-report.yml b/.github/workflows/python-test-coverage-report.yml index f5f5f8eb03..dbe5b9e9c0 100644 --- a/.github/workflows/python-test-coverage-report.yml +++ b/.github/workflows/python-test-coverage-report.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Download coverage report - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} run-id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/python-test-coverage.yml b/.github/workflows/python-test-coverage.yml index d93e62062f..e14bcb30b8 100644 --- a/.github/workflows/python-test-coverage.yml +++ b/.github/workflows/python-test-coverage.yml @@ -32,13 +32,13 @@ jobs: id: python-setup uses: ./.github/actions/python-setup with: - python-version: ${{ matrix.python-version }} + python-version: ${{ env.UV_PYTHON }} os: ${{ runner.os }} env: # Configure a constant location for the uv cache UV_CACHE_DIR: /tmp/.uv-cache - name: Run all tests with coverage report - run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml + run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml - name: Check coverage threshold run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }} - name: Upload coverage report diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index ba2796a8f5..3e12773090 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -40,7 +40,7 @@ jobs: UV_CACHE_DIR: /tmp/.uv-cache # Unit tests - name: Run all tests - run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }} + run: uv run poe test -A working-directory: ./python # Surface failing tests 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' }} 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 diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md new file mode 100644 index 0000000000..d63f38c734 --- /dev/null +++ b/docs/decisions/0021-agent-skills-design.md @@ -0,0 +1,960 @@ +status: proposed +date: 2026-03-23 +contact: sergeymenshykh +deciders: rbarreto, westey-m, eavanvalkenburg +--- + +# Agent Skills: Multi-Source Architecture + +## Context and Problem Statement + +The Agent Framework needs a skills system that lets agents discover and use domain-specific knowledge, reference documents, and executable scripts. Skills can originate from different sources — filesystem directories (SKILL.md files), inline C# code, or reusable class libraries — and the framework must support all three uniformly while allowing extensibility, composition, and filtering. + +## Decision Drivers + +- Skills must be definable from multiple sources: filesystem, inline code, reusable classes, etc +- Common abstractions are needed so the provider and builder work uniformly regardless of skill origin +- File-based scripts must support user-defined executors, enabling custom runtimes and languages; code/class-based scripts execute in-process as C# delegates +- Skills must be filterable so consumers can include or exclude specific skills based on defined criteria +- Multiple skill sources must be composable into a single provider +- It must be possible to add custom skill sources (e.g., databases, REST APIs, package registries) by implementing a common abstraction + +## Architecture + +### Model-Facing Tools + +Skills are presented to the model as up to three tools that progressively disclose skill content. The system prompt lists available skill names and descriptions; the model then calls these tools on demand: + +- **`load_skill(skillName)`** — returns the full skill body (instructions, listed resources, listed scripts) +- **`read_skill_resource(skillName, resourceName)`** — reads a supplementary resource (file-based or code-defined) associated with a skill +- **`run_skill_script(skillName, scriptName, arguments?)`** — executes a script associated with a skill; only registered when at least one skill contains scripts + +Each tool delegates to the corresponding method on the resolved `AgentSkill` — calling `Resource.ReadAsync()` or `Script.RunAsync()` respectively. + +If skills have no scripts defined, the `run_skill_script` tool is **not advertised** to the model and instructions related to script execution are **not included** in the default skills instructions. + +### Abstract Base Types + +The architecture defines four abstract base types that all skill variants implement: + +```csharp +public abstract class AgentSkill +{ + public abstract AgentSkillFrontmatter Frontmatter { get; } + public abstract string Content { get; } + public abstract IReadOnlyList? Resources { get; } + public abstract IReadOnlyList? Scripts { get; } +} + +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillsSource +{ + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} +``` + +Skill metadata is captured via `AgentSkillFrontmatter`: + +```csharp +public sealed class AgentSkillFrontmatter +{ + public AgentSkillFrontmatter(string name, string description) { ... } + + public string Name { get; } + public string Description { get; } + public string? License { get; set; } + public string? Compatibility { get; set; } + public string? AllowedTools { get; set; } + public AdditionalPropertiesDictionary? Metadata { get; set; } +} +``` + +The type hierarchy at a glance: + +``` +AgentSkill (abstract) AgentSkillsSource (abstract) +├── AgentFileSkill ├── AgentFileSkillsSource (public) +└── [Programmatic] ├── AgentInMemorySkillsSource (public) + ├── AgentInlineSkill ├── AggregatingAgentSkillsSource (public) + └── AgentClassSkill (abstract) └── DelegatingAgentSkillsSource (abstract, public) + ├── FilteringAgentSkillsSource (public) +AgentSkillResource (abstract) ├── CachingAgentSkillsSource (public) +├── AgentFileSkillResource └── DeduplicatingAgentSkillsSource (public) +└── AgentInlineSkillResource + AgentSkillScript (abstract) + ├── AgentFileSkillScript + └── AgentInlineSkillScript +``` + +There are two top-level categories of skills: + +1. **File-Based Skills** — discovered from `SKILL.md` files on the filesystem. Resources and scripts are files in subdirectories. +2. **Programmatic Skills** — defined in C# code. These are further divided into: + - **Inline Skills** — built at runtime via the `AgentInlineSkill` class and its fluent API. Ideal for quick, agent-specific skill definitions. + - **Class-Based Skills** — defined as reusable C# classes that subclass `AgentClassSkill`. Ideal for packaging skills as shared libraries or NuGet packages. + +Both programmatic skill types use `AgentInlineSkillResource` and `AgentInlineSkillScript` for their resources and scripts. They are typically served by `AgentInMemorySkillsSource`, which accepts any `AgentSkill` and is not limited to programmatic skills. + +### File-Based Skills + +File-based skills are authored as `SKILL.md` files on disk. Resources and scripts are discovered from corresponding subfolders within the skill directory. + +**`AgentFileSkill`** — A filesystem-based skill discovered from a directory containing a `SKILL.md` file. Parsed from YAML frontmatter; content is the raw markdown body. Resources and scripts are discovered from files in corresponding subfolders: + +```csharp +public sealed class AgentFileSkill : AgentSkill +{ + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, string content, string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) { ... } +} +``` + +**`AgentFileSkillResource`** — A file-based skill resource. Reads content from a file on disk relative to the skill directory: + +```csharp +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + public AgentFileSkillResource(string name, string fullPath) { ... } + + public string FullPath { get; } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); + } +} +``` + +**`AgentFileSkillScript`** — A file-based skill script that represents a script file on disk. Delegates execution to an external `AgentFileSkillScriptRunner` callback (e.g., runs Python/shell via `Process.Start`). Throws `NotSupportedException` if no executor is configured: + +```csharp +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, AgentFileSkillScript script, + AIFunctionArguments arguments, CancellationToken cancellationToken); + +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptRunner _executor; + + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner executor) + : base(name) { ... } + + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + + return await _executor(fileSkill, this, arguments, cancellationToken); + } +} +``` + +The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.UseFileScriptRunner(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. + +**`AgentFileSkillsSource`** — A skill source that discovers skills from filesystem directories containing `SKILL.md` files. Recursively scans directories (max 2 levels), validates frontmatter, and enforces path traversal and symlink security checks: + +```csharp +public sealed partial class AgentFileSkillsSource : AgentSkillsSource +{ + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +**`AgentFileSkillsSourceOptions`** — Configuration options for `AgentFileSkillsSource`. Allows customizing the allowed file extensions for resources and scripts without adding constructor parameters: + +```csharp +public sealed class AgentFileSkillsSourceOptions +{ + public IEnumerable? AllowedResourceExtensions { get; set; } + public IEnumerable? AllowedScriptExtensions { get; set; } +} +``` + +**Example** — A file-based skill on disk and how it is added to a source: + +``` +skills/ +└── unit-converter/ + ├── SKILL.md # frontmatter + instructions + ├── resources/ + │ └── conversion-table.csv # discovered as a resource + └── scripts/ + └── convert.py # discovered as a script +``` + +```csharp +var source = new AgentFileSkillsSource(skillPaths: ["./skills"], scriptRunner: SubprocessScriptRunner.RunAsync); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +### Programmatic Skills + +Programmatic skills are defined in C# code rather than discovered from the filesystem. There are two kinds: **inline** and **class-based**. Both use `AgentInlineSkillResource` and `AgentInlineSkillScript` for resources and scripts, and are held by a single `AgentInMemorySkillsSource`. + +**`AgentInMemorySkillsSource`** — A general-purpose skill source that holds any `AgentSkill` instances in memory. Although commonly used for programmatic skills (`AgentInlineSkill` and `AgentClassSkill`), it accepts any `AgentSkill` subclass and is not restricted to code-defined skills: + +```csharp +public sealed class AgentInMemorySkillsSource : AgentSkillsSource +{ + public AgentInMemorySkillsSource( + IEnumerable skills, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +#### Inline Skills + +Inline skills are built at runtime via the `AgentInlineSkill` class and its fluent API. They are ideal for quick, agent-specific skill definitions where a full class hierarchy would be overkill. + +**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: + +```csharp +public sealed class AgentInlineSkill : AgentSkill +{ + public AgentInlineSkill(string name, string description, string instructions, string? license = null, string? compatibility = null, ...) { ... } + public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) { ... } + + public AgentInlineSkill AddResource(object value, string name, string? description = null); + public AgentInlineSkill AddResource(Delegate handler, string name, string? description = null); + public AgentInlineSkill AddScript(Delegate handler, string name, string? description = null); +} +``` + +**`AgentInlineSkillResource`** — A skill resource that wraps a static value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(object value, string name, string? description = null) + : base(name, description) + { + _value = value; + } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(_value); + } +} +``` + +**`AgentInlineSkillResource`** — A skill resource backed by a delegate. The delegate is invoked via an `AIFunction` each time `ReadAsync` is called, producing a dynamic (computed) value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return await _function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken); + } +} +``` + +**`AgentInlineSkillScript`** — A skill script backed by a delegate via an `AIFunction`: + +```csharp +public sealed class AgentInlineSkillScript : AgentSkillScript +{ + private readonly AIFunction _function; + + public AgentInlineSkillScript(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public JsonElement? ParametersSchema => _function.JsonSchema; + + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + return await _function.InvokeAsync(arguments, cancellationToken); + } +} +``` + +**Example** — Creating an inline skill with a resource and script, then adding it to a source: + +```csharp +var skill = new AgentInlineSkill( + name: "unit-converter", + description: "Converts between measurement units.", + instructions: """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """ + ) + .AddResource("kg=2.205lb, m=3.281ft, L=0.264gal", "conversion-table", "Supported unit pairs") + .AddScript(Convert, "convert", "Converts a value between units"); + +var source = new AgentInMemorySkillsSource([skill]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); + +static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +``` + +#### Class-Based Skills + +Class-based skills are designed for packaging skills as reusable libraries. Users subclass `AgentClassSkill` and override properties. Unlike inline skills, class-based skills are self-contained, can live in shared libraries or NuGet packages, and are well-suited for dependency injection. + +**`AgentClassSkill`** — An abstract base class for defining skills as reusable C# classes that bundle all skill components (frontmatter, instructions, resources, scripts) together. Designed for packaging skills as distributable libraries: + +```csharp +public abstract class AgentClassSkill : AgentSkill +{ + public abstract string Instructions { get; } + + // Content is auto-synthesized from Frontmatter + Instructions + Resources + Scripts + public override string Content => + SkillContentBuilder.BuildContent(Frontmatter.Name, Frontmatter.Description, + SkillContentBuilder.BuildBody(Instructions, Resources, Scripts)); +} +``` + +**Example** — Defining a class-based skill and adding it to a source: + +```csharp +public class UnitConverterSkill : AgentClassSkill +{ + public override AgentSkillFrontmatter Frontmatter { get; } = + new("unit-converter", "Converts between measurement units."); + + public override string Instructions => """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """; + + public override IReadOnlyList? Resources { get; } = + [ + new AgentInlineSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"), + ]; + + public override IReadOnlyList? Scripts { get; } = + [ + new AgentInlineSkillScript(Convert, "convert"), + ]; + + private static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +} + +var source = new AgentInMemorySkillsSource([new UnitConverterSkill()]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Filtering, Caching, and Deduplication + +The following subsections present alternative approaches for handling filtering, caching, and deduplication of skills across multiple sources. + +### Via Composition + +In this approach, the `AgentSkillsProvider` accepts a **single** `AgentSkillsSource`. Multiple sources are composed externally via an aggregate source, and cross-cutting concerns like filtering, caching, and deduplication are implemented as **source decorators** — subclasses of `DelegatingAgentSkillsSource` that intercept `GetSkillsAsync()`. + +**`FilteringAgentSkillsSource`** — A decorator that applies filter logic before returning results. The decorator pattern keeps filtering orthogonal to source implementations and allows composing multiple filters: + +```csharp +public sealed class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + + public FilteringAgentSkillsSource(AgentSkillsSource innerSource, Func predicate) + : base(innerSource) + { + _predicate = predicate; + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var skills = await this.InnerSource.GetSkillsAsync(cancellationToken); + return skills.Where(_predicate).ToList(); + } +} +``` + +**`CachingAgentSkillsSource`** — A decorator that caches skills after the first load, keeping the provider stateless and giving consumers control over caching granularity per source. For example, file-based skills (expensive to discover) can be cached while code-defined skills remain uncached: + +```csharp +public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private IList? _cached; + + public CachingAgentSkillsSource(AgentSkillsSource innerSource) + : base(innerSource) + { + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return _cached ??= await this.InnerSource.GetSkillsAsync(cancellationToken); + } +} +``` + +**Deduplication** is similarly implemented as a decorator (`DeduplicatingAgentSkillsSource`) that deduplicates by name (case-insensitive, first-one-wins) and logs a warning for skipped duplicates. + +**Example** — Combining file-based and code-defined sources with filtering and caching: + +```csharp +var fileSource = new CachingAgentSkillsSource(new AgentFileSkillsSource(["./skills"])); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); + +var compositeSource = new FilteringAgentSkillsSource( + new AggregatingAgentSkillsSource([fileSource, codeSource]), + filter: s => s.Frontmatter.Name != "internal"); + +var provider = new AgentSkillsProvider(compositeSource); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- Clean single-responsibility: the provider serves skills, sources provide them. +- Caching, filtering, and deduplication are composable as source decorators — each concern is a separate, testable wrapper. + +**Cons:** +- DI is less flexible: multiple `AgentSkillsSource` implementations registered in the container cannot be auto-injected into the provider. The consumer must manually compose them via an aggregate source. +- Increased public API surface: requires additional public classes (aggregate source, caching decorators, filtering decorators) that consumers need to learn and use. + +### Via AgentSkillsProvider + +In this approach, the `AgentSkillsProvider` accepts **`IEnumerable`** and handles aggregation, filtering, caching, and deduplication internally. + +The provider aggregates skills from all registered sources, deduplicates by name (case-insensitive, first-one-wins), caches the result after the first load, and optionally applies filtering via a predicate on `AgentSkillsProviderOptions`. Duplicate skill names are logged as warnings. + +**Example** — Registering multiple sources directly with the provider: + +```csharp +// Conceptual example — in practice, use AgentSkillsProviderBuilder +var fileSource = new AgentFileSkillsSource(["./skills"]); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); + +var provider = new AgentSkillsProvider( + sources: [fileSource, codeSource], + options: new AgentSkillsProviderOptions + { + Filter = s => s.Frontmatter.Name != "internal", + }); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- DI-friendly: register multiple `AgentSkillsSource` implementations in the container, and they are all auto-injected into `AgentSkillsProvider` via `IEnumerable`. +- Smaller public API surface: no need for aggregate source, caching decorators, or filtering decorator classes — these concerns are handled internally by the provider. + +**Cons:** +- The provider takes on multiple responsibilities — aggregation, caching, deduplication, and filtering. +- Less granular caching control: caching is all-or-nothing across sources rather than per-source as with decorators. +- Less extensible: new behaviors (e.g., ordering, TTL expiration) require modifying the provider rather than adding a decorator. + +### Builder Pattern + +**`AgentSkillsProviderBuilder`** provides a fluent API for composing skills from multiple sources. The builder centralizes configuration — script executors, approval callbacks, prompt templates, and filtering — so consumers don't need to know the underlying source types. + +The builder internally decides how to wire up the object graph: it creates the appropriate source instances, applies caching and filtering, and returns a fully configured `AgentSkillsProvider`. This keeps the setup code concise while still allowing fine-grained control when needed. + +**Example** — Using the builder to combine multiple source types with configuration: + +```csharp +var provider = new AgentSkillsProviderBuilder() + .UseFileSkill("./skills") // file-based source + .UseInlineSkills(codeSkill) // code-defined source + .UseClassSkills(new ClassSkill()) // class-based source + .UseFileScriptRunner(SubprocessScriptRunner.RunAsync) // script runner + .UseScriptApproval() // optional human-in-the-loop + .UsePromptTemplate(customTemplate) // optional prompt customization + .UseFilter(s => s.Frontmatter.Name != "internal") // optional skill filtering + .Build(); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Adding a Custom Skill Type + +The skills framework is designed for extensibility. While file-based and inline skills cover common +scenarios, you can introduce entirely new skill types by subclassing the four base classes: + +| Base class | Purpose | +|-----------------------|-----------------------------------------------------| +| `AgentSkillsSource` | Discovers and loads skills from a particular origin | +| `AgentSkill` | Holds metadata, content, resources, and scripts | +| `AgentSkillResource` | Provides supplementary content to a skill | +| `AgentSkillScript` | Represents an executable action within a skill | + +The example below implements a **cloud-based skill type** where skills, resources, and scripts are +all stored in and executed through a remote cloud service (e.g., Azure Blob Storage + Azure Functions). + +### Step 1 — Define a custom resource + +A `CloudSkillResource` reads resource content from a cloud storage endpoint instead of the local +filesystem: + +```csharp +/// +/// A skill resource backed by a cloud storage endpoint. +/// +public sealed class CloudSkillResource : AgentSkillResource +{ + private readonly HttpClient _httpClient; + + public CloudSkillResource(string name, Uri blobUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + BlobUri = blobUri ?? throw new ArgumentNullException(nameof(blobUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud blob that holds this resource's content. + /// + public Uri BlobUri { get; } + + /// + public override async Task ReadAsync( + IServiceProvider? serviceProvider = null, + CancellationToken cancellationToken = default) + { + return await _httpClient.GetStringAsync(BlobUri, cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 2 — Define a custom script + +A `CloudSkillScript` executes a script by calling a cloud function endpoint, passing arguments as +the request body: + +```csharp +/// +/// A skill script executed via a cloud function endpoint. +/// +public sealed class CloudSkillScript : AgentSkillScript +{ + private readonly HttpClient _httpClient; + + public CloudSkillScript(string name, Uri functionUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + FunctionUri = functionUri ?? throw new ArgumentNullException(nameof(functionUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud function that runs this script. + /// + public Uri FunctionUri { get; } + + /// + public override async Task RunAsync( + AgentSkill skill, + AIFunctionArguments arguments, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(arguments); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(FunctionUri, content, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 3 — Define a custom skill + +A `CloudSkill` bundles cloud-specific metadata (e.g., the base endpoint) with the standard skill +shape: + +```csharp +/// +/// An whose content, resources, and scripts are stored in a cloud service. +/// +public sealed class CloudSkill : AgentSkill +{ + public CloudSkill( + AgentSkillFrontmatter frontmatter, + string content, + Uri endpoint, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + Frontmatter = frontmatter ?? throw new ArgumentNullException(nameof(frontmatter)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + Resources = resources; + Scripts = scripts; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the base cloud endpoint for this skill. + /// + public Uri Endpoint { get; } + + /// + public override IReadOnlyList? Resources { get; } + + /// + public override IReadOnlyList? Scripts { get; } +} +``` + +### Step 4 — Define a custom source + +A `CloudSkillsSource` discovers skills from a cloud catalog API and constructs `CloudSkill` +instances with their associated resources and scripts: + +```csharp +/// +/// A skill source that discovers and loads skills from a cloud catalog API. +/// +public sealed class CloudSkillsSource : AgentSkillsSource +{ + private readonly Uri _catalogUri; + private readonly HttpClient _httpClient; + + public CloudSkillsSource(Uri catalogUri, HttpClient httpClient) + { + _catalogUri = catalogUri ?? throw new ArgumentNullException(nameof(catalogUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + public override async Task> GetSkillsAsync( + CancellationToken cancellationToken = default) + { + // Fetch the skill catalog from the cloud service. + var json = await _httpClient.GetStringAsync(_catalogUri, cancellationToken) + .ConfigureAwait(false); + var catalog = JsonSerializer.Deserialize(json)!; + + var skills = new List(); + + foreach (var entry in catalog.Skills) + { + var frontmatter = new AgentSkillFrontmatter(entry.Name, entry.Description); + + // Build cloud-backed resources. + var resources = entry.Resources + .Select(r => new CloudSkillResource(r.Name, r.BlobUri, _httpClient, r.Description)) + .ToList(); + + // Build cloud-backed scripts. + var scripts = entry.Scripts + .Select(s => new CloudSkillScript(s.Name, s.FunctionUri, _httpClient, s.Description)) + .ToList(); + + skills.Add(new CloudSkill(frontmatter, entry.Content, entry.Endpoint, resources, scripts)); + } + + return skills; + } +} +``` + +### Step 5 — Register with the builder + +Use `UseSource` to wire the custom source into the provider: + +```csharp +var httpClient = new HttpClient(); + +var provider = new AgentSkillsProviderBuilder() + .UseSource(new CloudSkillsSource( + new Uri("https://my-service.example.com/skills/catalog"), + httpClient)) + // Mix with other source types if needed: + .UseFileSkill("/local/skills", scriptRunner) + .UseInlineSkills(someInlineSkill) + .Build(); +``` + +The `AgentSkillsProvider` handles all skill types uniformly — any combination of file-based, inline, +class-based, and custom skills can coexist in the same provider. Custom skills automatically +participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`), +filtering, deduplication, and caching — no additional integration work is required. + +## Script Representation: `AgentSkillScript` vs `AIFunction` + +Two approaches were considered for representing executable scripts within skills: + +### Option A — Custom `AgentSkillScript` abstract base class (original design) + +Scripts are modeled as a custom `AgentSkillScript` abstract class with `Name`, `Description`, and +`RunAsync(AgentSkill, AIFunctionArguments, CancellationToken)`. Concrete implementations: +`AgentInlineSkillScript` (wraps a delegate/`AIFunction`) and `AgentFileSkillScript` (wraps a file path + executor delegate). + +```csharp +// Base type +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes scripts as: +public abstract IReadOnlyList? Scripts { get; } + +// Inline script wraps an AIFunction internally +var script = new AgentInlineSkillScript(ConvertUnits, "convert"); + +// Pre-built AIFunction must be wrapped +var script = new AgentInlineSkillScript(myAIFunction); + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + new AgentInlineSkillScript(ConvertUnits, "convert"), +]; + +// Provider executes scripts by passing the owning skill: +await script.RunAsync(skill, arguments, cancellationToken); +``` + +**Pros:** + +- **Explicit skill context at execution time.** `RunAsync` receives the owning `AgentSkill`, so any script can access skill metadata or resources during execution without requiring construction-time wiring. +- **Self-contained abstraction.** A dedicated type communicates clearly that scripts are a skills-framework concept, separate from general-purpose AI functions. +- **Easier extensibility for custom script types.** Third-party implementations can subclass `AgentSkillScript` and access the owning skill in `RunAsync` without special setup. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillScript` is a thin pass-through around `AIFunction` — it adds a class, a constructor, and an indirection layer for no behavioral difference. +- **Parallel abstraction.** `AgentSkillScript` and `AIFunction` serve overlapping purposes (named callable with arguments), creating two parallel hierarchies for the same concept. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillScript` to use them as scripts, adding ceremony. + +### Option B — Reuse `AIFunction` directly + +Scripts are represented as `AIFunction` (from `Microsoft.Extensions.AI`). `AgentSkill.Scripts` returns +`IReadOnlyList?`. `AgentInlineSkillScript` is eliminated entirely — callers use +`AIFunctionFactory.Create(delegate, name: ...)` or pass `AIFunction` instances directly. +`AgentFileSkillScript` becomes an `AIFunction` subclass that captures its owning `AgentFileSkill` via +an internal back-reference set during construction. + +```csharp +// AgentSkill exposes scripts as AIFunction directly: +public abstract IReadOnlyList? Scripts { get; } + +// Inline scripts use AIFunctionFactory — no wrapper class needed +var skill = new AgentInlineSkill("my-skill", "desc", "instructions"); +skill.AddScript(ConvertUnits, "convert"); // delegate +skill.AddScript(myAIFunction); // pre-built AIFunction — no wrapping + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + AIFunctionFactory.Create(ConvertUnits, name: "convert"), +]; + +// Provider executes scripts via standard AIFunction invocation: +await script.InvokeAsync(arguments, cancellationToken); + +// File-based scripts extend AIFunction and capture the owning skill internally: +public sealed class AgentFileSkillScript : AIFunction +{ + internal AgentFileSkill? Skill { get; set; } // set by AgentFileSkill constructor + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await _executor(Skill!, this, arguments, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer types.** Eliminates `AgentSkillScript` and `AgentInlineSkillScript`, reducing the public API surface by two classes. +- **Seamless interop.** Any `AIFunction` — whether from `AIFunctionFactory`, a custom subclass, or an external library — can be used as a skill script with zero wrapping. +- **Consistent with `Microsoft.Extensions.AI` ecosystem.** Scripts share the same type as tool functions used by `IChatClient` and `FunctionInvokingChatClient`, reducing conceptual overhead for developers already familiar with the ecosystem. + +**Cons:** + +- **No owning-skill context in invocation signature.** `AIFunction.InvokeAsync` does not accept an `AgentSkill` parameter, so `AgentFileSkillScript` must capture its owning skill via an internal setter during construction. This adds a construction-order dependency: the skill must set the back-reference on its scripts. +- **Custom script types lose automatic skill access.** Third-party `AIFunction` subclasses that need the owning skill must implement their own mechanism (e.g., constructor injection, closure capture) instead of receiving it as a method parameter. +- **Semantic overloading.** `AIFunction` now means both "a tool the model can call" and "a script within a skill", which could blur the distinction for framework users. + +## Resource Representation: `AgentSkillResource` vs `AIFunction` + +Two approaches were considered for representing skill resources (supplementary content such as references, assets, or dynamic data): + +### Option A — Custom `AgentSkillResource` abstract base class (original design) + +Resources are modeled as a custom `AgentSkillResource` abstract class with `Name`, `Description`, and +`ReadAsync(IServiceProvider?, CancellationToken)`. Concrete implementations: +`AgentInlineSkillResource` (static value, delegate, or `AIFunction` wrapper) and `AgentFileSkillResource` (reads file content from disk). + +```csharp +// Base type +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes resources as: +public abstract IReadOnlyList? Resources { get; } + +// Static resource +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource (delegate) +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction must be wrapped +var resource = new AgentInlineSkillResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via: +await resource.ReadAsync(serviceProvider, cancellationToken); +``` + +**Pros:** + +- **Clear semantic distinction.** A dedicated `AgentSkillResource` type distinguishes resources (data providers) from scripts (executable actions), making the API self-documenting. +- **Purpose-built API.** `ReadAsync` communicates intent better than `InvokeAsync` for a data-access operation. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillResource` wraps `AIFunction` internally for delegate/function cases — adding a class and indirection for no behavioral difference. +- **Parallel abstraction.** `AgentSkillResource` and `AIFunction` serve overlapping purposes (named callable that returns data), creating two parallel hierarchies. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillResource`, adding ceremony. + +### Option B — Reuse `AIFunction` directly + +Resources are represented as `AIFunction`. `AgentSkill.Resources` returns `IReadOnlyList?`. +`AgentInlineSkillResource` becomes an `AIFunction` subclass (retained as a convenience for the static-value +pattern: `new AgentInlineSkillResource("data", "name")`). `AgentFileSkillResource` becomes an `AIFunction` +subclass that reads file content. + +```csharp +// AgentSkill exposes resources as AIFunction directly: +public abstract IReadOnlyList? Resources { get; } + +// Static resource — AgentInlineSkillResource is retained as a convenience AIFunction subclass +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource — AgentInlineSkillResource wraps delegate as AIFunction +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction can be used directly — no wrapping needed +skill.AddResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via standard AIFunction invocation: +await resource.InvokeAsync(arguments, cancellationToken); + +// File-based resources extend AIFunction directly: +internal sealed class AgentFileSkillResource : AIFunction +{ + public string FullPath { get; } + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer base types.** Eliminates the `AgentSkillResource` abstract class, reducing the public API surface. +- **Seamless interop.** Any `AIFunction` can be used as a skill resource with zero wrapping. + +**Cons:** + +- **Loss of semantic distinction.** Resources and scripts are now both `AIFunction`, which could make it less obvious which list a function belongs to when reading code. +- **Static values require a wrapper.** Unlike the original `ReadAsync` which could return a stored value directly, `AIFunction.InvokeAsync` implies invocation. `AgentInlineSkillResource` is retained as a convenience subclass to handle the static-value case, so this is not eliminated — just moved to a different class. + +## Decision Outcome + +### 1. Keep `AgentSkillResource` and `AgentSkillScript` (Option A for both sections) + +We are staying with the custom `AgentSkillResource` and `AgentSkillScript` model classes instead of reusing `AIFunction`: + +- **Resources have no parameters.** If a consumer provides an `AIFunction` with parameters, those parameters will never be advertised to the LLM, and the resulting call will fail. +- **Approval breaks for `AIFunction`-based representations.** When a resource or script represented by an `AIFunction` is configured with approval, the second approval invocation will not work correctly. +- **Injecting the owning skill into an `AIFunction`-based script is problematic.** Constructor injection would introduce a circular reference between the skill and the script. An internal property setter is possible but adds coupling. + +### 2. Make all agent skill classes internal + +All agent-skill-related classes are made `internal` to minimize the public API surface while the feature matures. We can reconsider and promote types to `public` later based on community signal. + +This leaves two public entry points: + +- **`AgentSkillsProvider`** — use directly when all skills come from a single source and filtering is not needed. +- **`AgentSkillsProviderBuilder`** — use when mixing skill types or when filtering support is required. + +### 3. Caching at provider level + +Caching of tools and instructions is implemented inside `AgentSkillsProvider` rather than as an external decorator. Recreating tools and instructions on every provider call is wasteful, and a caching decorator sitting outside the provider would not have the information needed to cache them effectively. diff --git a/docs/decisions/0021-provider-leading-clients.md b/docs/decisions/0021-provider-leading-clients.md new file mode 100644 index 0000000000..1dcc334209 --- /dev/null +++ b/docs/decisions/0021-provider-leading-clients.md @@ -0,0 +1,72 @@ +--- +status: accepted +contact: eavanvalkenburg +date: 2026-03-20 +deciders: eavanvalkenburg, sphenry, chetantoshnival +consulted: taochenosu, moonbox3, dmytrostruk, giles17, alliscode +--- + +# Provider-Leading Client Design & OpenAI Package Extraction + +## Context and Problem Statement + +The `agent-framework-core` package currently bundles OpenAI and Azure OpenAI client implementations along with their dependencies (`openai`, `azure-identity`, `azure-ai-projects`, `packaging`). This makes core heavier than necessary for users who don't use OpenAI, and it conflates the core abstractions with a specific provider implementation. Additionally, the current class naming (`OpenAIResponsesClient`, `OpenAIChatClient`) is based on the underlying OpenAI API names rather than what users actually want to do, making discoverability harder for newcomers. + +## Decision Drivers + +- **Lightweight core**: Core should only contain abstractions, middleware infrastructure, and telemetry — no provider-specific code or dependencies. +- **Discoverability-first**: Import namespaces should guide users to the right client. `from agent_framework.openai import ...` should surface all OpenAI-related clients; `from agent_framework.azure import ...` should surface Foundry, Azure AI, and other Azure-specific classes. +- **Provider-leading naming**: The primary client name should reflect the provider, not the underlying API. The Responses API is now the recommended default for OpenAI, so its client should be called `OpenAIChatClient` (not `OpenAIResponsesClient`). +- **Clean separation of concerns**: Azure-specific deprecated wrappers belong in the azure-ai package, not in the OpenAI package. + +## Considered Options + +- **Keep OpenAI in core**: Simpler but keeps core heavy; doesn't help discoverability. +- **Extract OpenAI with Azure wrappers in the OpenAI package**: Keeps Azure OpenAI wrappers alongside OpenAI code, but pollutes the OpenAI package with Azure concerns. +- **Extract OpenAI, place Azure wrappers in azure-ai**: Clean separation; the OpenAI package has zero Azure dependencies; deprecated Azure wrappers live in a single file in azure-ai for easy future deletion. + +## Decision Outcome + +Chosen option: "Extract OpenAI, place Azure wrappers in azure-ai", because it achieves the lightest core, cleanest OpenAI package, and the most maintainable deprecation path. + +Key changes: + +1. **New `agent-framework-openai` package** with dependencies on `agent-framework-core`, `openai`, and `packaging` only. +2. **Class renames**: `OpenAIResponsesClient` → `OpenAIChatClient` (Responses API), `OpenAIChatClient` → `OpenAIChatCompletionClient` (Chat Completions API). Old names remain as deprecated aliases. +3. **Deprecated classes**: `OpenAIAssistantsClient`, all `AzureOpenAI*Client` classes, `AzureAIClient`, `AzureAIAgentClient`, and `AzureAIProjectAgentProvider` are marked deprecated. +4. **New `FoundryChatClient`** in azure-ai for Azure AI Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`. +5. **All deprecated `AzureOpenAI*` classes** consolidated into a single file (`_deprecated_azure_openai.py`) in the azure-ai package for clean future deletion. +6. **Core's `agent_framework.openai` and `agent_framework.azure` namespaces** become lazy-loading gateways, preserving backward-compatible import paths while removing hard dependencies. +7. **Unified `model` parameter** replaces `model_id` (OpenAI), `deployment_name` (Azure OpenAI), and `model_deployment_name` (Azure AI) across all client constructors. The term `model` is intentionally generic: it naturally maps to an OpenAI model name *and* to an Azure OpenAI deployment name, making it straightforward to use `OpenAIChatClient` with either OpenAI or Azure OpenAI backends (via `AsyncAzureOpenAI`). Environment variables are similarly unified (e.g., `OPENAI_MODEL` instead of separate `OPENAI_RESPONSES_MODEL_ID` / `OPENAI_CHAT_MODEL_ID`). +8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Azure AI Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale. + +### Foundry Agent Design: `FoundryAgentClient` vs `FoundryAgent` + +The existing `AzureAIClient` combines two concerns: CRUD lifecycle management (creating/deleting agents on the service) and runtime communication (sending messages via the Responses API). The new design removes CRUD entirely — users connect to agents that already exist in Foundry. + +**Two approaches were considered:** + +**Option A — `FoundryAgentClient` only (public ChatClient):** +Users compose `Agent(client=FoundryAgentClient(...), tools=[...])`. This follows the universal `Agent(client=X)` pattern used by every other provider. However, a "client" that wraps a named remote agent (with `agent_name` as a constructor param) is semantically odd — clients typically wrap a model endpoint, not a specific agent. + +**Option B — `FoundryAgent` (Agent subclass) + private `_FoundryAgentChatClient` and public `RawFoundryAgentChatClient`:** +Users write `FoundryAgent(agent_name="my-agent", ...)` for the common case. Internally, `FoundryAgent` creates a `_FoundryAgentChatClient` and passes it to the standard `Agent` base class. For advanced customization, users pass `client_type=RawFoundryAgentChatClient` (or a custom subclass) to control the client middleware layers. The `Agent(client=RawFoundryAgentChatClient(...))` composition pattern still works for users who prefer it. + +**Chosen option: Option B**, because: +- The common case (`FoundryAgent(...)`) is a single object with no boilerplate. +- `client_type=` gives full control over client middleware without parameter duplication — the agent forwards connection params to the client internally. +- `RawFoundryAgent(RawAgent)` and `FoundryAgent(Agent)` mirror the established `RawAgent`/`Agent` pattern. +- Runtime validation (only `FunctionTool` allowed) lives in `RawFoundryAgentChatClient._prepare_options`, ensuring it applies regardless of how the client is used — through `FoundryAgent`, `Agent(client=...)`, or any custom composition. + +**Public classes:** +- `RawFoundryAgentChatClient(RawOpenAIChatClient)` — Responses API client that injects agent reference and validates tools. Extension point for custom client middleware. +- `RawFoundryAgent(RawAgent)` — Agent without agent-level middleware/telemetry. +- `FoundryAgent(AgentTelemetryLayer, AgentMiddlewareLayer, RawFoundryAgent)` — Recommended production agent. + +**Internal (private):** +- `_FoundryAgentChatClient` — Full client with function invocation, chat middleware, and telemetry layers. Created automatically by `FoundryAgent`; users customize via `client_type=RawFoundryAgentChatClient` or a custom subclass. + +**Deprecated:** +- `AzureAIClient` — replaced by `FoundryAgent` (which uses `FoundryAgentClient` internally). +- `AzureAIAgentClient` — refers to V1 Agents Service API, no direct replacement. +- `AzureAIProjectAgentProvider` — replaced by `FoundryAgent`. diff --git a/docs/decisions/0022-chat-history-persistence-consistency.md b/docs/decisions/0022-chat-history-persistence-consistency.md new file mode 100644 index 0000000000..2c760e6614 --- /dev/null +++ b/docs/decisions/0022-chat-history-persistence-consistency.md @@ -0,0 +1,116 @@ +--- +status: accepted +contact: westey-m +date: 2026-03-23 +deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub +consulted: +informed: +--- + +# Chat History Persistence Consistency + +## Context and Problem Statement + +When using `ChatClientAgent` with tools, the `FunctionInvokingChatClient` (FIC) loops multiple times — service call → tool execution → service call → … — before producing a final response. There are two points of discrepancy between how chat history is stored by the framework's `ChatHistoryProvider` and how the underlying AI service stores chat history (e.g., OpenAI Responses with `store=true`): + +1. **Persistence timing**: The AI service persists messages after *each* service call within the FIC loop. The `ChatHistoryProvider` currently persists messages only once, at the *end* of the full agent run (after all FIC loop iterations complete). + +2. **Trailing `FunctionResultContent` storage**: When tool calling is terminated mid-loop (e.g., via `FunctionInvokingChatClient` termination filters), the final response from the agent may contain `FunctionResultContent` that was never sent to a subsequent service call. The AI service never stores this trailing `FunctionResultContent`, but the `ChatHistoryProvider` currently stores all response content, including the trailing `FunctionResultContent`. + +These discrepancies mean that a `ChatHistoryProvider`-managed conversation and a service-managed conversation can diverge in content and structure, even when processing the same interactions. + +### Practical Impact: Resuming After Tool-Call Termination + +Today, users of `AIAgent` get different behaviors depending on whether chat history is stored service-side or in a `ChatHistoryProvider`. This creates concrete challenges — for example, when the function call loop is terminated and the user wants to resume the conversation in a subsequent run. With service-stored history, the trailing `FunctionResultContent` is never persisted, so the last stored message is the `FunctionCallContent` from the service. With `ChatHistoryProvider`-stored history, the trailing `FunctionResultContent` *is* persisted. The user cannot know whether the last `FunctionResultContent` is in the chat history or not without inspecting the storage mechanism, making it difficult to write resumption logic that works correctly regardless of the storage backend. + +### Relationship Between the Two Discrepancies + +The persistence timing and `FunctionResultContent` trimming behaviors are interrelated: + +- **Per-service-call persistence**: When messages are persisted after each individual service call, trailing `FunctionResultContent` trimming is unnecessary. If tool calling is terminated, the `FunctionResultContent` from the terminated call was never sent to a subsequent service call, so it is never persisted. The per-service-call approach naturally matches the service's behavior. + +- **Per-run persistence**: When messages are batched and persisted at the end of the full run, trailing `FunctionResultContent` trimming becomes necessary to match the service's behavior. Without trimming, the stored history contains `FunctionResultContent` that the service would never have stored. + +This means the trimming feature (introduced in [PR #4792](https://github.com/microsoft/agent-framework/pull/4792)) is primarily needed as a complement to per-run persistence. The `PersistChatHistoryAtEndOfRun` setting (introduced in [PR #4762](https://github.com/microsoft/agent-framework/pull/4762)) inverts the default so that per-service-call persistence is the standard behavior, and per-run persistence is opt-in. + +## Decision Drivers + +- **A. Consistency**: The default behavior of `ChatHistoryProvider` should produce stored history that closely matches what the underlying AI service would store, minimizing surprise when switching between framework-managed and service-managed chat history. +- **B. Atomicity**: A run that fails mid-way through a multi-step tool-calling loop should not leave chat history in a partially-updated state, unless the user explicitly opts into that behavior. +- **C. Recoverability**: For long-running tool-calling loops, it should be possible to recover intermediate progress if the process is interrupted, rather than losing all work from the current run. +- **D. Simplicity**: The default behavior should be easy to understand and predict for most users, without requiring knowledge of the FIC loop internals. +- **E. Flexibility**: Regardless of the chosen default, users should be able to opt into the alternative behavior. + +## Considered Options + +- Option 1: Default to per-run persistence with `FunctionResultContent` trimming (opt-in to per-service-call) +- Option 2: Default to per-service-call persistence (opt-in to per-run) + +## Pros and Cons of the Options + +### Option 1: Default to per-run persistence with `FunctionResultContent` trimming + +Keep the current default behavior of persisting chat history only at the end of the full agent run. Add `FunctionResultContent` trimming as the default to improve consistency with service storage. Provide an opt-in setting for users who want per-service-call persistence. + +Settings: +- `PersistChatHistoryAtEndOfRun` = `true` + +- Good, because runs are atomic — chat history is only updated when the full run succeeds, satisfying driver B. +- Good, because the mental model is simple: one run = one history update, satisfying driver D. +- Good, because trimming trailing `FunctionResultContent` improves consistency with service storage, partially satisfying driver A. +- Good, because users can opt in to per-service-call persistence for checkpointing/recovery scenarios, satisfying drivers C and E. +- Bad, because the default persistence timing still differs from the service's behavior (per-run vs. per-service-call), only partially satisfying driver A. +- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C by default. + +### Option 2: Default to per-service-call persistence + +Change the default to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). Provide an opt-in setting for users who want per-run atomicity with trimming. + +Settings: +- `PersistChatHistoryAtEndOfRun` = `false` (default) + +- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying driver A. +- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C. +- Good, because no separate `FunctionResultContent` trimming logic is needed, reducing complexity. +- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), not satisfying driver B. A subsequent run cannot proceed without manually providing the missing `FunctionResultContent`. +- Bad, because the mental model is more complex: a single run may produce multiple history updates, partially failing driver D. +- Neutral, because users can opt out to per-run persistence if they prefer atomicity, satisfying driver E. + +## Decision Outcome + +Chosen option: **Option 2 — Default to per-service-call persistence**, because it fully satisfies the consistency driver (A), naturally handles `FunctionResultContent` trimming without additional logic, and provides better recoverability for long-running tool-calling loops. Per-run persistence remains available via the `PersistChatHistoryAtEndOfRun` setting for users who prefer atomic run semantics. + +### Configuration Matrix + +The behavior depends on the combination of `UseProvidedChatClientAsIs` and `PersistChatHistoryAtEndOfRun`: + +| `UseProvidedChatClientAsIs` | `PersistChatHistoryAtEndOfRun` | Behavior | +|---|---|---| +| `false` (default) | `false` (default) | **Per-service-call persistence.** A `ChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. | +| `true` | `false` | **User responsibility.** No middleware is injected because the user has provided a custom chat client stack. The user is responsible for ensuring correct persistence behavior (e.g., by including their own persisting middleware). | +| `false` | `true` | **Per-run persistence with marking.** A `ChatHistoryPersistingChatClient` middleware is injected, but configured to *mark* messages with metadata rather than store them immediately. At the end of the run, marked messages are stored. Trailing `FunctionResultContent` is trimmed. | +| `true` | `true` | **Per-run persistence with warning.** The system checks whether the custom chat client stack includes a `ChatHistoryPersistingChatClient`. If not, a warning is emitted (particularly relevant for workflow handoff scenarios where trimming cannot be guaranteed). If no `ChatHistoryPersistingChatClient` is preset, all messages are stored at the end of the run, otherwise marked messages are stored. | + +### Consequences + +- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying consistency (driver A). +- Good, because intermediate progress is preserved if the process is interrupted, satisfying recoverability (driver C). +- Good, because no separate `FunctionResultContent` trimming logic is needed in the default path, reducing complexity. +- Good, because marking persisted messages with metadata enables deduplication and aids debugging. +- Good, because warnings for custom chat client configurations without the persisting middleware help prevent silent failures in workflow handoff scenarios. +- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases. +- Bad, because the mental model is more complex for the default path: a single run may produce multiple history updates. +- Neutral, because users who prefer atomic run semantics can opt in to per-run persistence via `PersistChatHistoryAtEndOfRun = true`. +- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator. + +### Implementation Notes + +#### Conversation ID Consistency + +The `ChatHistoryPersistingChatClient` middleware must also update the session's `ConversationId` consistently for both response-based and conversation-based service interactions, ensuring the session always reflects the latest service-provided identifier. + +## More Information + +- [PR #4762: Persist messages during function call loop](https://github.com/microsoft/agent-framework/pull/4762) — introduces `PersistChatHistoryAfterEachServiceCall` option and `ChatHistoryPersistingChatClient` decorator +- [PR #4792: Trim final FRC to match service storage](https://github.com/microsoft/agent-framework/pull/4792) — introduces `StoreFinalFunctionResultContent` option and `FilterFinalFunctionResultContent` logic +- [Issue #2889](https://github.com/microsoft/agent-framework/issues/2889) — original issue tracking chat history persistence during function call loops diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 3209ed7874..cde0247dbd 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -22,11 +22,10 @@ - - - - - + + + + @@ -43,12 +42,12 @@ - + - - - + + + @@ -67,25 +66,26 @@ - - - - + + + + - + - - - - - - - - + + + + + + + + + - - - + + + @@ -114,9 +114,9 @@ - + - + @@ -152,6 +152,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 86ef805024..8975fb072e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -67,6 +67,7 @@ + @@ -86,6 +87,8 @@ + + @@ -111,7 +114,7 @@ - + @@ -319,7 +322,6 @@ - @@ -463,6 +465,10 @@ + + + + diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index 94ac5b417b..1a3feb4c4f 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -29,4 +29,7 @@ + + + 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/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs index 69d71e7b88..8e1f4245b6 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs @@ -18,6 +18,7 @@ using OpenTelemetry.Trace; #region Setup Telemetry +// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories. const string SourceName = "OpenTelemetryAspire.ConsoleApp"; const string ServiceName = "AgentOpenTelemetry"; @@ -40,7 +41,6 @@ var resource = ResourceBuilder.CreateDefault() var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) .AddSource(SourceName) // Our custom activity source - .AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry .AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)); @@ -54,8 +54,7 @@ using var tracerProvider = tracerProviderBuilder.Build(); // Setup metrics with resource and instrument name filtering using var meterProvider = Sdk.CreateMeterProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) - .AddMeter(SourceName) // Our custom meter - .AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics + .AddMeter(SourceName) // Our custom meter source .AddHttpClientInstrumentation() // HTTP client metrics .AddRuntimeInstrumentation() // .NET runtime metrics .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)) @@ -128,7 +127,7 @@ var agent = new ChatClientAgent(instrumentedChatClient, instructions: "You are a helpful assistant that provides concise and informative responses.", tools: [AIFunctionFactory.Create(GetWeatherAsync)]) .AsBuilder() - .UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level + .UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level .Build(); var session = await agent.CreateSessionAsync(); 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/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_CustomImplementation/Agent_With_CustomImplementation.csproj b/dotnet/samples/02-agents/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj index 945912bfd4..409693f2fd 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj @@ -9,7 +9,7 @@ - + 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 deleted file mode 100644 index 290c3f9b6b..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Agent Skills with a ChatClientAgent. -// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities. -// Skills follow the progressive disclosure pattern: advertise -> load -> read resources. -// -// This sample includes the expense-report skill: -// - Policy-based expense filing with references and assets - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Responses; - -// --- Configuration --- -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -// --- Skills Provider --- -// Discovers skills from the 'skills' directory and makes them available to the agent -var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); - -// --- Agent Setup --- -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) - .AsAIAgent(new ChatClientAgentOptions - { - Name = "SkillsAgent", - ChatOptions = new() - { - Instructions = "You are a helpful assistant.", - }, - AIContextProviders = [skillsProvider], - }); - -// --- Example 1: Expense policy question (loads FAQ resource) --- -Console.WriteLine("Example 1: Checking expense policy FAQ"); -Console.WriteLine("---------------------------------------"); -AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."); -Console.WriteLine($"Agent: {response1.Text}\n"); - -// --- Example 2: Filing an expense report (multi-turn with template asset) --- -Console.WriteLine("Example 2: Filing an expense report"); -Console.WriteLine("---------------------------------------"); -AgentSession session = await agent.CreateSessionAsync(); -AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.", - session); -Console.WriteLine($"Agent: {response2.Text}\n"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md deleted file mode 100644 index 78099fa8a5..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Agent Skills Sample - -This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework. - -## What are Agent Skills? - -Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern: - -1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill) -2. **Load**: Full instructions are loaded on-demand via `load_skill` tool -3. **Resources**: References and other files loaded via `read_skill_resource` tool - -## Skills Included - -### expense-report -Policy-based expense filing with spending limits, receipt requirements, and approval workflows. -- `references/POLICY_FAQ.md` — Detailed expense policy Q&A -- `assets/expense-report-template.md` — Submission template - -## Project Structure - -``` -Agent_Step01_BasicSkills/ -├── Program.cs -├── Agent_Step01_BasicSkills.csproj -└── skills/ - └── expense-report/ - ├── SKILL.md - ├── references/ - │ └── POLICY_FAQ.md - └── assets/ - └── expense-report-template.md -``` - -## Running the Sample - -### Prerequisites -- .NET 10.0 SDK -- Azure OpenAI endpoint with a deployed model - -### Setup -1. Set environment variables: - ```bash - export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" - export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" - ``` - -2. Run the sample: - ```bash - dotnet run - ``` - -### Examples - -The sample runs two examples: - -1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource -2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset - -## Learn More - -- [Agent Skills Specification](https://agentskills.io/) -- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md deleted file mode 100644 index fc6c83cf30..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: expense-report -description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories. -metadata: - author: contoso-finance - version: "2.1" ---- - -# Expense Report - -## Categories and Limits - -| Category | Limit | Receipt | Approval | -|---|---|---|---| -| Meals — solo | $50/day | >$25 | No | -| Meals — team/client | $75/person | Always | Manager if >$200 total | -| Lodging | $250/night | Always | Manager if >3 nights | -| Ground transport | $100/day | >$15 | No | -| Airfare | Economy | Always | Manager; VP if >$1,500 | -| Conference/training | $2,000/event | Always | Manager + L&D | -| Office supplies | $100 | Yes | No | -| Software/subscriptions | $50/month | Yes | Manager if >$200/year | - -## Filing Process - -1. Collect receipts — must show vendor, date, amount, payment method. -2. Categorize per table above. -3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md). -4. For client/team meals: list attendee names and business purpose. -5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000. -6. Reimbursement: 10 business days via direct deposit. - -## Policy Rules - -- Submit within 30 days of transaction. -- Alcohol is never reimbursable. -- Foreign currency: convert to USD at transaction-date rate; note original currency and amount. -- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes. -- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter. -- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md deleted file mode 100644 index 3f7c7dc36c..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md +++ /dev/null @@ -1,5 +0,0 @@ -# Expense Report Template - -| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached | -|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------| -| | | | | | | | | | Yes or No | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md deleted file mode 100644 index 8e971192f8..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md +++ /dev/null @@ -1,55 +0,0 @@ -# Expense Policy — Frequently Asked Questions - -## Meals - -**Q: Can I expense coffee or snacks during the workday?** -A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal. - -**Q: What if a team dinner exceeds the per-person limit?** -A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP. - -**Q: Do I need to list every attendee?** -A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list. - -## Travel - -**Q: Can I book a premium economy or business class flight?** -A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation. - -**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?** -A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people. - -**Q: Are tips reimbursable?** -A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification. - -## Lodging - -**Q: What if the $250/night limit isn't enough for the city I'm visiting?** -A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking. - -**Q: Can I stay with friends/family instead and get a per-diem?** -A: No. Contoso reimburses actual lodging costs only, not per-diems. - -## Subscriptions and Software - -**Q: Can I expense a personal productivity tool?** -A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing. - -**Q: What about annual subscriptions?** -A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report. - -## Receipts and Documentation - -**Q: My receipt is faded/damaged. What do I do?** -A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter. - -**Q: Do I need a receipt for parking meters or tolls?** -A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required. - -## Approval and Reimbursement - -**Q: My manager is on leave. Who approves my report?** -A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system. - -**Q: Can I submit expenses from a previous quarter?** -A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj similarity index 86% rename from dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj rename to dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj index 2a503bbfb2..7e7e9ef0fa 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs new file mode 100644 index 0000000000..b787bb86a3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use file-based Agent Skills with a ChatClientAgent. +// Skills are discovered from SKILL.md files on disk and follow the progressive disclosure pattern: +// 1. Advertise — skill names and descriptions in the system prompt +// 2. Load — full instructions loaded on demand via load_skill tool +// 3. Read resources — reference files read via read_skill_resource tool +// 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor +// +// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider --- +// Discovers skills from the 'skills' directory containing SKILL.md files. +// The script runner runs file-based scripts (e.g. Python) as local subprocesses. +var skillsProvider = new AgentSkillsProvider( + Path.Combine(AppContext.BaseDirectory, "skills"), + SubprocessScriptRunner.RunAsync); +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Unit conversion --- +Console.WriteLine("Converting units with file-based skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md new file mode 100644 index 0000000000..41b813b98f --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -0,0 +1,51 @@ +# File-Based Agent Skills Sample + +This sample demonstrates how to use **file-based Agent Skills** with a `ChatClientAgent`. + +## What it demonstrates + +- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource` +- The progressive disclosure pattern: advertise → load → read resources → run scripts +- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor +- Running file-based scripts (Python) via a subprocess-based executor + +## Skills Included + +### unit-converter + +Converts between common units (miles↔km, pounds↔kg) using a multiplication factor. + +- `references/conversion-table.md` — Conversion factor table +- `scripts/convert.py` — Python script that performs the conversion + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model +- Python 3 installed and available as `python3` on your PATH + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with file-based skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md new file mode 100644 index 0000000000..6a8e692ff2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md @@ -0,0 +1,11 @@ +--- +name: unit-converter +description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms. +--- + +## Usage + +When the user requests a unit conversion: +1. First, review `references/conversion-table.md` to find the correct factor +2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`) +3. Present the converted value clearly with both units diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md new file mode 100644 index 0000000000..7a0160b854 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md @@ -0,0 +1,10 @@ +# Conversion Tables + +Formula: **result = value × factor** + +| From | To | Factor | +|-------------|-------------|----------| +| miles | kilometers | 1.60934 | +| kilometers | miles | 0.621371 | +| pounds | kilograms | 0.453592 | +| kilograms | pounds | 2.20462 | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py new file mode 100644 index 0000000000..228c8809ff --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py @@ -0,0 +1,29 @@ +# Unit conversion script +# Converts a value using a multiplication factor: result = value × factor +# +# Usage: +# python scripts/convert.py --value 26.2 --factor 1.60934 +# python scripts/convert.py --value 75 --factor 2.20462 + +import argparse +import json + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert a value using a multiplication factor.", + epilog="Examples:\n" + " python scripts/convert.py --value 26.2 --factor 1.60934\n" + " python scripts/convert.py --value 75 --factor 2.20462", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") + parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") + args = parser.parse_args() + + result = round(args.value * args.factor, 4) + print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) + + +if __name__ == "__main__": + main() diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 8488ec9eed..75b850f077 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -4,4 +4,4 @@ Samples demonstrating Agent Skills capabilities. | Sample | Description | |--------|-------------| -| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | +| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs new file mode 100644 index 0000000000..e95bde61df --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Sample subprocess-based skill script runner. +// Executes file-based skill scripts as local subprocesses. +// This is provided for demonstration purposes only. + +using System.Diagnostics; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +/// +/// Executes file-based skill scripts as local subprocesses. +/// +/// +/// This runner uses the script's absolute path, converts the arguments +/// to CLI flags, and returns captured output. It is intended for +/// demonstration purposes only. +/// +internal static class SubprocessScriptRunner +{ + /// + /// Runs a skill script as a local subprocess. + /// + public static async Task RunAsync( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + if (!File.Exists(script.FullPath)) + { + return $"Error: Script file not found: {script.FullPath}"; + } + + string extension = Path.GetExtension(script.FullPath); + string? interpreter = extension switch + { + ".py" => "python3", + ".js" => "node", + ".sh" => "bash", + ".ps1" => "pwsh", + _ => null, + }; + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".", + }; + + if (interpreter is not null) + { + startInfo.FileName = interpreter; + startInfo.ArgumentList.Add(script.FullPath); + } + else + { + startInfo.FileName = script.FullPath; + } + + if (arguments is not null) + { + foreach (var (key, value) in arguments) + { + if (value is bool boolValue) + { + if (boolValue) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + } + } + else if (value is not null) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + startInfo.ArgumentList.Add(value.ToString()!); + } + } + } + + Process? process = null; + try + { + process = Process.Start(startInfo); + if (process is null) + { + return $"Error: Failed to start process for script '{script.Name}'."; + } + + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + + if (!string.IsNullOrEmpty(error)) + { + output += $"\nStderr:\n{error}"; + } + + if (process.ExitCode != 0) + { + output += $"\nScript exited with code {process.ExitCode}"; + } + + return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Kill the process on cancellation to avoid leaving orphaned subprocesses. + process?.Kill(entireProcessTree: true); + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; + } + finally + { + process?.Dispose(); + } + } + + /// + /// Normalizes a parameter key to a consistent --flag format. + /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). + /// + private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); +} 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..a8dc73839a 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("{}"))); @@ -73,16 +73,28 @@ foreach (ClientResult result in getConversationItemsResults.GetRawPages()) using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString()); foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray()) { + // Skip non-message items (e.g. tool calls, reasoning) that lack a "role" property + if (!element.TryGetProperty("role"u8, out var roleElement)) + { + continue; + } + string messageId = element.GetProperty("id"u8).ToString(); - string messageRole = element.GetProperty("role"u8).ToString(); + string messageRole = roleElement.ToString(); Console.WriteLine($" Message ID: {messageId}"); Console.WriteLine($" Message Role: {messageRole}"); - foreach (var content in element.GetProperty("content").EnumerateArray()) + if (element.TryGetProperty("content"u8, out var contentElement)) { - string messageContentText = content.GetProperty("text"u8).ToString(); - Console.WriteLine($" Message Text: {messageContentText}"); + foreach (var content in contentElement.EnumerateArray()) + { + if (content.TryGetProperty("text"u8, out var textElement)) + { + Console.WriteLine($" Message Text: {textElement}"); + } + } } + Console.WriteLine(); } } 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_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_Step08_UsingImages/Agent_Step08_UsingImages.csproj b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj index 73a41005f1..2b01c47354 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj @@ -16,5 +16,11 @@ + + + + Always + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg new file mode 100644 index 0000000000..13ef1e1840 Binary files /dev/null and b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg differ diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs index 984a9e3b5c..08e5b63139 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs @@ -22,7 +22,7 @@ var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential( ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg") + await DataContent.LoadFromAsync("Assets/walkway.jpg"), ]); var session = await agent.CreateSessionAsync(); 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/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/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj index 99073874ee..a47c262d42 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj @@ -12,13 +12,9 @@ - - - - diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj new file mode 100644 index 0000000000..41aafe3437 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs new file mode 100644 index 0000000000..07382e6417 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Program.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how the ChatClientAgent persists chat history after each individual +// call to the AI service. +// When an agent uses tools, FunctionInvokingChatClient may loop multiple times +// (service call → tool execution → service call), and intermediate messages (tool calls and +// results) are persisted after each service call. This allows you to inspect or recover them +// even if the process is interrupted mid-loop, but may also result in chat history that is not +// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases. +// +// To opt into end-of-run persistence instead (atomic run semantics), set +// PersistChatHistoryAtEndOfRun = true on ChatClientAgentOptions. +// +// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one +// using streaming (RunStreamingAsync), to demonstrate correct behavior in both modes. + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var store = Environment.GetEnvironmentVariable("AZURE_OPENAI_RESPONSES_STORE") ?? "false"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Define multiple tools so the model makes several tool calls in a single run. +[Description("Get the current weather for a city.")] +static string GetWeather([Description("The city name.")] string city) => + city.ToUpperInvariant() switch + { + "SEATTLE" => "Seattle: 55°F, cloudy with light rain.", + "NEW YORK" => "New York: 72°F, sunny and warm.", + "LONDON" => "London: 48°F, overcast with fog.", + "DUBLIN" => "Dublin: 43°F, overcast with fog.", + _ => $"{city}: weather data not available." + }; + +[Description("Get the current time in a city.")] +static string GetTime([Description("The city name.")] string city) => + city.ToUpperInvariant() switch + { + "SEATTLE" => "Seattle: 9:00 AM PST", + "NEW YORK" => "New York: 12:00 PM EST", + "LONDON" => "London: 5:00 PM GMT", + "DUBLIN" => "Dublin: 5:00 PM GMT", + _ => $"{city}: time data not available." + }; + +// Create the agent — per-service-call persistence is the default behavior. +// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat +// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory(). +IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ? + openAIClient.GetResponsesClient().AsIChatClient(deploymentName) : + openAIClient.GetResponsesClient().AsIChatClientWithStoredOutputDisabled(deploymentName); +AIAgent agent = chatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = "WeatherAssistant", + ChatOptions = new() + { + Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.", + Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)] + }, + }); + +await RunNonStreamingAsync(); +await RunStreamingAsync(); + +async Task RunNonStreamingAsync() +{ + int lastChatHistorySize = 0; + string lastConversationId = string.Empty; + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("\n=== Non-Streaming Mode ==="); + Console.ResetColor(); + + AgentSession session = await agent.CreateSessionAsync(); + + // First turn — ask about multiple cities so the model calls tools. + const string Prompt = "What's the weather and time in Seattle, New York, and London?"; + PrintUserMessage(Prompt); + + var response = await agent.RunAsync(Prompt, session); + PrintAgentResponse(response.Text); + PrintChatHistory(session, "After run", ref lastChatHistorySize, ref lastConversationId); + + // Second turn — follow-up to verify chat history is correct. + const string FollowUp1 = "And Dublin?"; + PrintUserMessage(FollowUp1); + + response = await agent.RunAsync(FollowUp1, session); + PrintAgentResponse(response.Text); + PrintChatHistory(session, "After second run", ref lastChatHistorySize, ref lastConversationId); + + // Third turn — follow-up to verify chat history is correct. + const string FollowUp2 = "Which city is the warmest?"; + PrintUserMessage(FollowUp2); + + response = await agent.RunAsync(FollowUp2, session); + PrintAgentResponse(response.Text); + PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId); +} + +async Task RunStreamingAsync() +{ + int lastChatHistorySize = 0; + string lastConversationId = string.Empty; + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("\n=== Streaming Mode ==="); + Console.ResetColor(); + + AgentSession session = await agent.CreateSessionAsync(); + + // First turn — ask about multiple cities so the model calls tools. + const string Prompt = "What's the weather and time in Seattle, New York, and London?"; + PrintUserMessage(Prompt); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[Agent] "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(Prompt, session)) + { + Console.Write(update); + + // During streaming we should be able to see updates to the chat history + // before the full run completes, as each service call is made and persisted. + PrintChatHistory(session, "During run", ref lastChatHistorySize, ref lastConversationId); + } + + Console.WriteLine(); + PrintChatHistory(session, "After run", ref lastChatHistorySize, ref lastConversationId); + + // Second turn — follow-up to verify chat history is correct. + const string FollowUp1 = "And Dublin?"; + PrintUserMessage(FollowUp1); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[Agent] "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(FollowUp1, session)) + { + Console.Write(update); + + // During streaming we should be able to see updates to the chat history + // before the full run completes, as each service call is made and persisted. + PrintChatHistory(session, "During second run", ref lastChatHistorySize, ref lastConversationId); + } + + Console.WriteLine(); + PrintChatHistory(session, "After second run", ref lastChatHistorySize, ref lastConversationId); + + // Third turn — follow-up to verify chat history is correct. + const string FollowUp2 = "Which city is the warmest?"; + PrintUserMessage(FollowUp2); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[Agent] "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(FollowUp2, session)) + { + Console.Write(update); + + // During streaming we should be able to see updates to the chat history + // before the full run completes, as each service call is made and persisted. + PrintChatHistory(session, "During third run", ref lastChatHistorySize, ref lastConversationId); + } + + Console.WriteLine(); + PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId); +} + +void PrintUserMessage(string message) +{ + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[User] "); + Console.ResetColor(); + Console.WriteLine(message); +} + +void PrintAgentResponse(string? text) +{ + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[Agent] "); + Console.ResetColor(); + Console.WriteLine(text); +} + +// Helper to print the current chat history from the session. +void PrintChatHistory(AgentSession session, string label, ref int lastChatHistorySize, ref string lastConversationId) +{ + if (session.TryGetInMemoryChatHistory(out var history) && history.Count != lastChatHistorySize) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"\n [{label} — Chat history: {history.Count} message(s)]"); + foreach (var msg in history) + { + var preview = msg.Text?.Length > 80 ? msg.Text[..80] + "…" : msg.Text; + var contentTypes = string.Join(", ", msg.Contents.Select(c => c.GetType().Name)); + Console.WriteLine($" {msg.Role,-12} | {(string.IsNullOrWhiteSpace(preview) ? $"[{contentTypes}]" : preview)}"); + } + + Console.ResetColor(); + + lastChatHistorySize = history.Count; + } + + if (session is ChatClientAgentSession ccaSession && ccaSession.ConversationId is not null && ccaSession.ConversationId != lastConversationId) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{label} — Conversation ID: {ccaSession.ConversationId}]"); + Console.ResetColor(); + lastConversationId = ccaSession.ConversationId; + } +} diff --git a/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md new file mode 100644 index 0000000000..d6157586f0 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/README.md @@ -0,0 +1,63 @@ +# In-Function-Loop Checkpointing + +This sample demonstrates how `ChatClientAgent` persists chat history after each individual call to the AI service by default. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop. + +## What This Sample Shows + +When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By default, chat history is persisted after each service call via the `ChatHistoryPersistingChatClient` decorator: + +- A `ChatHistoryPersistingChatClient` decorator is automatically inserted into the chat client pipeline +- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages +- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically + +To opt into end-of-run persistence instead (atomic run semantics), set `PersistChatHistoryAtEndOfRun = true` on `ChatClientAgentOptions`. In that mode, the decorator marks messages with metadata rather than persisting them immediately, and `ChatClientAgent` persists only the marked messages at the end of the run. + +Per-service-call persistence is useful for: +- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted +- **Observability** — you can inspect the chat history while the agent is still running (e.g., during streaming) +- **Long-running tool loops** — agents with many sequential tool calls benefit from incremental persistence + +## How It Works + +The sample asks the agent about the weather and time in three cities. The model calls the `GetWeather` and `GetTime` tools for each city, resulting in multiple service calls within a single `RunStreamingAsync` invocation. After the run completes, the sample prints the full chat history to show all the intermediate messages that were persisted along the way. + +### Pipeline Architecture + +``` +ChatClientAgent + └─ FunctionInvokingChatClient (handles tool call loop) + └─ ChatHistoryPersistingChatClient (persists after each service call) + └─ Leaf IChatClient (Azure OpenAI) +``` + +## Prerequisites + +- .NET 10 SDK or later +- Azure OpenAI service endpoint and model deployment +- Azure CLI installed and authenticated + +**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +## Environment Variables + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Running the Sample + +```powershell +cd dotnet/samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing +dotnet run +``` + +## Expected Behavior + +The sample runs two conversation turns: + +1. **First turn** — asks about weather and time in three cities. The model calls `GetWeather` and `GetTime` tools (potentially in parallel or sequentially), then provides a summary. The chat history dump after the run shows all the intermediate tool call and result messages. + +2. **Second turn** — asks a follow-up question ("Which city is the warmest?") that uses the persisted conversation context. The chat history dump shows the full accumulated conversation. + +The chat history printout uses `session.TryGetInMemoryChatHistory()` to inspect the in-memory storage. diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index 4ac53ba246..c5258ba9f4 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -45,6 +45,7 @@ Before you begin, ensure you have the following prerequisites: |[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.| |[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.| |[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.| +|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.| ## Running the samples from the console 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_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs index d44d62df51..d810c8046a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs @@ -24,7 +24,7 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - await DataContent.LoadFromAsync("assets/walkway.jpg"), + await DataContent.LoadFromAsync("Assets/walkway.jpg"), ]); AgentSession session = await agent.CreateSessionAsync(); 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/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 99d26c103d..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() { @@ -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/CustomAgentExecutors/Program.cs b/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs index e2dec8505b..41b622fdad 100644 --- a/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs +++ b/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs @@ -61,6 +61,12 @@ public static class Program { Console.WriteLine($"{outputEvent}"); } + + if (evt is WorkflowErrorEvent errorEvent) + { + Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}"); + Console.WriteLine($"Details: {errorEvent.Exception}"); + } } } } @@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve /// /// A custom executor that uses an AI agent to provide feedback on a slogan. /// -internal sealed class FeedbackExecutor : Executor +[SendsMessage(typeof(FeedbackResult))] +[YieldsOutput(typeof(string))] +internal sealed partial class FeedbackExecutor : Executor { private readonly AIAgent _agent; private AgentSession? _session; 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/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/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs index 07ba96989a..bc9faff3b0 100644 --- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.AI; namespace WorkflowAsAnAgentSample; /// -/// This sample introduces the concepts workflows as agents, where a workflow can be +/// This sample introduces the concept of workflows as agents, where a workflow can be /// treated as an . This allows you to interact with a workflow /// as if it were a single agent. /// @@ -18,6 +18,14 @@ namespace WorkflowAsAnAgentSample; /// /// You will interact with the workflow in an interactive loop, sending messages and receiving /// streaming responses from the workflow as if it were an agent who responds in both languages. +/// +/// This sample also demonstrates , which is required +/// for stateful executors that are shared across multiple workflow runs. Each iteration +/// of the interactive loop triggers a new workflow run against the same workflow instance. +/// Between runs, the framework automatically calls +/// on shared executors so that accumulated state (e.g., collected messages) is cleared +/// before the next run begins. See WorkflowFactory.ConcurrentAggregationExecutor +/// for the implementation. /// /// /// Pre-requisites: @@ -39,7 +47,10 @@ public static class Program var agent = workflow.AsAIAgent("workflow-agent", "Workflow Agent"); var session = await agent.CreateSessionAsync(); - // Start an interactive loop to interact with the workflow as if it were an agent + // Start an interactive loop to interact with the workflow as if it were an agent. + // Each iteration runs the workflow again on the same workflow instance. Between runs, + // the framework calls IResettableExecutor.ResetAsync() on shared stateful executors + // (like ConcurrentAggregationExecutor) to clear accumulated state from the previous run. while (true) { Console.WriteLine(); diff --git a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs index 669b9ac87c..bcac8894ab 100644 --- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs +++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs @@ -10,6 +10,14 @@ internal static class WorkflowFactory { /// /// Creates a workflow that uses two language agents to process input concurrently. + /// + /// In this workflow, the Start and the + /// are provided as shared instances, meaning + /// the same executor objects are reused across multiple workflow runs. The language agents + /// (French and English) are created via a factory and instantiated per workflow run. + /// Stateful shared executors must implement so the + /// framework can clear their state between runs. Framework-provided executors like + /// already implement this interface. /// /// The chat client to use for the agents /// A workflow that processes input using two language agents @@ -40,7 +48,18 @@ internal static class WorkflowFactory /// /// Executor that aggregates the results from the concurrent agents. + /// + /// This executor is stateful — it accumulates messages in + /// as they arrive from each agent. Because it is provided as a shared instance + /// (not via a factory), the same object is reused across workflow runs. Implementing + /// allows the framework to call + /// between runs, clearing accumulated state so each run starts fresh. + /// + /// Without , attempting to reuse a workflow containing + /// shared executor instances that do not implement this interface would throw an + /// . /// + [YieldsOutput(typeof(string))] private sealed class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor"), IResettableExecutor { @@ -64,7 +83,11 @@ internal static class WorkflowFactory } } - /// + /// + /// Resets the executor state between workflow runs by clearing accumulated messages. + /// The framework calls this automatically when a workflow run completes, before the + /// workflow can be used for another run. + /// public ValueTask ResetAsync() { this._messages.Clear(); 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/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/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) diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj index 1f36cef576..1bccc99d4f 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj @@ -14,7 +14,6 @@ - diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj new file mode 100644 index 0000000000..68f9ccb801 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj @@ -0,0 +1,35 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowMcpTool + WorkflowMcpTool + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs new file mode 100644 index 0000000000..0621680e33 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Executors.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowMcpTool; + +internal sealed class TranslateText() : Executor("TranslateText") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"[Activity] TranslateText: '{message}'"); + return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant())); + } +} + +internal sealed class FormatOutput() : Executor("FormatOutput") +{ + public override ValueTask HandleAsync( + TranslationResult message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine("[Activity] FormatOutput: Formatting result"); + return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}"); + } +} + +internal sealed class LookupOrder() : Executor("LookupOrder") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"[Activity] LookupOrder: '{message}'"); + return ValueTask.FromResult(new OrderInfo(message, "Alice Johnson", "Wireless Headphones", Quantity: 2, UnitPrice: 49.99m)); + } +} + +internal sealed class EnrichOrder() : Executor("EnrichOrder") +{ + public override ValueTask HandleAsync( + OrderInfo message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"[Activity] EnrichOrder: '{message.OrderId}'"); + return ValueTask.FromResult(new OrderSummary(message, TotalPrice: message.Quantity * message.UnitPrice, Status: "Confirmed")); + } +} + +internal sealed record TranslationResult(string Original, string Translated); + +internal sealed record OrderInfo(string OrderId, string CustomerName, string Product, int Quantity, decimal UnitPrice); + +internal sealed record OrderSummary(OrderInfo Order, decimal TotalPrice, string Status); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs new file mode 100644 index 0000000000..0970ca16b8 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to expose a durable workflow as an MCP (Model Context Protocol) tool. +// When using AddWorkflow with exposeMcpToolTrigger: true, the Functions host will automatically +// generate a remote MCP endpoint for the app at /runtime/webhooks/mcp with a workflow-specific +// tool name. MCP-compatible clients can then invoke the workflow as a tool. + +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using WorkflowMcpTool; + +// Define executors +TranslateText translateText = new(); +FormatOutput formatOutput = new(); +LookupOrder lookupOrder = new(); +EnrichOrder enrichOrder = new(); + +// Build a simple workflow: TranslateText -> FormatOutput +Workflow translateWorkflow = new WorkflowBuilder(translateText) + .WithName("Translate") + .WithDescription("Translate text to uppercase and format the result") + .AddEdge(translateText, formatOutput) + .Build(); + +// Build a workflow that returns a POCO: LookupOrder -> EnrichOrder +Workflow orderLookupWorkflow = new WorkflowBuilder(lookupOrder) + .WithName("OrderLookup") + .WithDescription("Look up an order by ID and return enriched order details") + .AddEdge(lookupOrder, enrichOrder) + .Build(); + +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableWorkflows(workflows => + { + // Expose both workflows as MCP tool triggers. + workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true); + workflows.AddWorkflow(orderLookupWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true); + }) + .Build(); +app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md new file mode 100644 index 0000000000..a5411bf375 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/README.md @@ -0,0 +1,81 @@ +# Workflow as MCP Tool Sample + +This sample demonstrates how to expose durable workflows as [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) tools, enabling MCP-compatible clients to invoke workflows directly. + +## Key Concepts Demonstrated + +- **Workflow as MCP Tool**: Expose workflows as callable MCP tools using `exposeMcpToolTrigger: true` +- **MCP Server Hosting**: The Azure Functions host automatically generates a remote MCP endpoint at `/runtime/webhooks/mcp` +- **String and POCO Results**: Shows workflows returning both plain strings and structured JSON objects + +## Sample Architecture + +The sample creates two workflows exposed as MCP tools: + +### Translate Workflow (returns a string) + +| Executor | Input | Output | Description | +|----------|-------|--------|-------------| +| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase | +| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string | + +### OrderLookup Workflow (returns a POCO) + +| Executor | Input | Output | Description | +|----------|-------|--------|-------------| +| **LookupOrder** | `string` | `OrderInfo` | Looks up an order by ID | +| **EnrichOrder** | `OrderInfo` | `OrderSummary` | Adds computed fields (total price, status) | + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including: + +- Prerequisites installation +- Durable Task Scheduler setup +- Storage emulator configuration + +For this sample, you'll also need [Node.js](https://nodejs.org/en/download) to use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector). + +## Running the Sample + +1. **Start the Function App**: + + ```bash + cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool + func start + ``` + +2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output: + + ```text + MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp + ``` + +## Invoking Workflows via MCP Inspector + +1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): + + ```bash + npx @modelcontextprotocol/inspector + ``` + +2. Connect to the MCP server endpoint: + - For **Transport Type**, select **"Streamable HTTP"** + - For **URL**, enter `http://localhost:7071/runtime/webhooks/mcp` + - Click the **Connect** button + +3. Click the **List Tools** button. You should see two tools: `Translate` and `OrderLookup`. + +4. Test the **Translate** tool (returns a plain string): + - Select the `Translate` tool + - Set `hello world` as the `input` parameter + - Click **Run Tool** + - Expected result: `Original: hello world => Translated: HELLO WORLD` + +5. Test the **OrderLookup** tool (returns a JSON object): + - Select the `OrderLookup` tool + - Set `ORD-2025-42` as the `input` parameter + - Click **Run Tool** + - Expected result: A JSON object containing order details such as `OrderId`, `CustomerName`, `Product`, `TotalPrice`, and `Status` + +You'll see the workflow executor activities logged in the terminal where you ran `func start`. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json new file mode 100644 index 0000000000..fcb6658e92 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json @@ -0,0 +1,8 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj new file mode 100644 index 0000000000..517dd323a7 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj @@ -0,0 +1,42 @@ + + + net10.0 + v4 + Exe + enable + enable + + WorkflowAndAgents + WorkflowAndAgents + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs new file mode 100644 index 0000000000..727379b482 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Executors.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowAndAgents; + +internal sealed class TranslateText() : Executor("TranslateText") +{ + public override ValueTask HandleAsync( + string message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine($"[Activity] TranslateText: '{message}'"); + return ValueTask.FromResult(new TranslationResult(message, message.ToUpperInvariant())); + } +} + +internal sealed class FormatOutput() : Executor("FormatOutput") +{ + public override ValueTask HandleAsync( + TranslationResult message, + IWorkflowContext context, + CancellationToken cancellationToken = default) + { + Console.WriteLine("[Activity] FormatOutput: Formatting result"); + return ValueTask.FromResult($"Original: {message.Original} => Translated: {message.Translated}"); + } +} + +internal sealed record TranslationResult(string Original, string Translated); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs new file mode 100644 index 0000000000..51b9fb4d7f --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows +// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent +// accessible via HTTP and MCP tool triggers. + +#pragma warning disable IDE0002 // Simplify Member Access + +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using OpenAI.Chat; +using WorkflowAndAgents; + +// Get the Azure OpenAI endpoint and deployment name from environment variables. +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") + ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); + +// Use Azure Key Credential if provided, otherwise use Azure CLI Credential. +string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); +AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) + ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); + +ChatClient chatClient = client.GetChatClient(deploymentName); + +// Define a standalone AI agent +AIAgent assistant = chatClient.AsAIAgent( + "You are a helpful assistant. Answer questions clearly and concisely.", + "Assistant", + description: "A general-purpose helpful assistant."); + +// Define workflow executors +TranslateText translateText = new(); +FormatOutput formatOutput = new(); + +// Build a workflow: TranslateText -> FormatOutput +Workflow translateWorkflow = new WorkflowBuilder(translateText) + .WithName("Translate") + .WithDescription("Translate text to uppercase and format the result") + .AddEdge(translateText, formatOutput) + .Build(); + +// Use ConfigureDurableOptions to register both agents and workflows together +using IHost app = FunctionsApplication + .CreateBuilder(args) + .ConfigureFunctionsWebApplication() + .ConfigureDurableOptions(options => + { + // Register the standalone agent with HTTP and MCP tool triggers + options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true); + + // Register the workflow with an HTTP endpoint and MCP tool trigger + options.Workflows.AddWorkflow(translateWorkflow, exposeStatusEndpoint: false, exposeMcpToolTrigger: true); + }) + .Build(); +app.Run(); diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md new file mode 100644 index 0000000000..37841777cc --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md @@ -0,0 +1,76 @@ +# Workflow and Agents Sample + +This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows. + +## Key Concepts Demonstrated + +- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together +- **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers +- **Workflow**: A simple text translation workflow also exposed as an MCP tool +- **Mixed Triggers**: Both agents and workflows coexist in the same Functions host + +## Sample Architecture + +### Standalone Agent + +| Agent | Description | +|-------|-------------| +| **Assistant** | A general-purpose AI assistant accessible via HTTP (`/agents/Assistant/run`) and as an MCP tool | + +### Translate Workflow + +| Executor | Input | Output | Description | +|----------|-------|--------|-------------| +| **TranslateText** | `string` | `TranslationResult` | Converts input text to uppercase | +| **FormatOutput** | `TranslationResult` | `string` | Formats the result into a readable string | + +## Environment Setup + +See the [README.md](../../README.md) file in the parent directory for complete setup instructions, including: + +- Prerequisites installation +- Durable Task Scheduler setup +- Storage emulator configuration + +This sample also requires Azure OpenAI credentials. Set the following in `local.settings.json`: + +- `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint URL +- `AZURE_OPENAI_DEPLOYMENT_NAME`: Your chat model deployment name +- `AZURE_OPENAI_API_KEY` (optional): If not set, Azure CLI credential is used + +## Running the Sample + +1. **Start the Function App**: + + ```bash + cd dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents + func start + ``` + +2. **Expected Functions**: When the app starts, you should see functions for both the agent and the workflow: + + - `dafx-Assistant` (entity trigger for the agent) + - `http-Assistant` (HTTP trigger for the agent) + - `mcptool-Assistant` (MCP tool trigger for the agent) + - `wf-Translate` (orchestration trigger for the workflow) + - `mcptool-wf-Translate` (MCP tool trigger for the workflow) + +## Invoking the Agent via HTTP + +```bash +curl -X POST http://localhost:7071/agents/Assistant/run \ + -H "Content-Type: application/json" \ + -d '{"query": "What is the capital of France?"}' +``` + +## Invoking via MCP Inspector + +1. Install and run the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): + + ```bash + npx @modelcontextprotocol/inspector + ``` + +2. Connect to `http://localhost:7071/runtime/webhooks/mcp` using **Streamable HTTP** transport. + +3. Click **List Tools** to see both the `Assistant` agent tool and the `Translate` workflow tool. diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json new file mode 100644 index 0000000000..9384a0a583 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Agents.AI.DurableTask": "Information", + "Microsoft.Agents.AI.Hosting.AzureFunctions": "Information", + "DurableTask": "Information", + "Microsoft.DurableTask": "Information" + } + }, + "extensions": { + "durableTask": { + "hubName": "default", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json new file mode 100644 index 0000000000..5f6d7d3340 --- /dev/null +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT_NAME": "" + } +} diff --git a/dotnet/samples/04-hosting/DurableWorkflows/README.md b/dotnet/samples/04-hosting/DurableWorkflows/README.md index 2b7103de50..f2386380d7 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/README.md +++ b/dotnet/samples/04-hosting/DurableWorkflows/README.md @@ -48,3 +48,4 @@ $env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhos | [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions | | [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions | | [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions | +| [04_WorkflowMcpTool](AzureFunctions/04_WorkflowMcpTool/) | Workflow exposed as an MCP tool | 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..1149f9a293 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 { @@ -60,7 +59,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var invoiceQuery = new AgentSkill() + var invoiceQuery = new A2A.AgentSkill() { Id = "id_invoice_agent", Name = "InvoiceQuery", @@ -92,7 +91,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var policyQuery = new AgentSkill() + var policyQuery = new A2A.AgentSkill() { Id = "id_policy_agent", Name = "PolicyAgent", @@ -124,7 +123,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var logisticsQuery = new AgentSkill() + var logisticsQuery = new A2A.AgentSkill() { Id = "id_logistics_agent", Name = "LogisticsQuery", 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/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj index eb2dc3f77e..96a72d1109 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj @@ -15,7 +15,6 @@ - diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj index 5335499168..8f5d432c7b 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj @@ -9,10 +9,12 @@ - - + + + + 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..a56157fe9d 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj @@ -36,10 +36,10 @@ - - + + - + 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..4e46f10c11 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj @@ -1,4 +1,4 @@ - + Exe @@ -35,10 +35,10 @@ - + - + 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..b7970f8c5f 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj @@ -36,11 +36,11 @@ - - - + + + - + 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..7789abd315 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj @@ -1,4 +1,4 @@ - + Exe @@ -35,10 +35,10 @@ - - + + - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj deleted file mode 100644 index 959cca1db5..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj +++ /dev/null @@ -1,68 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - false - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/Dockerfile deleted file mode 100644 index c9f39f9574..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "AgentWithTools.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/Program.cs deleted file mode 100644 index f564a0d8d3..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/Program.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Foundry tools (MCP and code interpreter) -// with an AI agent hosted using the Azure AI AgentServer SDK. - -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -string toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set."); - -// 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. -DefaultAzureCredential credential = new(); - -IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential) - .GetChatClient(deploymentName) - .AsIChatClient() - .AsBuilder() - .UseFoundryTools(new { type = "mcp", project_connection_id = toolConnectionId }, new { type = "code_interpreter" }) - .UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true) - .Build(); - -AIAgent agent = chatClient.AsAIAgent( - name: "AgentWithTools", - instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation. - - IMPORTANT: When the user asks about Microsoft Learn articles or documentation: - 1. You MUST use the microsoft_docs_fetch tool to retrieve the actual content - 2. Do NOT rely on your training data - 3. Always fetch the latest information from the provided URL - - Available tools: - - microsoft_docs_fetch: Fetches and converts Microsoft Learn documentation - - microsoft_docs_search: Searches Microsoft/Azure documentation - - microsoft_code_sample_search: Searches for code examples") - .AsBuilder() - .UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true) - .Build(); - -await agent.RunAIAgentAsync(telemetrySourceName: "Agents"); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/README.md deleted file mode 100644 index 55333f9940..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# What this sample demonstrates - -This sample demonstrates how to use Foundry tools with an AI agent via the `UseFoundryTools` extension. The agent is configured with two tool types: an MCP (Model Context Protocol) connection for fetching Microsoft Learn documentation and a code interpreter for running code when needed. - -Key features: - -- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter -- Connecting to an external MCP tool via a Foundry project connection -- Using `DefaultAzureCredential` for Azure authentication -- OpenTelemetry instrumentation for both the chat client and agent - -> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md). - -## Prerequisites - -In addition to the common prerequisites: - -1. An **Azure AI Foundry project** with a chat model deployed (e.g., `gpt-5.2`, `gpt-4o-mini`) -2. The **Azure AI Developer** role assigned on the Foundry resource (includes the `agents/write` data action required by `UseFoundryTools`) -3. An **MCP tool connection** configured in your Foundry project pointing to `https://learn.microsoft.com/api/mcp` - -## Environment Variables - -In addition to the common environment variables in the root README: - -```powershell -# Your Azure AI Foundry project endpoint (required by UseFoundryTools) -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-resource.services.ai.azure.com/api/projects/your-project" - -# Chat model deployment name (defaults to gpt-4o-mini if not set) -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" - -# The MCP tool connection name (just the name, not the full ARM resource ID) -$env:MCP_TOOL_CONNECTION_ID="SampleMCPTool" -``` - -## How It Works - -1. An `AzureOpenAIClient` is created with `DefaultAzureCredential` and used to get a chat client -2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types: - - **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities - - **Code interpreter**: Allows the agent to execute code snippets when needed -3. `UseFoundryTools` resolves the connection using `AZURE_AI_PROJECT_ENDPOINT` internally -4. A `ChatClientAgent` is created with instructions guiding it to use the MCP tools for documentation queries -5. The agent is hosted using `RunAIAgentAsync` which exposes the OpenAI Responses-compatible API endpoint diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/agent.yaml deleted file mode 100644 index 5d2b1f8d8d..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/agent.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: AgentWithTools -displayName: "Agent with Tools" -description: > - An AI agent that uses Foundry tools (MCP and code interpreter) with Azure OpenAI. - The agent can fetch Microsoft Learn documentation and run code when needed. -metadata: - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Tools - - MCP - - Code Interpreter -template: - kind: hosted - name: AgentWithTools - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-4o-mini - - name: MCP_TOOL_CONNECTION_ID - value: ${MCP_TOOL_CONNECTION_ID} -resources: - - name: "gpt-4o-mini" - kind: model - id: gpt-4o-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/run-requests.http deleted file mode 100644 index 22a37ff54e..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/run-requests.http +++ /dev/null @@ -1,30 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### Simple string input -POST {{endpoint}} -Content-Type: application/json -{ - "input": "Please use the microsoft_docs_fetch tool to fetch and summarize the Microsoft Learn article at https://learn.microsoft.com/azure/ai-services/openai/overview" -} - -### Explicit input -POST {{endpoint}} -Content-Type: application/json -{ - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Please use the microsoft_docs_fetch tool to fetch and summarize the Microsoft Learn article at https://learn.microsoft.com/azure/ai-services/openai/overview" - } - ] - } - ] -} 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..7789abd315 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj @@ -35,10 +35,10 @@ - - + + - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj index b2fb41ac5e..e8c7a434b0 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj @@ -1,4 +1,4 @@ - + Exe net10.0 @@ -33,12 +33,12 @@ - - + + - - - + + + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs index 6947c85e3f..8d21eef20a 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs @@ -41,7 +41,7 @@ try .Build(); Console.WriteLine("Starting Writer-Reviewer Workflow Agent Server on http://localhost:8088"); - await workflow.AsAgent().RunAIAgentAsync(); + await workflow.AsAIAgent().RunAIAgentAsync(); } finally { diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj index 756f3d30ee..70df458d90 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj @@ -33,11 +33,11 @@ - - + + - - + + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/README.md b/dotnet/samples/05-end-to-end/HostedAgents/README.md index a36a9bddd1..919aa4b580 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/README.md +++ b/dotnet/samples/05-end-to-end/HostedAgents/README.md @@ -6,7 +6,6 @@ These samples demonstrate how to build and host AI agents using the [Azure AI Ag | Sample | Description | |--------|-------------| -| [`AgentWithTools`](./AgentWithTools/) | Foundry tools (MCP + code interpreter) via `UseFoundryTools` | | [`AgentWithLocalTools`](./AgentWithLocalTools/) | Local C# function tool execution (Seattle hotel search) | | [`AgentThreadAndHITL`](./AgentThreadAndHITL/) | Human-in-the-loop with `ApprovalRequiredAIFunction` and thread persistence | | [`AgentWithHostedMCP`](./AgentWithHostedMCP/) | Hosted MCP server tool (Microsoft Learn search) | @@ -40,19 +39,18 @@ Most samples require one or more of these environment variables: |----------|---------|-------------| | `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL | | `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-4o-mini`) | -| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithTools, AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Azure AI Foundry project endpoint | -| `MCP_TOOL_CONNECTION_ID` | AgentWithTools | Foundry MCP tool connection name | +| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Azure AI Foundry project endpoint | | `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-4o-mini`) | See each sample's README for the specific variables required. ## Azure AI Foundry Setup (for samples that use Foundry) -Some samples (`AgentWithTools`, `AgentWithLocalTools`) connect to an Azure AI Foundry project. If you're using these samples, you'll need additional setup. +Some samples (`AgentWithLocalTools`, `FoundrySingleAgent`, `FoundryMultiAgent`) connect to an Azure AI Foundry project. If you're using these samples, you'll need additional setup. ### Azure AI Developer Role -The `UseFoundryTools` extension requires the **Azure AI Developer** role on the Cognitive Services resource. Even if you created the project, you may not have this role by default. +Some Foundry operations require the **Azure AI Developer** role on the Cognitive Services resource. Even if you created the project, you may not have this role by default. ```powershell az role assignment create ` @@ -65,23 +63,6 @@ az role assignment create ` For more details on permissions, see [Azure AI Foundry Permissions](https://aka.ms/FoundryPermissions). -### Creating an MCP Tool Connection - -The `AgentWithTools` sample requires an MCP tool connection configured in your Foundry project: - -1. Go to the [Azure AI Foundry portal](https://ai.azure.com) -2. Navigate to your project -3. Go to **Connected resources** → **+ New connection** → **Model Context Protocol tool** -4. Fill in: - - **Name**: `SampleMCPTool` (or any name you prefer) - - **Remote MCP Server endpoint**: `https://learn.microsoft.com/api/mcp` - - **Authentication**: `Unauthenticated` -5. Click **Connect** - -The connection **name** (e.g., `SampleMCPTool`) is used as the `MCP_TOOL_CONNECTION_ID` environment variable. - -> **Important**: Use only the connection **name**, not the full ARM resource ID. - ## Running a Sample Each sample runs as a standalone hosted agent on `http://localhost:8088/`: @@ -110,14 +91,6 @@ Each sample includes a `Dockerfile` and `agent.yaml` for deployment. To deploy y Assign the **Azure AI Developer** role to your user. See [Azure AI Developer Role](#azure-ai-developer-role) above. -### `Project connection ... was not found` - -Make sure `MCP_TOOL_CONNECTION_ID` contains only the connection **name** (e.g., `SampleMCPTool`), not the full ARM resource ID path. - -### `AZURE_AI_PROJECT_ENDPOINT must be set` - -The `UseFoundryTools` extension requires `AZURE_AI_PROJECT_ENDPOINT`. Set it to your Foundry project endpoint (e.g., `https://your-resource.services.ai.azure.com/api/projects/your-project`). - ### Multi-framework error when running `dotnet run` If you see "Your project targets multiple frameworks", specify the framework: 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/Directory.Build.props b/dotnet/src/Directory.Build.props new file mode 100644 index 0000000000..d4ac526fc9 --- /dev/null +++ b/dotnet/src/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj b/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj index 57cb375e14..7fbcfa5237 100644 --- a/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj @@ -16,12 +16,9 @@ Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality. - - - - + diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs index 9c1286c9b9..641825d1dc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs @@ -2,10 +2,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -147,6 +149,7 @@ public abstract class AIContextProvider // Create a filtered context for ProvideAIContextAsync, filtering input messages // to exclude non-external messages (e.g. chat history, other AI context provider messages). +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var filteredContext = new InvokingContext( context.Agent, context.Session, @@ -156,6 +159,7 @@ public abstract class AIContextProvider Messages = inputContext.Messages is not null ? this.ProvideInputMessageFilter(inputContext.Messages) : null, Tools = inputContext.Tools }); +#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var provided = await this.ProvideAIContextAsync(filteredContext, cancellationToken).ConfigureAwait(false); @@ -294,7 +298,9 @@ public abstract class AIContextProvider return default; } +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var subContext = new InvokedContext(context.Agent, context.Session, this.StoreInputRequestMessageFilter(context.RequestMessages), this.StoreInputResponseMessageFilter(context.ResponseMessages!)); +#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. return this.StoreAIContextAsync(subContext, cancellationToken); } @@ -372,6 +378,7 @@ public abstract class AIContextProvider /// The session associated with the agent invocation. /// The AI context to be used by the agent for this invocation. /// or is . + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public InvokingContext( AIAgent agent, AgentSession? session, @@ -431,6 +438,7 @@ public abstract class AIContextProvider /// that were used by the agent for this invocation. /// The response messages generated during this invocation. /// , , or is . + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public InvokedContext( AIAgent agent, AgentSession? session, diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs index f4f198df97..a2aa8e1ed5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs @@ -2,10 +2,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -250,7 +252,9 @@ public abstract class ChatHistoryProvider return default; } +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var subContext = new InvokedContext(context.Agent, context.Session, this._storeInputRequestMessageFilter(context.RequestMessages), this._storeInputResponseMessageFilter(context.ResponseMessages!)); +#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. return this.StoreChatHistoryAsync(subContext, cancellationToken); } @@ -340,6 +344,7 @@ public abstract class ChatHistoryProvider /// The session associated with the agent invocation. /// The messages to be used by the agent for this invocation. /// is . + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public InvokingContext( AIAgent agent, AgentSession? session, @@ -399,6 +404,7 @@ public abstract class ChatHistoryProvider /// that were used by the agent for this invocation. /// The response messages generated during this invocation. /// , , or is . + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public InvokedContext( AIAgent agent, AgentSession? session, diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/MessageAIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/MessageAIContextProvider.cs index c5f367443c..041e621341 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/MessageAIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/MessageAIContextProvider.cs @@ -2,10 +2,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -49,12 +51,14 @@ public abstract class MessageAIContextProvider : AIContextProvider { // Call ProvideMessagesAsync directly to return only additional messages. // The base AIContextProvider.InvokingCoreAsync handles merging with the original input and stamping. +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. return new AIContext { Messages = await this.ProvideMessagesAsync( new InvokingContext(context.Agent, context.Session, context.AIContext.Messages ?? []), cancellationToken).ConfigureAwait(false) }; +#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } /// @@ -109,10 +113,12 @@ public abstract class MessageAIContextProvider : AIContextProvider // Create a filtered context for ProvideMessagesAsync, filtering input messages // to exclude non-external messages (e.g. chat history, other AI context provider messages). +#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var filteredContext = new InvokingContext( context.Agent, context.Session, this.ProvideInputMessageFilter(inputMessages)); +#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var providedMessages = await this.ProvideMessagesAsync(filteredContext, cancellationToken).ConfigureAwait(false); @@ -163,6 +169,7 @@ public abstract class MessageAIContextProvider : AIContextProvider /// The session associated with the agent invocation. /// The messages to be used by the agent for this invocation. /// or is . + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public InvokingContext( AIAgent agent, AgentSession? session, diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj index e31093e174..9acfb1fab3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj @@ -28,7 +28,6 @@ - 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..8e14047919 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,7 @@ Microsoft Agent Framework AzureAI Persistent Agents Provides Microsoft Agent Framework support for Azure AI Persistent Agents. + 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/src/Microsoft.Agents.AI.AzureAI.Persistent/README.md b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/README.md new file mode 100644 index 0000000000..0513ad5d99 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/README.md @@ -0,0 +1,3 @@ +# Microsoft.Agents.AI.AzureAI.Persistent + +Provides integration between the Microsoft Agent Framework and Azure AI Agents Persistent (`Azure.AI.Agents.Persistent`). 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.AzureAI/ProjectResponsesClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs new file mode 100644 index 0000000000..5a899d5076 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Azure.AI.Extensions.OpenAI; + +/// +/// Provides extension methods for +/// to simplify the creation of AI agents that work with Azure AI services. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class ProjectResponsesClientExtensions +{ + /// + /// Gets an for use with this that does not store responses for later retrieval. + /// + /// + /// This corresponds to setting the "store" property in the JSON representation to false. + /// + /// The client. + /// Optional deployment name (model) to use for requests. + /// + /// 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 + /// (like when the store parameter is set to false, or when an organization is enrolled in the zero data retention program). + /// Defaults to . + /// + /// 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 ProjectResponsesClient responseClient, string? deploymentName = null, bool includeReasoningEncryptedContent = true) + { + return Throw.IfNull(responseClient) + .AsIChatClient(deploymentName) + .AsBuilder() + .ConfigureOptions(x => + { + var previousFactory = x.RawRepresentationFactory; + x.RawRepresentationFactory = state => + { + var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions(); + + responseOptions.StoredOutputEnabled = false; + + if (includeReasoningEncryptedContent && + !responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent)) + { + responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent); + } + + return responseOptions; + }; + }) + .Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index 35baa055d1..6f9f37518e 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -37,7 +38,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider private readonly string _memoryStoreName; private readonly int _maxMemories; private readonly int _updateDelay; - private readonly bool _enableSensitiveTelemetryData; + private readonly Redactor _redactor; private readonly AIProjectClient _client; private readonly ILogger? _logger; @@ -79,7 +80,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider this._memoryStoreName = memoryStoreName; this._maxMemories = effectiveOptions.MaxMemories; this._updateDelay = effectiveOptions.UpdateDelay; - this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; + this._redactor = effectiveOptions.EnableSensitiveTelemetryData ? NullRedactor.Instance : (effectiveOptions.Redactor ?? new ReplacingRedactor("")); } /// @@ -416,7 +417,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider private static bool IsAllowedRole(ChatRole role) => role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System; - private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + private string SanitizeLogData(string? data) => this._redactor.Redact(data); /// /// Represents the state of a stored in the . diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs index 870fe1d271..cf4fb5ab15 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI.FoundryMemory; @@ -37,8 +38,22 @@ public sealed class FoundryMemoryProviderOptions /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. /// /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// public bool EnableSensitiveTelemetryData { get; set; } + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Gets or sets the key used to store the provider state in the session's . /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj index a1b8f85ae8..7abc3d0bcc 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj @@ -8,6 +8,7 @@ true true + true true true @@ -20,6 +21,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index 8f6ac4de24..d6169ad805 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -18,7 +18,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index 8239ff17cc..64ec846eb8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -167,6 +167,20 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor return; } + if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint) + { + if (mcpToolInvocationContext is null) + { + throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}."); + } + + context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync( + mcpToolInvocationContext, + durableTaskClient, + context); + return; + } + throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}."); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index 6dc1ab2244..e6c94347a1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -29,6 +29,7 @@ internal static class BuiltInFunctions internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}"; internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}"; internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}"; + internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}"; #pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location); @@ -378,6 +379,55 @@ internal static class BuiltInFunctions return agentResponse.Text; } + /// + /// Runs a workflow via MCP tool trigger. + /// Extracts the input argument, schedules a new orchestration, waits for completion, and returns the output. + /// + public static async Task RunWorkflowMcpToolAsync( + [McpToolTrigger("BuiltInWorkflowMcpTool")] ToolInvocationContext context, + [DurableClient] DurableTaskClient client, + FunctionContext functionContext) + { + if (context.Arguments is null) + { + throw new ArgumentException("MCP Tool invocation is missing required arguments."); + } + + if (!context.Arguments.TryGetValue("input", out object? inputObj) || inputObj is not string input) + { + throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string."); + } + + string workflowName = context.Name; + string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName); + + DurableWorkflowInput orchestrationInput = new() { Input = input }; + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput); + + OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true, + cancellation: functionContext.CancellationToken); + + if (metadata is null) + { + throw new InvalidOperationException($"Workflow orchestration '{instanceId}' returned no metadata."); + } + + if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed) + { + string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error"; + throw new InvalidOperationException($"Workflow orchestration '{instanceId}' failed: {errorMessage}"); + } + + if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed) + { + throw new InvalidOperationException($"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'."); + } + + return metadata.ReadOutputAs()?.Result; + } + /// /// Creates an error response with the specified status code and error message. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 93c90bba9c..2c188757d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768)) - Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) ## v1.0.0-preview.251219.1 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs index 1039fb5aec..4debb5facf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DefaultFunctionsAgentOptionsProvider.cs @@ -6,7 +6,8 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// /// Provides access to agent-specific options for functions agents by name. -/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured. +/// Returns when no explicit options have been configured for an agent, +/// which distinguishes standalone agents from those auto-registered by workflows. /// internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary functionsAgentOptions) : IFunctionsAgentOptionsProvider @@ -14,32 +15,19 @@ internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary _functionsAgentOptions = functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions)); - // Default options. HTTP trigger enabled, MCP tool disabled. - private static readonly FunctionsAgentOptions s_defaultOptions = new() - { - HttpTrigger = { IsEnabled = true }, - McpToolTrigger = { IsEnabled = false } - }; - /// /// Attempts to retrieve the options associated with the specified agent name. - /// If not found, a default options instance (with HTTP trigger enabled) is returned. + /// Returns when no options have been explicitly configured for the agent. /// /// The name of the agent whose options are to be retrieved. Cannot be null or empty. - /// The options for the specified agent. Will never be null. - /// Always true. Returns configured options if present; otherwise default fallback options. + /// + /// When this method returns , contains the options for the specified agent; + /// otherwise, . + /// + /// if options were found for the agent; otherwise, . public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options) { ArgumentException.ThrowIfNullOrEmpty(agentName); - - if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing)) - { - options = existing; - return true; - } - - // If not defined, return default options. - options = s_defaultOptions; - return true; + return this._functionsAgentOptions.TryGetValue(agentName, out options); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs index 65578a7383..fe20eeb6f9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentFunctionMetadataTransformer.cs @@ -6,9 +6,13 @@ using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.AzureFunctions; /// -/// Transforms function metadata by registering durable agent functions for each configured agent. +/// Transforms function metadata by registering durable agent functions for each explicitly configured agent. /// -/// This transformer adds both entity trigger and HTTP trigger functions for every agent registered in the application. +/// +/// This transformer adds entity, HTTP, and MCP tool trigger functions for agents that have +/// explicit . Agents auto-registered by workflows +/// (which lack explicit options) are handled by . +/// internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer { private readonly ILogger _logger; @@ -38,24 +42,27 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat { string agentName = kvp.Key; - this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); + // Only generate triggers for agents with explicit Functions agent options. + // Agents auto-registered by workflows are handled by DurableWorkflowsFunctionMetadataTransformer. + if (!this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions)) + { + continue; + } + this._logger.LogRegisteringTriggerForAgent(agentName, "entity"); original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName)); - if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions)) + if (agentTriggerOptions.HttpTrigger.IsEnabled) { - if (agentTriggerOptions.HttpTrigger.IsEnabled) - { - this._logger.LogRegisteringTriggerForAgent(agentName, "http"); - original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint)); - } + this._logger.LogRegisteringTriggerForAgent(agentName, "http"); + original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint)); + } - if (agentTriggerOptions.McpToolTrigger.IsEnabled) - { - AIAgent agent = kvp.Value(this._serviceProvider); - this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool"); - original.Add(CreateMcpToolTrigger(agentName, agent.Description)); - } + if (agentTriggerOptions.McpToolTrigger.IsEnabled) + { + AIAgent agent = kvp.Value(this._serviceProvider); + this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool"); + original.Add(CreateMcpToolTrigger(agentName, agent.Description)); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs index ad21d8f4e1..8d161710ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableAgentsOptionsExtensions.cs @@ -134,4 +134,17 @@ public static class DurableAgentsOptionsExtensions { return new Dictionary(s_agentOptions, StringComparer.OrdinalIgnoreCase); } + + /// + /// Ensures every agent in has an entry in the + /// options registry. Agents that already have explicit options are left untouched. + /// New entries receive the default configuration (HTTP trigger enabled, MCP tool disabled). + /// + internal static void EnsureDefaultOptionsForAll(IEnumerable agentNames) + { + foreach (string name in agentNames) + { + s_agentOptions.TryAdd(name, new FunctionsAgentOptions { HttpTrigger = { IsEnabled = true } }); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs index d88cd939d9..46053507b1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionMetadataFactory.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json.Nodes; using Microsoft.Agents.AI.DurableTask; using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; @@ -98,4 +99,65 @@ internal static class FunctionMetadataFactory ScriptFile = BuiltInFunctions.ScriptFile, }; } + + /// + /// Creates function metadata for an MCP tool trigger function that starts a workflow. + /// + /// The name of the workflow to expose as an MCP tool. + /// An optional description for the MCP tool. If null, a default description is generated. + /// A configured for an MCP tool trigger. + internal static DefaultFunctionMetadata CreateWorkflowMcpToolTrigger( + string workflowName, + string? description) + { + var functionName = $"{BuiltInFunctions.McpToolPrefix}{workflowName}"; + var toolDescription = description ?? $"Run the {workflowName} workflow"; + + var toolProperties = new JsonArray(new JsonObject + { + ["propertyName"] = "input", + ["propertyType"] = "string", + ["description"] = "The input to the workflow.", + ["isRequired"] = true, + ["isArray"] = false, + }); + + var triggerBinding = new JsonObject + { + ["name"] = "context", + ["type"] = "mcpToolTrigger", + ["direction"] = "In", + ["toolName"] = workflowName, + ["description"] = toolDescription, + ["toolProperties"] = toolProperties.ToJsonString(), + }; + + var inputBinding = new JsonObject + { + ["name"] = "input", + ["type"] = "mcpToolProperty", + ["direction"] = "In", + ["propertyName"] = "input", + ["description"] = "The input to the workflow", + ["isRequired"] = true, + ["dataType"] = "String", + ["propertyType"] = "string", + }; + + var clientBinding = new JsonObject + { + ["name"] = "client", + ["type"] = "durableClient", + ["direction"] = "In", + }; + + return new DefaultFunctionMetadata + { + Name = functionName, + Language = "dotnet-isolated", + RawBindings = [triggerBinding.ToJsonString(), inputBinding.ToJsonString(), clientBinding.ToJsonString()], + EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, + ScriptFile = BuiltInFunctions.ScriptFile, + }; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs index ceb47c389a..3c5e7936da 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs @@ -27,9 +27,16 @@ public static class FunctionsApplicationBuilderExtensions { ArgumentNullException.ThrowIfNull(configure); + // Create/get shared options BEFORE the DurableTask library call so it can find them. + FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services); + // The main agent services registration is done in Microsoft.DurableTask.Agents. builder.Services.ConfigureDurableAgents(configure); + // Ensure all agents registered through this path have default FunctionsAgentOptions. + // This distinguishes them from agents auto-registered by workflows. + DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(sharedOptions.Agents.GetAgentFactories().Keys); + builder.Services.TryAddSingleton(_ => new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); @@ -67,6 +74,13 @@ public static class FunctionsApplicationBuilderExtensions builder.Services.ConfigureDurableOptions(configure); + if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0) + { + builder.Services.TryAddSingleton(_ => + new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot())); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + if (sharedOptions.Workflows.Workflows.Count > 0) { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -102,12 +116,14 @@ public static class FunctionsApplicationBuilderExtensions builder.UseWhen(static context => string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) || string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) || - string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal) || + string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal) ); builder.Services.TryAddSingleton(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs index 6e7b6ec5a8..ee9051fa0d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsDurableOptions.cs @@ -10,6 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions; internal sealed class FunctionsDurableOptions : DurableOptions { private readonly HashSet _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _mcpToolTriggerWorkflows = new(StringComparer.OrdinalIgnoreCase); /// /// Enables the status HTTP endpoint for the specified workflow. @@ -26,4 +27,20 @@ internal sealed class FunctionsDurableOptions : DurableOptions { return this._statusEndpointWorkflows.Contains(workflowName); } + + /// + /// Enables the MCP tool trigger for the specified workflow. + /// + internal void EnableMcpToolTrigger(string workflowName) + { + this._mcpToolTriggerWorkflows.Add(workflowName); + } + + /// + /// Returns whether the MCP tool trigger is enabled for the specified workflow. + /// + internal bool IsMcpToolTriggerEnabled(string workflowName) + { + return this._mcpToolTriggerWorkflows.Contains(workflowName); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs index 6f40cbb791..7cf38397ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowOptionsExtensions.cs @@ -27,4 +27,31 @@ public static class DurableWorkflowOptionsExtensions functionsOptions.EnableStatusEndpoint(workflow.Name!); } } + + /// + /// Adds a workflow and configures whether to expose a status HTTP endpoint and/or an MCP tool trigger. + /// + /// The workflow options to add the workflow to. + /// The workflow instance to add. + /// If , a GET endpoint is generated at workflows/{name}/status/{runId}. + /// If , an MCP tool trigger is generated for the workflow. + public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint, bool exposeMcpToolTrigger) + { + ArgumentNullException.ThrowIfNull(options); + + options.AddWorkflow(workflow); + + if (options.ParentOptions is FunctionsDurableOptions functionsOptions) + { + if (exposeStatusEndpoint) + { + functionsOptions.EnableStatusEndpoint(workflow.Name!); + } + + if (exposeMcpToolTrigger) + { + functionsOptions.EnableMcpToolTrigger(workflow.Name!); + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs index c7ad9a5ebd..dc7b799b00 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/DurableWorkflowsFunctionMetadataTransformer.cs @@ -50,8 +50,11 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet int initialCount = original.Count; this._logger.LogTransformingFunctionMetadata(initialCount); - // Track registered function names to avoid duplicates when workflows share executors. - HashSet registeredFunctions = []; + // Seed with existing function names to avoid duplicates across transformers + // (e.g., when DurableAgentFunctionMetadataTransformer already registered entity triggers). + HashSet registeredFunctions = new( + original.Select(f => f.Name!), + StringComparer.OrdinalIgnoreCase); DurableWorkflowOptions workflowOptions = this._options.Workflows; foreach (var workflow in workflowOptions.Workflows) @@ -113,6 +116,17 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet } } + // Register an MCP tool trigger if opted in via AddWorkflow(exposeMcpToolTrigger: true). + if (this._options.IsMcpToolTriggerEnabled(workflow.Key)) + { + string mcpToolFunctionName = $"{BuiltInFunctions.McpToolPrefix}{workflow.Key}"; + if (registeredFunctions.Add(mcpToolFunctionName)) + { + this._logger.LogRegisteringWorkflowTrigger(workflow.Key, mcpToolFunctionName, "mcpTool"); + original.Add(FunctionMetadataFactory.CreateWorkflowMcpToolTrigger(workflow.Key, workflow.Value.Description)); + } + } + // Register activity or entity functions for each executor in the workflow. // ReflectExecutors() returns all executors across the graph; no need to manually traverse edges. foreach (KeyValuePair entry in workflow.Value.ReflectExecutors()) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs index 3e9483c616..2433eacbe0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs @@ -39,14 +39,16 @@ internal abstract record ChatCompletionRequestMessage /// Thrown when the content is neither text nor AI contents. public virtual ChatMessage ToChatMessage() { + var role = new ChatRole(this.Role); + if (this.Content.IsText) { - return new(ChatRole.User, this.Content.Text); + return new(role, this.Content.Text); } else if (this.Content.IsContents) { var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList(); - return new ChatMessage(ChatRole.User, aiContents!); + return new ChatMessage(role, aiContents!); } throw new InvalidOperationException("MessageContent has no value"); @@ -165,9 +167,11 @@ internal sealed record FunctionMessage : ChatCompletionRequestMessage /// Thrown when the content is not text. public override ChatMessage ToChatMessage() { + var role = new ChatRole(this.Role); + if (this.Content.IsText) { - return new(ChatRole.User, this.Content.Text); + return new(role, this.Content.Text); } throw new InvalidOperationException("FunctionMessage Content must be text"); 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.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index d7c54e2114..8be799ac1a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -8,6 +8,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -51,7 +52,7 @@ public sealed class Mem0Provider : MessageAIContextProvider private readonly ProviderSessionState _sessionState; private IReadOnlyList? _stateKeys; private readonly string _contextPrompt; - private readonly bool _enableSensitiveTelemetryData; + private readonly Redactor _redactor; private readonly Mem0Client _client; private readonly ILogger? _logger; @@ -91,7 +92,7 @@ public sealed class Mem0Provider : MessageAIContextProvider this._client = new Mem0Client(httpClient); this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; - this._enableSensitiveTelemetryData = options?.EnableSensitiveTelemetryData ?? false; + this._redactor = options?.EnableSensitiveTelemetryData == true ? NullRedactor.Instance : (options?.Redactor ?? new ReplacingRedactor("")); } /// @@ -297,5 +298,5 @@ public sealed class Mem0Provider : MessageAIContextProvider public Mem0ProviderScope SearchScope { get; } } - private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + private string SanitizeLogData(string? data) => this._redactor.Redact(data); } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs index 4a3a16712f..2c09bf9a7d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI.Mem0; @@ -21,8 +22,22 @@ public sealed class Mem0ProviderOptions /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. /// /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// public bool EnableSensitiveTelemetryData { get; set; } + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Gets or sets the key used to store the provider state in the session's . /// diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj index 19a5019843..9612b3d4b0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj @@ -6,6 +6,7 @@ true + true true @@ -15,10 +16,18 @@ false + + + + + + + + Microsoft Agent Framework - Mem0 integration 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..4ceff75743 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. /// /// 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/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index b8b32f3b06..d0ba8406b5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -26,7 +26,7 @@ - + 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.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs index b62377a971..66b67bffdf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -68,7 +68,7 @@ internal static class SemanticAnalyzer string classKey = GetClassKey(classSymbol); bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool derivesFromExecutor = DerivesFromExecutor(classSymbol); - bool configureProtocol = HasConfigureProtocolDefined(classSymbol); + bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); // Extract class metadata string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true @@ -97,7 +97,7 @@ internal static class SemanticAnalyzer return new MethodAnalysisResult( classKey, @namespace, className, genericParameters, isNested, containingTypeChain, baseHasConfigureProtocol, classSendTypes, classYieldTypes, - isPartialClass, derivesFromExecutor, configureProtocol, + isPartialClass, derivesFromExecutor, hasManualConfigureProtocol, classLocation, handler, Diagnostics: new ImmutableEquatableArray(methodDiagnostics.ToImmutable())); @@ -149,7 +149,7 @@ internal static class SemanticAnalyzer return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } - if (first.HasManualConfigureRoutes) + if (first.HasManualConfigureProtocol) { allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.ConfigureProtocolAlreadyDefined, @@ -212,6 +212,7 @@ internal static class SemanticAnalyzer bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool derivesFromExecutor = DerivesFromExecutor(classSymbol); bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); + bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol); string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true ? null @@ -241,6 +242,7 @@ internal static class SemanticAnalyzer isPartialClass, derivesFromExecutor, hasManualConfigureProtocol, + baseHasConfigureProtocol, classLocation, typeName, attributeKind)); @@ -321,7 +323,7 @@ internal static class SemanticAnalyzer first.GenericParameters, first.IsNested, first.ContainingTypeChain, - BaseHasConfigureProtocol: false, // Not relevant for protocol-only + first.BaseHasConfigureProtocol, Handlers: ImmutableEquatableArray.Empty, ClassSendTypes: new ImmutableEquatableArray(sendTypes.ToImmutable()), ClassYieldTypes: new ImmutableEquatableArray(yieldTypes.ToImmutable())); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs index 9a74c88447..23d748e629 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs @@ -38,6 +38,7 @@ internal static class SourceBuilder sb.AppendLine("using System.Collections.Generic;"); sb.AppendLine("using Microsoft.Agents.AI.Workflows;"); sb.AppendLine(); + sb.AppendLine("using RouteBuilder = Microsoft.Agents.AI.Workflows.RouteBuilder;"); // Namespace if (!string.IsNullOrWhiteSpace(info.Namespace)) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs index df9205cc5f..1039855ea5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// /// Represents protocol type information extracted from class-level [SendsMessage] or [YieldsOutput] attributes. /// Used by the incremental generator pipeline to capture classes that declare protocol types -/// but may not have [MessageHandler] methods (e.g., when ConfigureRoutes is manually implemented). +/// but may not have [MessageHandler] methods (e.g., when ConfigureProtocol is manually implemented). /// /// Unique identifier for the class (fully qualified name). /// The namespace of the class. @@ -15,7 +15,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// The chain of containing types for nested classes. Empty if not nested. /// Whether the class is declared as partial. /// Whether the class derives from Executor. -/// Whether the class has a manually defined ConfigureRoutes method. +/// Whether the class has a manually defined ConfigureProtocol method. +/// Whether a base class already overrides ConfigureProtocol. /// Location info for diagnostics. /// The fully qualified type name from the attribute. /// Whether this is from a SendsMessage or YieldsOutput attribute. @@ -28,7 +29,8 @@ internal sealed record ClassProtocolInfo( string ContainingTypeChain, bool IsPartialClass, bool DerivesFromExecutor, - bool HasManualConfigureRoutes, + bool HasManualConfigureProtocol, + bool BaseHasConfigureProtocol, DiagnosticLocationInfo? ClassLocation, string TypeName, ProtocolAttributeKind AttributeKind) @@ -38,5 +40,5 @@ internal sealed record ClassProtocolInfo( /// public static ClassProtocolInfo Empty { get; } = new( string.Empty, null, string.Empty, null, false, string.Empty, - false, false, false, null, string.Empty, ProtocolAttributeKind.Send); + false, false, false, false, null, string.Empty, ProtocolAttributeKind.Send); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs index fb3fafc6c2..4b6df3f7a5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// Uses value-equatable types to support incremental generator caching. /// /// -/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes) +/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureProtocol) /// is extracted here but validated once per class in CombineMethodResults to avoid /// redundant validation work when a class has multiple handlers. /// @@ -29,7 +29,7 @@ internal sealed record MethodAnalysisResult( // Class-level facts (used for validation in CombineMethodResults) bool IsPartialClass, bool DerivesFromExecutor, - bool HasManualConfigureRoutes, + bool HasManualConfigureProtocol, // Class location for diagnostics (value-equatable) DiagnosticLocationInfo? ClassLocation, 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/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 501c7df230..22ee1b48eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder return builder.Build(); } - /// Creates a new using as the starting agent in the workflow. + /// Creates a new using as the starting agent in the workflow. /// The agent that will receive inputs provided to the workflow. /// The builder for creating a workflow based on handoffs. /// @@ -154,7 +154,7 @@ public static partial class AgentWorkflowBuilder /// The must be capable of understanding those provided. If the agent /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. /// - public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) + public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) { Throw.IfNull(initialAgent); return new(initialAgent); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs index 543fdeb530..c47298f112 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs @@ -5,11 +5,14 @@ using System.Collections.Generic; using System.IO; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Workflows.Checkpointing; +internal record CheckpointFileIndexEntry(CheckpointInfo CheckpointInfo, string FileName); + /// /// Provides a file system-based implementation of a JSON checkpoint store that persists checkpoint data and index /// information to disk using JSON files. @@ -28,6 +31,8 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos internal DirectoryInfo Directory { get; } internal HashSet CheckpointIndex { get; } + private static JsonTypeInfo EntryTypeInfo => WorkflowsJsonUtilities.JsonContext.Default.CheckpointFileIndexEntry; + /// /// Initializes a new instance of the class that uses the specified directory /// @@ -64,9 +69,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos using StreamReader reader = new(this._indexFile, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, BufferSize, leaveOpen: true); while (reader.ReadLine() is string line) { - if (JsonSerializer.Deserialize(line, KeyTypeInfo) is { } info) + if (JsonSerializer.Deserialize(line, EntryTypeInfo) is { } entry) { - this.CheckpointIndex.Add(info); + // We never actually use the file names from the index entries since they can be derived from the CheckpointInfo, but it is useful to + // have the UrlEncoded file names in the index file for human readability + this.CheckpointIndex.Add(entry.CheckpointInfo); } } } @@ -93,8 +100,14 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos } } - private string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key) - => Path.Combine(this.Directory.FullName, $"{sessionId}_{key.CheckpointId}.json"); + internal string GetFileNameForCheckpoint(string sessionId, CheckpointInfo key) + { + string protoPath = $"{sessionId}_{key.CheckpointId}.json"; + + // Escape the protoPath to ensure it is a valid file name, especially if sessionId or CheckpointId contain path separators, etc. + return Uri.EscapeDataString(protoPath) // This takes care of most of the invalid path characters + .Replace(".", "%2E"); // This takes care of escaping the root folder, since EscapeDataString does not escape dots + } private CheckpointInfo GetUnusedCheckpointInfo(string sessionId) { @@ -116,13 +129,16 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos CheckpointInfo key = this.GetUnusedCheckpointInfo(sessionId); string fileName = this.GetFileNameForCheckpoint(sessionId, key); + string filePath = Path.Combine(this.Directory.FullName, fileName); + try { - using Stream checkpointStream = File.Open(fileName, FileMode.Create, FileAccess.Write, FileShare.None); + using Stream checkpointStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); using Utf8JsonWriter jsonWriter = new(checkpointStream, new JsonWriterOptions() { Indented = false }); value.WriteTo(jsonWriter); - JsonSerializer.Serialize(this._indexFile!, key, KeyTypeInfo); + CheckpointFileIndexEntry entry = new(key, fileName); + JsonSerializer.Serialize(this._indexFile!, entry, EntryTypeInfo); byte[] bytes = Encoding.UTF8.GetBytes(Environment.NewLine); await this._indexFile!.WriteAsync(bytes, 0, bytes.Length, CancellationToken.None).ConfigureAwait(false); await this._indexFile!.FlushAsync(CancellationToken.None).ConfigureAwait(false); @@ -136,7 +152,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos try { // try to clean up after ourselves - File.Delete(fileName); + File.Delete(filePath); } catch { } @@ -149,6 +165,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos { this.CheckDisposed(); string fileName = this.GetFileNameForCheckpoint(sessionId, key); + string filePath = Path.Combine(this.Directory.FullName, fileName); if (!this.CheckpointIndex.Contains(key) || !File.Exists(fileName)) @@ -156,7 +173,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos throw new KeyNotFoundException($"Checkpoint '{key.CheckpointId}' not found in store at '{this.Directory.FullName}'."); } - using FileStream checkpointFileStream = File.Open(fileName, FileMode.Open, FileAccess.Read, FileShare.Read); + using FileStream checkpointFileStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); using JsonDocument document = await JsonDocument.ParseAsync(checkpointFileStream).ConfigureAwait(false); return document.RootElement.Clone(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs index e18bae72a5..d6e6df5dbd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ConfigurationExtensions.cs @@ -3,9 +3,9 @@ namespace Microsoft.Agents.AI.Workflows; /// -/// Provides extensions methods for creating objects +/// Provides extension methods for creating objects /// -public static class ConfigurationExtensions +internal static class ConfigurationExtensions { /// /// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs index 3f876926be..b154bd6ca7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Configured.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Provides methods for creating instances. /// -public static class Configured +internal static class Configured { /// /// Creates a instance from an existing subject instance. @@ -50,10 +50,10 @@ public static class Configured /// A representation of a preconfigured, lazy-instantiatable instance of . /// /// The type of the preconfigured subject. -/// A factory to intantiate the subject when desired. +/// A factory to instantiate the subject when desired. /// The unique identifier for the configured subject. /// -public class Configured(Func> factoryAsync, string id, object? raw = null) +internal class Configured(Func> factoryAsync, string id, object? raw = null) { /// /// Gets the raw representation of the configured object, if any. @@ -66,14 +66,14 @@ public class Configured(Func> fact public string Id => id; /// - /// Gets the factory function to create an instance of given a . + /// Gets the factory function to create an instance of given a . /// - public Func> FactoryAsync => factoryAsync; + public Func> FactoryAsync => factoryAsync; /// /// The configuration for this configured instance. /// - public Config Configuration => new(this.Id); + public ExecutorConfig Configuration => new(this.Id); /// /// Gets a "partially" applied factory function that only requires no parameters to create an instance of @@ -87,11 +87,11 @@ public class Configured(Func> fact /// /// The type of the preconfigured subject. /// The type of configuration options for the preconfigured subject. -/// A factory to intantiate the subject when desired. +/// A factory to instantiate the subject when desired. /// The unique identifier for the configured subject. /// Additional configuration options for the subject. /// -public class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null) +internal class Configured(Func, string, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null) { /// /// The raw representation of the configured object, if any. @@ -109,14 +109,14 @@ public class Configured(Func, string, Value public TOptions? Options => options; /// - /// Gets the factory function to create an instance of given a . + /// Gets the factory function to create an instance of given a . /// - public Func, string, ValueTask> FactoryAsync => factoryAsync; + public Func, string, ValueTask> FactoryAsync => factoryAsync; /// /// The configuration for this configured instance. /// - public Config Configuration => new(this.Id, this.Options); + public ExecutorConfig Configuration => new(this.Id, this.Options); /// /// Gets a "partially" applied factory function that only requires no parameters to create an instance of @@ -124,11 +124,11 @@ public class Configured(Func, string, Value /// internal Func> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId); - private Func> CreateValidatingMemoizedFactory() + private Func> CreateValidatingMemoizedFactory() { return FactoryAsync; - async ValueTask FactoryAsync(Config configuration, string sessionId) + async ValueTask FactoryAsync(ExecutorConfig configuration, string sessionId) { if (this.Id != configuration.Id) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs index f5d1d40370..bda7e61a38 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs @@ -53,6 +53,9 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) => this._eventStream.GetStatusAsync(cancellationToken); + internal bool TryGetResponsePortExecutorId(string portId, out string? executorId) + => this._stepRunner.TryGetResponsePortExecutorId(portId, out executorId); + public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default) { //Debug.Assert(breakOnHalt); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs index 8c2162508d..6e3f2af5e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -95,6 +96,18 @@ internal sealed class EdgeMap return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken); } + internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId) + { + if (this._portEdgeRunners.TryGetValue(portId, out ResponseEdgeRunner? portRunner)) + { + executorId = portRunner.ExecutorId; + return true; + } + + executorId = null; + return false; + } + internal async ValueTask> ExportStateAsync() { Dictionary exportedStates = []; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeState.cs index db8241c13d..c33160d159 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/FanInEdgeState.cs @@ -3,25 +3,25 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; -using System.Threading; using Microsoft.Agents.AI.Workflows.Checkpointing; namespace Microsoft.Agents.AI.Workflows.Execution; internal sealed class FanInEdgeState { - private List _pendingMessages; + private readonly object _syncLock = new(); + public FanInEdgeState(FanInEdgeData fanInEdge) { this.SourceIds = fanInEdge.SourceIds.ToArray(); this.Unseen = [.. this.SourceIds]; - this._pendingMessages = []; + this.PendingMessages = []; } public string[] SourceIds { get; } public HashSet Unseen { get; private set; } - public List PendingMessages => this._pendingMessages; + public List PendingMessages { get; private set; } [JsonConstructor] public FanInEdgeState(string[] sourceIds, HashSet unseen, List pendingMessages) @@ -29,28 +29,35 @@ internal sealed class FanInEdgeState this.SourceIds = sourceIds; this.Unseen = unseen; - this._pendingMessages = pendingMessages; + this.PendingMessages = pendingMessages; } public IEnumerable>? ProcessMessage(string sourceId, MessageEnvelope envelope) { - this.PendingMessages.Add(new(envelope)); - this.Unseen.Remove(sourceId); + List? takenMessages = null; - if (this.Unseen.Count == 0) + // Serialize concurrent calls from parallel executor tasks during superstep execution. + // NOTE - IMPORTANT: If this ProcessMessage method ever becomes async, replace this lock with an async friendly solution to avoid deadlocks. + lock (this._syncLock) { - List takenMessages = Interlocked.Exchange(ref this._pendingMessages, []); - this.Unseen = [.. this.SourceIds]; + this.PendingMessages.Add(new(envelope)); + this.Unseen.Remove(sourceId); - if (takenMessages.Count == 0) + if (this.Unseen.Count == 0) { - return null; + takenMessages = this.PendingMessages; + this.PendingMessages = []; + this.Unseen = [.. this.SourceIds]; } - - return takenMessages.Select(portable => portable.ToMessageEnvelope()) - .GroupBy(keySelector: messageEnvelope => messageEnvelope.Source); } - return null; + if (takenMessages is null || takenMessages.Count == 0) + { + return null; + } + + return takenMessages + .Select(portable => portable.ToMessageEnvelope()) + .GroupBy(messageEnvelope => messageEnvelope.Source); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs index 9b8c3c460c..8de0dbd5e2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs @@ -19,6 +19,7 @@ internal interface ISuperStepRunner bool HasUnprocessedMessages { get; } ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default); + bool TryGetResponsePortExecutorId(string portId, out string? executorId); ValueTask IsValidInputTypeAsync(CancellationToken cancellationToken = default); ValueTask EnqueueMessageAsync(T message, CancellationToken cancellationToken = default); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs index 81ffedc6af..3e34797bf0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs @@ -106,8 +106,7 @@ internal sealed class StateManager if (typeof(T) == typeof(object)) { // Reading as object will break across serialize/deserialize boundaries, e.g. checkpointing, distributed runtime, etc. - // Disabled pending upstream updates for this change; see https://github.com/microsoft/agent-framework/issues/1369 - //throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants."); + throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants."); } Throw.IfNullOrEmpty(key); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs index a0170e7757..afca74af5b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorBindingExtensions.cs @@ -113,7 +113,7 @@ public static class ExecutorBindingExtensions /// An id for the executor to be instantiated. /// An optional parameter specifying the options. /// An instance that resolves to the result of the factory call when messages get sent to it. - public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + public static ExecutorBinding BindExecutor(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) where TExecutor : Executor where TOptions : ExecutorOptions { @@ -139,7 +139,7 @@ public static class ExecutorBindingExtensions /// An instance that resolves to the result of the factory call when messages get sent to it. [Obsolete("Use BindExecutor() instead")] [EditorBrowsable(EditorBrowsableState.Never)] - public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) + public static ExecutorBinding ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null) where TExecutor : Executor where TOptions : ExecutorOptions => factoryAsync.BindExecutor(id, options); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs index 09792d2a64..48bfd12bb9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Config.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorConfig.cs @@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows; /// Represents a configuration for an object with a string identifier. For example, object. /// /// A unique identifier for the configurable object. -public class Config(string id) +public class ExecutorConfig(string id) { /// /// Gets a unique identifier for the configurable object. @@ -23,7 +23,7 @@ public class Config(string id) /// The type of options for the configurable object. /// A unique identifier for the configurable object. /// The options for the configurable object. -public class Config(string id, TOptions? options = default) : Config(id) +public class ExecutorConfig(string id, TOptions? options = default) : ExecutorConfig(id) { /// /// Gets the options for the configured object. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index bd0b3114f1..8e5cee7ed5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Workflows.Specialized; @@ -8,22 +9,42 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; +/// +[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] +public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + +/// +public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + /// /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. /// -public sealed class HandoffsWorkflowBuilder +public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore { - internal const string FunctionPrefix = "handoff_to_"; + /// + /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, + /// where `<agent_id>` is the ID of the target agent to hand off to. + /// + public const string FunctionPrefix = "handoff_to_"; + private readonly AIAgent _initialAgent; private readonly Dictionary> _targets = []; private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); + + private bool _emitAgentResponseEvents; + private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; + private bool _returnToPrevious; /// /// Initializes a new instance of the class with no handoff relationships. /// /// The first agent to be invoked (prior to any handoff). - internal HandoffsWorkflowBuilder(AIAgent initialAgent) + internal HandoffWorkflowBuilderCore(AIAgent initialAgent) { this._initialAgent = initialAgent; this._allAgents.Add(initialAgent); @@ -47,14 +68,41 @@ public sealed class HandoffsWorkflowBuilder """; /// - /// Sets additional instructions to provide to an agent that has handoffs about how and when to - /// perform them. + /// Sets instructions to provide to each agent that has handoffs about how and when to perform them. /// + /// + /// In the vast majority of cases, the will be sufficient, and there will be no need to customize. + /// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see + /// constant. + /// /// The instructions to provide, or to restore the default instructions. - public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) + public TBuilder WithHandoffInstructions(string? instructions) { this.HandoffInstructions = instructions ?? DefaultHandoffInstructions; - return this; + return (TBuilder)this; + } + + /// + /// Sets a value indicating whether agent streaming update events should be emitted during execution. + /// If , the value will be taken from the + /// + /// + /// + public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) + { + this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; + return (TBuilder)this; + } + + /// + /// Sets a value indicating whether aggregated agent response events should be emitted during execution. + /// + /// + /// + public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) + { + this._emitAgentResponseEvents = emitAgentResponseEvents; + return (TBuilder)this; } /// @@ -62,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder /// s flowing through the handoff workflow. Defaults to . /// /// The filtering behavior to apply. - public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) + public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) { this._toolCallFilteringBehavior = behavior; - return this; + return (TBuilder)this; + } + + /// + /// Configures the workflow so that subsequent user turns route directly back to the specialist agent + /// that handled the previous turn, rather than always routing through the initial (coordinator) agent. + /// + /// The updated instance. + public TBuilder EnableReturnToPrevious() + { + this._returnToPrevious = true; + return (TBuilder)this; } /// @@ -75,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder /// The target agents to add as handoff targets for the source agent. /// The updated instance. /// The handoff reason for each target in is derived from that agent's description or name. - public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to) + public TBuilder WithHandoffs(AIAgent from, IEnumerable to) { Throw.IfNull(from); Throw.IfNull(to); @@ -90,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder this.WithHandoff(from, target); } - return this; + return (TBuilder)this; } /// @@ -103,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -118,7 +177,7 @@ public sealed class HandoffsWorkflowBuilder this.WithHandoff(source, to, handoffReason); } - return this; + return (TBuilder)this; } /// @@ -131,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -161,7 +220,7 @@ public sealed class HandoffsWorkflowBuilder Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered."); } - return this; + return (TBuilder)this; } /// @@ -171,17 +230,40 @@ public sealed class HandoffsWorkflowBuilder /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(); - HandoffsEndExecutor end = new(); + HandoffsStartExecutor start = new(this._returnToPrevious); + HandoffsEndExecutor end = new(this._returnToPrevious); WorkflowBuilder builder = new(start); - HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior); + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, + this._emitAgentResponseEvents, + this._emitAgentResponseUpdateEvents, + this._toolCallFilteringBehavior); - // Create an AgentExecutor for each again. + // Create an AgentExecutor for each agent. Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); - // Connect the start executor to the initial agent. - builder.AddEdge(start, executors[this._initialAgent.Id]); + // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). + if (this._returnToPrevious) + { + string initialAgentId = this._initialAgent.Id; + builder.AddSwitch(start, sb => + { + foreach (var agent in this._allAgents) + { + if (agent.Id != initialAgentId) + { + string agentId = agent.Id; + sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]); + } + } + + sb.WithDefault(executors[initialAgentId]); + }); + } + else + { + builder.AddEdge(start, executors[this._initialAgent.Id]); + } // Initialize each executor with its handoff targets to the other executors. foreach (var agent in this._allAgents) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 2a61f80ced..f93b09ddf3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -160,6 +160,8 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; + bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId) + => this.RunContext.TryGetResponsePortExecutorId(portId, out executorId); public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index eda7b90a80..f0bb8cac26 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -296,6 +296,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext return this._externalRequests.TryRemove(requestId, out _); } + internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId) + => this._edgeMap.TryGetResponsePortExecutorId(portId, out executorId); + private IEventSink OutgoingEvents { get; } internal StateManager StateManager { get; } = new(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index a38f49681a..3f3d83fbee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -12,6 +12,15 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents); +internal static class TurnExtensions +{ + public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting) + => token.EmitEvents ?? agentSetting ?? false; + + public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting) + => turnTokenSetting ?? agentSetting ?? false; +} + internal sealed class AIAgentHostExecutor : ChatProtocolExecutor { private readonly AIAgent _agent; @@ -19,7 +28,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 +47,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,19 +68,26 @@ 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])]; + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => + { + pendingMessages.Add(new ChatMessage(ChatRole.User, [response])); - // ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests. - return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken); + await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); + + // Clear the buffered turn messages because they were consumed by ContinueTurnAsync. + return null; + }, context, cancellationToken); } private ValueTask HandleFunctionResultAsync( @@ -84,12 +100,18 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'."); } - List implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])]; - return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken); - } + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => + { + pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result])); - public bool ShouldEmitStreamingEvents(bool? emitEvents) - => emitEvents ?? this._options.EmitAgentUpdateEvents ?? false; + await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); + + // Clear the buffered turn messages because they were consumed by ContinueTurnAsync. + return null; + }, context, cancellationToken); + } private async ValueTask EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) => this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -159,12 +181,15 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor } protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken); + => this.ContinueTurnAsync(messages, + context, + TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents), + cancellationToken); 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; @@ -198,7 +223,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents)); } - if (this._options.EmitAgentResponseEvents == true) + if (this._options.EmitAgentResponseEvents) { await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } @@ -218,15 +243,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/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs index 9173100b3e..15203fd5dc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIContentExternalHandler.cs @@ -16,10 +16,12 @@ internal sealed class AIContentExternalHandler _pendingRequests = new(); public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func handler) { + this._portId = portId; PortBinding? portBinding = null; protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding)); this._portBinding = portBinding; @@ -58,12 +60,14 @@ internal sealed class AIContentExternalHandler this._portBinding == null; + private string CreateExternalRequestId(string requestId) => $"{this._portId.Length}:{this._portId}:{requestId}"; + private static string MakeKey(string id) => $"{id}_PendingRequests"; public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index d1367b83ad..98205a40c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -14,14 +14,20 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed class HandoffAgentExecutorOptions { - public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) + public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) { this.HandoffInstructions = handoffInstructions; + this.EmitAgentResponseEvents = emitAgentResponseEvents; + this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; this.ToolCallFilteringBehavior = toolCallFilteringBehavior; } public string? HandoffInstructions { get; set; } + public bool EmitAgentResponseEvents { get; set; } + + public bool? EmitAgentResponseUpdateEvents { get; set; } + public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; } @@ -36,7 +42,7 @@ internal sealed class HandoffMessagesFilter internal static bool IsHandoffFunctionName(string name) { - return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); + return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); } public IEnumerable FilterMessages(List messages) @@ -167,6 +173,7 @@ internal sealed class HandoffAgentExecutor( private readonly AIAgent _agent = agent; private readonly HashSet _handoffFunctionNames = []; + private readonly Dictionary _handoffFunctionToAgentId = []; private ChatClientAgentRunOptions? _agentOptions; public void Initialize( @@ -193,9 +200,10 @@ internal sealed class HandoffAgentExecutor( foreach (HandoffTarget handoff in handoffs) { index++; - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); this._handoffFunctionNames.Add(handoffFunc.Name); + this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id; this._agentOptions.ChatOptions.Tools.Add(handoffFunc); @@ -250,16 +258,27 @@ internal sealed class HandoffAgentExecutor( } } - allMessages.AddRange(updates.ToAgentResponse().Messages); + AgentResponse agentResponse = updates.ToAgentResponse(); + + if (options.EmitAgentResponseEvents) + { + await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); + } + + allMessages.AddRange(agentResponse.Messages); roleChanges.ResetUserToAssistantForChangedRoles(); - return new(message.TurnToken, requestedHandoff, allMessages); + string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId) + ? targetAgentId + : this._agent.Id; + + return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId); async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) { updates.Add(update); - if (message.TurnToken.EmitEvents is true) + if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents)) { await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index cc4d87d21a..56e2fef9df 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, string? InvokedHandoff, - List Messages); + List Messages, + string? CurrentAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs index 69f81376be..4a43c00a72 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs @@ -1,20 +1,35 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; /// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor { public const string ExecutorId = "HandoffEnd"; protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((handoff, context, cancellationToken) => - context.YieldOutputAsync(handoff.Messages, cancellationToken))) + this.HandleAsync(handoff, context, cancellationToken))) .YieldsOutput>(); + private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken) + { + if (returnToPrevious) + { + await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey, + handoff.CurrentAgentId, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken) + .ConfigureAwait(false); + } + + await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false); + } + public ValueTask ResetAsync() => default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs index 9039e86f5b..87c3b4566b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs @@ -7,8 +7,14 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; +internal static class HandoffConstants +{ + internal const string CurrentAgentTrackerKey = "LastAgentId"; + internal const string CurrentAgentTrackerScope = "HandoffOrchestration"; +} + /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. -internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -22,7 +28,25 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, base.ConfigureProtocol(protocolBuilder).SendsMessage(); protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken); + { + if (returnToPrevious) + { + return context.InvokeWithStateAsync( + async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) => + { + HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId); + await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); + + return currentAgentId; + }, + HandoffConstants.CurrentAgentTrackerKey, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken); + } + + HandoffState handoff = new(new(emitEvents), null, messages); + return context.SendMessageAsync(handoff, cancellationToken); + } public new ValueTask ResetAsync() => base.ResetAsync(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs index b479cae75e..c7a19380b2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs @@ -60,6 +60,9 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable internal ValueTask TrySendMessageUntypedAsync(object message, Type? declaredType = null) => this._runHandle.EnqueueMessageUntypedAsync(message, declaredType); + internal bool TryGetResponsePortExecutorId(string portId, out string? executorId) + => this._runHandle.TryGetResponsePortExecutorId(portId, out executorId); + /// /// Asynchronously streams workflow events as they occur during workflow execution. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs index 2815ed99f0..69c09abb27 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs @@ -43,7 +43,7 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider => this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages); protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) - => new(this._sessionState.GetOrInitializeState(context.Session).Messages); + => new(this._sessionState.GetOrInitializeState(context.Session).Messages.AsReadOnly()); protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) { @@ -62,6 +62,12 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider } } + public IEnumerable GetAllMessages(AgentSession session) + { + var state = this._sessionState.GetOrInitializeState(session); + return state.Messages.AsReadOnly(); + } + public void UpdateBookmark(AgentSession session) { var state = this._sessionState.GetOrInitializeState(session); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 7679123970..295c08ceeb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -119,13 +119,17 @@ internal sealed class WorkflowHostAgent : AIAgent MessageMerger merger = new(); await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) { merger.AddUpdate(update); } - return merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); + + return response; } protected override async @@ -138,11 +142,18 @@ internal sealed class WorkflowHostAgent : AIAgent await this.ValidateWorkflowAsync().ConfigureAwait(false); WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); + MessageMerger merger = new(); + await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) { + merger.AddUpdate(update); yield return update; } + + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 40a18dbadb..715c232e7d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -25,6 +25,25 @@ internal sealed class WorkflowSession : AgentSession private InMemoryCheckpointManager? _inMemoryCheckpointManager; + /// + /// Tracks pending external requests by their workflow-facing request ID. + /// This mapping enables converting incoming response content back to + /// when resuming a workflow from a checkpoint. + /// + /// + /// + /// Entries are added when a is received during workflow execution, + /// and removed when a matching response is delivered via . + /// + /// + /// The number of entries is bounded by the number of outstanding external requests in a single workflow run. + /// When a session is abandoned, all pending requests are released with the session object. + /// Request-level timeouts, if needed, should be implemented in the workflow definition itself + /// (e.g., using a timer racing against an external event). + /// + /// + private readonly Dictionary _pendingRequests = []; + internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv) { inProcEnv = null; @@ -90,6 +109,7 @@ internal sealed class WorkflowSession : AgentSession this.LastCheckpoint = sessionState.LastCheckpoint; this.StateBag = sessionState.StateBag; + this._pendingRequests = sessionState.PendingRequests ?? []; } public CheckpointInfo? LastCheckpoint { get; set; } @@ -101,7 +121,8 @@ internal sealed class WorkflowSession : AgentSession this.SessionId, this.LastCheckpoint, this._inMemoryCheckpointManager, - this.StateBag); + this.StateBag, + this._pendingRequests); return marshaller.Marshal(info); } @@ -110,7 +131,7 @@ internal sealed class WorkflowSession : AgentSession { Throw.IfNullOrEmpty(parts); - AgentResponseUpdate update = new(ChatRole.Assistant, parts) + return new(ChatRole.Assistant, parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), @@ -118,30 +139,22 @@ internal sealed class WorkflowSession : AgentSession ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message) { Throw.IfNull(message); - AgentResponseUpdate update = new(message.Role, message.Contents) + return new(message.Role, message.Contents) { CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow, MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"), ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } - private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) + private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) { // The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session, // and does not need to be checked again here. @@ -154,109 +167,257 @@ internal sealed class WorkflowSession : AgentSession cancellationToken) .ConfigureAwait(false); - await run.TrySendMessageAsync(messages).ConfigureAwait(false); - return run; + // Process messages: convert response content to ExternalResponse, send regular messages as-is + ResumeDispatchInfo dispatchInfo = await this.SendMessagesWithResponseConversionAsync(run, messages).ConfigureAwait(false); + return new ResumeRunResult(run, dispatchInfo); } - return await this._executionEnvironment + StreamingRun newRun = await this._executionEnvironment .RunStreamingAsync(this._workflow, messages, this.SessionId, cancellationToken) .ConfigureAwait(false); + return new ResumeRunResult(newRun); } + /// + /// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent + /// to ExternalResponse when there's a matching pending request. + /// + /// + /// Structured information about how resume content was dispatched. + /// + private async ValueTask SendMessagesWithResponseConversionAsync(StreamingRun run, List messages) + { + List regularMessages = []; + // Responses are deferred until after regular messages are queued so response handlers + // can merge buffered regular content in the same continuation turn. + List<(ExternalResponse Response, string RequestId)> externalResponses = []; + bool hasMatchedResponseForStartExecutor = false; + + // Tracks content IDs already matched to pending requests within this invocation, + // preventing duplicate responses for the same ID from being sent to the workflow engine. + HashSet? matchedContentIds = null; + + foreach (ChatMessage message in messages) + { + List regularContents = []; + + foreach (AIContent content in message.Contents) + { + string? contentId = GetResponseContentId(content); + + // Skip duplicate response content for an already-matched content ID + if (contentId != null && matchedContentIds?.Contains(contentId) == true) + { + continue; + } + + if (contentId != null + && this.TryGetPendingRequest(contentId) is ExternalRequest pendingRequest) + { + // For intercepted/complex topologies the port may not be registered in the EdgeMap. + // Treat unknown port as non-start-executor (conservative): TurnToken will still be sent. + if (run.TryGetResponsePortExecutorId(pendingRequest.PortInfo.PortId, out string? responseExecutorId)) + { + hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal); + } + + AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest); + externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId)); + (matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId); + } + else + { + regularContents.Add(content); + } + } + + if (regularContents.Count > 0) + { + ChatMessage cloned = message.Clone(); + cloned.Contents = regularContents; + regularMessages.Add(cloned); + } + } + + // Send regular messages first so response handlers can merge them with responses. + bool hasRegularMessages = regularMessages.Count > 0; + if (hasRegularMessages) + { + await run.TrySendMessageAsync(regularMessages).ConfigureAwait(false); + } + + // Send external responses after regular messages. + bool hasMatchedExternalResponses = false; + foreach ((ExternalResponse response, string requestId) in externalResponses) + { + await run.SendResponseAsync(response).ConfigureAwait(false); + hasMatchedExternalResponses = true; + this.RemovePendingRequest(requestId); + } + + return new ResumeDispatchInfo( + hasRegularMessages, + hasMatchedExternalResponses, + hasMatchedResponseForStartExecutor); + } + + /// + /// Creates the workflow-facing request content surfaced in response updates. + /// + private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch + { + ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent) + => CloneFunctionCallContent(functionCallContent, externalRequest.RequestId), + ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent) + => CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId), + ExternalRequest externalRequest + => externalRequest.ToFunctionCall(), + }; + + /// + /// Rewrites workflow-facing response content back to the original agent-owned content ID. + /// + private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch + { + FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent) + => CloneFunctionResultContent(functionResultContent, functionCallContent.CallId), + ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent) + => CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId), + _ => content, + }; + + /// + /// Gets the workflow-facing request ID from response content types. + /// + private static string? GetResponseContentId(AIContent content) => content switch + { + FunctionResultContent functionResultContent => functionResultContent.CallId, + ToolApprovalResponseContent toolApprovalResponseContent => toolApprovalResponseContent.RequestId, + _ => null + }; + + /// + /// Tries to get a pending request by workflow-facing request ID. + /// + private ExternalRequest? TryGetPendingRequest(string requestId) => + this._pendingRequests.TryGetValue(requestId, out ExternalRequest? request) ? request : null; + + /// + /// Adds a pending request indexed by workflow-facing request ID. + /// + private void AddPendingRequest(string requestId, ExternalRequest request) => this._pendingRequests[requestId] = request; + + /// + /// Removes a pending request by workflow-facing request ID. + /// + private void RemovePendingRequest(string requestId) => + this._pendingRequests.Remove(requestId); + internal async IAsyncEnumerable InvokeStageAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { - try - { - this.LastResponseId = Guid.NewGuid().ToString("N"); - List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); + this.LastResponseId = Guid.NewGuid().ToString("N"); + List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); -#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below. - await using StreamingRun run = - await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); + ResumeRunResult resumeResult = + await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); + +#pragma warning disable CA2007 // Analyzer misfiring. + await using StreamingRun run = resumeResult.Run; #pragma warning restore CA2007 + ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo; + + // Send a TurnToken to the start executor unless the only activity is an external + // response directed at the start executor itself (which self-emits a TurnToken via + // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit + // TurnTokens after processing responses, so the session must always provide one. + bool shouldSendTurnToken = + !dispatchInfo.HasMatchedExternalResponses + || !dispatchInfo.HasMatchedResponseForStartExecutor; + if (shouldSendTurnToken) + { await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) + } + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) - { - switch (evt) - { - case AgentResponseUpdateEvent agentUpdate: - yield return agentUpdate.Update; - break; - - case RequestInfoEvent requestInfo: - FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent); - yield return update; - break; - - case WorkflowErrorEvent workflowError: - Exception? exception = workflowError.Exception; - if (exception is TargetInvocationException tie && tie.InnerException != null) - { - exception = tie.InnerException; - } - - if (exception != null) - { - string message = this._includeExceptionDetails - ? exception.Message - : "An error occurred while executing the workflow."; - - ErrorContent errorContent = new(message); - yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); - } - - break; - - case SuperStepCompletedEvent stepCompleted: - this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; - goto default; - - case WorkflowOutputEvent output: - IEnumerable? updateMessages = output.Data switch - { - IEnumerable chatMessages => chatMessages, - ChatMessage chatMessage => [chatMessage], - _ => null - }; - - if (!this._includeWorkflowOutputsInResponse || updateMessages == null) - { - goto default; - } - - foreach (ChatMessage message in updateMessages) - { - yield return this.CreateUpdate(this.LastResponseId, evt, message); - } - break; - - default: - // Emit all other workflow events for observability (DevUI, logging, etc.) - yield return new AgentResponseUpdate(ChatRole.Assistant, []) - { - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Assistant, - ResponseId = this.LastResponseId, - RawRepresentation = evt - }; - break; - } - } - } - finally { - // Do we want to try to undo the step, and not update the bookmark? - this.ChatHistoryProvider.UpdateBookmark(this); + switch (evt) + { + case AgentResponseUpdateEvent agentUpdate: + yield return agentUpdate.Update; + break; + + case RequestInfoEvent requestInfo: + AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request); + + // Track the pending request so we can convert incoming responses back to ExternalResponse. + // External callers respond using the workflow-facing request ID, which is always RequestId. + this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); + + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); + yield return update; + break; + + case WorkflowErrorEvent workflowError: + Exception? exception = workflowError.Exception; + if (exception is TargetInvocationException tie && tie.InnerException != null) + { + exception = tie.InnerException; + } + + if (exception != null) + { + string message = this._includeExceptionDetails + ? exception.Message + : "An error occurred while executing the workflow."; + + ErrorContent errorContent = new(message); + yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); + } + + break; + + case SuperStepCompletedEvent stepCompleted: + this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + goto default; + + case WorkflowOutputEvent output: + IEnumerable? updateMessages = output.Data switch + { + IEnumerable chatMessages => chatMessages, + ChatMessage chatMessage => [chatMessage], + _ => null + }; + + if (!this._includeWorkflowOutputsInResponse || updateMessages == null) + { + goto default; + } + + foreach (ChatMessage message in updateMessages) + { + yield return this.CreateUpdate(this.LastResponseId, evt, message); + } + break; + + default: + // Emit all other workflow events for observability (DevUI, logging, etc.) + yield return new AgentResponseUpdate(ChatRole.Assistant, []) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + ResponseId = this.LastResponseId, + RawRepresentation = evt + }; + break; + } } } @@ -267,15 +428,116 @@ internal sealed class WorkflowSession : AgentSession /// public WorkflowChatHistoryProvider ChatHistoryProvider { get; } + /// + /// Captures the outcome of creating or resuming a workflow run, + /// indicating what types of messages were sent during resume. + /// + private readonly struct ResumeRunResult + { + /// The streaming run that was created or resumed. + public StreamingRun Run { get; } + + /// How resume-time content was dispatched into the workflow runtime. + public ResumeDispatchInfo DispatchInfo { get; } + + public ResumeRunResult(StreamingRun run, ResumeDispatchInfo dispatchInfo = default) + { + this.Run = Throw.IfNull(run); + this.DispatchInfo = dispatchInfo; + } + } + + /// + /// Captures how resumed input was split across regular-message and external-response delivery paths. + /// + private readonly struct ResumeDispatchInfo + { + public ResumeDispatchInfo(bool hasRegularMessages, bool hasMatchedExternalResponses, bool hasMatchedResponseForStartExecutor) + { + this.HasRegularMessages = hasRegularMessages; + this.HasMatchedExternalResponses = hasMatchedExternalResponses; + this.HasMatchedResponseForStartExecutor = hasMatchedResponseForStartExecutor; + } + + public bool HasRegularMessages { get; } + + public bool HasMatchedExternalResponses { get; } + + public bool HasMatchedResponseForStartExecutor { get; } + } + + /// + /// Clones a with a workflow-facing call ID. + /// + private static FunctionCallContent CloneFunctionCallContent(FunctionCallContent content, string callId) + { + FunctionCallContent clone = new(callId, content.Name, content.Arguments) + { + Exception = content.Exception, + InformationalOnly = content.InformationalOnly, + }; + + return CopyContentMetadata(content, clone); + } + + /// + /// Clones a with an agent-owned call ID. + /// + private static FunctionResultContent CloneFunctionResultContent(FunctionResultContent content, string callId) + { + FunctionResultContent clone = new(callId, content.Result) + { + Exception = content.Exception, + }; + + return CopyContentMetadata(content, clone); + } + + /// + /// Clones a with a workflow-facing request ID. + /// + private static ToolApprovalRequestContent CloneToolApprovalRequestContent(ToolApprovalRequestContent content, string id) + { + ToolApprovalRequestContent clone = new(id, content.ToolCall); + return CopyContentMetadata(content, clone); + } + + /// + /// Clones a with an agent-owned request ID. + /// + private static ToolApprovalResponseContent CloneToolApprovalResponseContent(ToolApprovalResponseContent content, string id) + { + ToolApprovalResponseContent clone = new(id, content.Approved, content.ToolCall) + { + Reason = content.Reason, + }; + + return CopyContentMetadata(content, clone); + } + + /// + /// Copies shared metadata to a cloned content instance. + /// + private static TContent CopyContentMetadata(AIContent source, TContent target) + where TContent : AIContent + { + target.AdditionalProperties = source.AdditionalProperties; + target.Annotations = source.Annotations; + target.RawRepresentation = source.RawRepresentation; + return target; + } + internal sealed class SessionState( string sessionId, CheckpointInfo? lastCheckpoint, InMemoryCheckpointManager? checkpointManager = null, - AgentSessionStateBag? stateBag = null) + AgentSessionStateBag? stateBag = null, + Dictionary? pendingRequests = null) { public string SessionId { get; } = sessionId; public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint; public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager; public AgentSessionStateBag StateBag { get; } = stateBag ?? new(); + public Dictionary? PendingRequests { get; } = pendingRequests; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs index 08d1dcbcbb..4a94961522 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs @@ -71,6 +71,7 @@ internal static partial class WorkflowsJsonUtilities [JsonSerializable(typeof(PortableValue))] [JsonSerializable(typeof(PortableMessageEnvelope))] [JsonSerializable(typeof(InMemoryCheckpointManager))] + [JsonSerializable(typeof(CheckpointFileIndexEntry))] // Runtime State Types [JsonSerializable(typeof(ScopeKey))] diff --git a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs index 305abe0465..bf93832232 100644 --- a/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs @@ -161,6 +161,8 @@ internal sealed class AIContextProviderChatClient : DelegatingChatClient } // Materialize the accumulated context back into messages and options. + // Clone options to avoid mutating the caller's instance across calls. + options = options?.Clone(); var enrichedMessages = aiContext.Messages ?? []; var tools = aiContext.Tools as IList ?? aiContext.Tools?.ToList(); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index adb6eb9f83..05caff4d83 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -138,6 +138,9 @@ public sealed partial class ChatClientAgent : AIAgent this._aiContextProviderStateKeys = ValidateAndCollectStateKeys(this._agentOptions?.AIContextProviders, this.ChatHistoryProvider); this._logger = (loggerFactory ?? chatClient.GetService() ?? NullLoggerFactory.Instance).CreateLogger(); + + // Warn if using a custom chat client stack with end-of-run persistence but no ChatHistoryPersistingChatClient. + this.WarnOnMissingPersistingClient(); } /// @@ -211,12 +214,14 @@ public sealed partial class ChatClientAgent : AIAgent ChatClientAgentContinuationToken? _) = await this.PrepareSessionAndMessagesAsync(session, inputMessages, options, cancellationToken).ConfigureAwait(false); - var chatClient = this.ChatClient; + // Update the run context with the resolved session so any downstream classes + // always have a valid session, even when the caller passed null. + EnsureRunContextHasSession(safeSession); + var chatClient = this.ChatClient; chatClient = ApplyRunOptionsTransformations(options, chatClient); var loggingAgentName = this.GetLoggingAgentName(); - this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType); // Call the IChatClient and notify the AIContextProvider of any failures. @@ -227,8 +232,7 @@ public sealed partial class ChatClientAgent : AIAgent } catch (Exception ex) { - await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false); - await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, inputMessagesForChatClient, cancellationToken).ConfigureAwait(false); + await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false); throw; } @@ -236,7 +240,8 @@ public sealed partial class ChatClientAgent : AIAgent // We can derive the type of supported session from whether we have a conversation id, // so let's update it and set the conversation id for the service session case. - this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken); + var forceEndOfRunPersistence = chatOptions?.ContinuationToken is not null || chatOptions?.AllowBackgroundResponses is true; + this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence); // Ensure that the author name is set for each message in the response. foreach (ChatMessage chatResponseMessage in chatResponse.Messages) @@ -244,11 +249,10 @@ public sealed partial class ChatClientAgent : AIAgent chatResponseMessage.AuthorName ??= this.Name; } - // Only notify the session of new messages if the chatResponse was successful to avoid inconsistent message state in the session. - await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false); - - // Notify the AIContextProvider of all new messages. - await this.NotifyAIContextProviderOfSuccessAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, cancellationToken).ConfigureAwait(false); + // Notify providers of all new messages unless persistence is handled per-service-call by the decorator. + // When background responses are allowed, force notification since per-service-call persistence + // is unreliable (the caller may stop consuming the stream before the decorator can persist). + await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false); return new AgentResponse(chatResponse) { @@ -296,6 +300,10 @@ public sealed partial class ChatClientAgent : AIAgent ChatClientAgentContinuationToken? continuationToken) = await this.PrepareSessionAndMessagesAsync(session, inputMessages, options, cancellationToken).ConfigureAwait(false); + // Update the run context with the resolved session so any downstream classes + // always have a valid session, even when the caller passed null. + EnsureRunContextHasSession(safeSession); + var chatClient = this.ChatClient; chatClient = ApplyRunOptionsTransformations(options, chatClient); @@ -315,8 +323,7 @@ public sealed partial class ChatClientAgent : AIAgent } catch (Exception ex) { - await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false); - await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false); + await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false); throw; } @@ -330,8 +337,7 @@ public sealed partial class ChatClientAgent : AIAgent } catch (Exception ex) { - await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false); - await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false); + await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false); throw; } @@ -353,27 +359,31 @@ public sealed partial class ChatClientAgent : AIAgent try { + // Re-ensure the run context has the resolved session before each MoveNextAsync. + // The base class RunStreamingAsync restores the original context (potentially with + // null session) after each yield, so we must re-establish it for the decorator. + EnsureRunContextHasSession(safeSession); hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false); } catch (Exception ex) { - await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false); - await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), cancellationToken).ConfigureAwait(false); + await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false); throw; } } var chatResponse = responseUpdates.ToChatResponse(); + var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true; + // We can derive the type of supported session from whether we have a conversation id, // so let's update it and set the conversation id for the service session case. - this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken); + this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence); - // To avoid inconsistent state we only notify the session of the input messages if no error occurs after the initial request. - await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false); - - // Notify the AIContextProvider of all new messages. - await this.NotifyAIContextProviderOfSuccessAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, cancellationToken).ConfigureAwait(false); + // Notify providers of all new messages unless persistence is handled per-service-call by the decorator. + // When resuming from a continuation token or using background responses, force notification + // to send the combined data (per-service-call persistence is unreliable for these scenarios). + await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false); } /// @@ -441,17 +451,29 @@ public sealed partial class ChatClientAgent : AIAgent #region Private /// - /// Notify the when an agent run succeeded, if there is an . + /// Notifies the and all of successfully completed messages. /// - private async Task NotifyAIContextProviderOfSuccessAsync( + /// + /// This method is also called by to persist messages per-service-call. + /// + internal async Task NotifyProvidersOfNewMessagesAsync( ChatClientAgentSession session, - IEnumerable inputMessages, + IEnumerable requestMessages, IEnumerable responseMessages, + ChatOptions? chatOptions, CancellationToken cancellationToken) { + ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session); + + if (chatHistoryProvider is not null) + { + var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, responseMessages); + await chatHistoryProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false); + } + if (this.AIContextProviders is { Count: > 0 } contextProviders) { - AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, responseMessages); + AIContextProvider.InvokedContext invokedContext = new(this, session, requestMessages, responseMessages); foreach (var contextProvider in contextProviders) { @@ -461,17 +483,29 @@ public sealed partial class ChatClientAgent : AIAgent } /// - /// Notify the of any failure during an agent run, if there is an . + /// Notifies the and all of a failure during a service call. /// - private async Task NotifyAIContextProviderOfFailureAsync( + /// + /// This method is also called by to report failures per-service-call. + /// + internal async Task NotifyProvidersOfFailureAsync( ChatClientAgentSession session, Exception ex, - IEnumerable inputMessages, + IEnumerable requestMessages, + ChatOptions? chatOptions, CancellationToken cancellationToken) { + ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session); + + if (chatHistoryProvider is not null) + { + var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, ex); + await chatHistoryProvider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false); + } + if (this.AIContextProviders is { Count: > 0 } contextProviders) { - AIContextProvider.InvokedContext invokedContext = new(this, session, inputMessages, ex); + AIContextProvider.InvokedContext invokedContext = new(this, session, requestMessages, ex); foreach (var contextProvider in contextProviders) { @@ -667,6 +701,12 @@ public sealed partial class ChatClientAgent : AIAgent throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token."); } + if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.PersistsChatHistoryPerServiceCall && this._logger.IsEnabled(LogLevel.Warning)) + { + var warningAgentName = this.GetLoggingAgentName(); + this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName); + } + session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false); if (session is not ChatClientAgentSession typedSession) { @@ -748,13 +788,20 @@ public sealed partial class ChatClientAgent : AIAgent chatOptions.ConversationId = typedSession.ConversationId; } + // When per-service-call persistence is active, set a sentinel conversation ID so that + // FunctionInvokingChatClient treats locally-persisted history the same as service-managed + // history. This prevents it from adding duplicate FunctionCallContent messages into the + // request when processing approval responses — the loaded history already contains them. + // ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client. + chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions); + // Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible. List messagesList = inputMessagesForChatClient as List ?? inputMessagesForChatClient.ToList(); return (typedSession, chatOptions, messagesList, continuationToken); } - private void UpdateSessionConversationId(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken) + internal void UpdateSessionConversationId(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(responseConversationId) && !string.IsNullOrWhiteSpace(session.ConversationId)) { @@ -798,45 +845,182 @@ public sealed partial class ChatClientAgent : AIAgent } } - private Task NotifyChatHistoryProviderOfFailureAsync( + /// + /// Updates the session conversation ID at the end of an agent run. + /// + /// + /// When a in persist mode handles per-service-call + /// conversation ID updates, this end-of-run update is skipped. When the decorator is in mark-only + /// mode or absent, the update is performed here. When is + /// (continuation token scenarios), the update is always performed. + /// + private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false) + { + if (!forceUpdate && this.PersistsChatHistoryPerServiceCall) + { + return; + } + + this.UpdateSessionConversationId(session, responseConversationId, cancellationToken); + } + + /// + /// Notifies providers of successfully completed messages at the end of an agent run. + /// + /// + /// When a in persist mode handles per-service-call + /// notification, this end-of-run notification is skipped. When the decorator is in mark-only mode, + /// only the marked messages are persisted. When no decorator is present (custom stack with + /// ), all messages are persisted. + /// When is (continuation token or + /// background response scenarios), notification is always performed with all messages because + /// per-service-call persistence is unreliable in these scenarios. + /// + private Task NotifyProvidersOfNewMessagesAtEndOfRunAsync( + ChatClientAgentSession session, + IEnumerable requestMessages, + IEnumerable responseMessages, + ChatOptions? chatOptions, + CancellationToken cancellationToken, + bool forceNotify = false) + { + if (!forceNotify && this.PersistsChatHistoryPerServiceCall) + { + return Task.CompletedTask; + } + + if (!forceNotify && this.HasMarkOnlyChatHistoryPersistingClient) + { + // In mark-only mode, persist only messages that were marked by the decorator. + var markedRequestMessages = GetMarkedMessages(requestMessages); + var markedResponseMessages = GetMarkedMessages(responseMessages); + return this.NotifyProvidersOfNewMessagesAsync(session, markedRequestMessages, markedResponseMessages, chatOptions, cancellationToken); + } + + return this.NotifyProvidersOfNewMessagesAsync(session, requestMessages, responseMessages, chatOptions, cancellationToken); + } + + /// + /// Notifies providers of a failure at the end of an agent run. + /// + /// + /// When a in persist mode handles per-service-call + /// notification (including failure), this end-of-run notification is skipped to avoid + /// duplicate notification. In all other cases, failure is reported at the end of the run. + /// + private Task NotifyProvidersOfFailureAtEndOfRunAsync( ChatClientAgentSession session, Exception ex, IEnumerable requestMessages, ChatOptions? chatOptions, CancellationToken cancellationToken) { - ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session); - - // Only notify the provider if we have one. - // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. - if (provider is not null) + if (this.PersistsChatHistoryPerServiceCall) { - var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, ex); - - return provider.InvokedAsync(invokedContext, cancellationToken).AsTask(); + return Task.CompletedTask; } - return Task.CompletedTask; + return this.NotifyProvidersOfFailureAsync(session, ex, requestMessages, chatOptions, cancellationToken); } - private Task NotifyChatHistoryProviderOfNewMessagesAsync( - ChatClientAgentSession session, - IEnumerable requestMessages, - IEnumerable responseMessages, - ChatOptions? chatOptions, - CancellationToken cancellationToken) + /// + /// Gets a value indicating whether the agent has a + /// decorator in persist mode (not mark-only), which handles per-service-call persistence. + /// + private bool PersistsChatHistoryPerServiceCall { - ChatHistoryProvider? provider = this.ResolveChatHistoryProvider(chatOptions, session); - - // Only notify the provider if we have one. - // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. - if (provider is not null) + get { - var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, responseMessages); - return provider.InvokedAsync(invokedContext, cancellationToken).AsTask(); + var persistingClient = this.ChatClient.GetService(); + return persistingClient?.MarkOnly == false; + } + } + + /// + /// Sets the sentinel on + /// when per-service-call persistence is active and no real + /// conversation ID is present. + /// + /// + /// The (possibly new) with the sentinel set, or the original + /// if no sentinel is needed. + /// + private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions) + { + if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId)) + { + chatOptions ??= new ChatOptions(); + chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId; } - return Task.CompletedTask; + return chatOptions; + } + + /// + /// Gets a value indicating whether the agent has a + /// decorator in mark-only mode, which marks messages for later persistence at the end of the run. + /// + private bool HasMarkOnlyChatHistoryPersistingClient + { + get + { + var persistingClient = this.ChatClient.GetService(); + return persistingClient?.MarkOnly == true; + } + } + + /// + /// Returns only the messages that have been marked as persisted by a in mark-only mode. + /// + private static List GetMarkedMessages(IEnumerable messages) + { + return messages.Where(m => + m.AdditionalProperties?.TryGetValue(ChatHistoryPersistingChatClient.PersistedMarkerKey, out var value) == true && value is true).ToList(); + } + + /// + /// Ensures that contains the resolved session. + /// + /// + /// The base class sets with the raw session parameter + /// (which may be null) and restores it after each yield in streaming scenarios. After + /// resolves or creates a session, we update the + /// context so the decorator always has a valid session. + /// The original agent from the context is preserved to maintain the top-of-stack agent in + /// decorated agent scenarios. + /// + private static void EnsureRunContextHasSession(ChatClientAgentSession safeSession) + { + var context = CurrentRunContext; + if (context is not null && context.Session != safeSession) + { + CurrentRunContext = new(context.Agent, safeSession, context.RequestMessages, context.RunOptions); + } + } + + /// + /// Checks for potential misconfiguration when using a custom chat client stack and logs warnings. + /// + private void WarnOnMissingPersistingClient() + { + if (this._agentOptions?.UseProvidedChatClientAsIs is not true) + { + return; + } + + if (this._agentOptions?.PersistChatHistoryAtEndOfRun is not true) + { + return; + } + + var persistingClient = this.ChatClient.GetService(); + if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning)) + { + var loggingAgentName = this.GetLoggingAgentName(); + this._logger.LogAgentChatClientMissingPersistingClient( + this.Id, + loggingAgentName); + } } private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs index 98ff4583dc..2a324522a4 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs @@ -69,4 +69,32 @@ internal static partial class ChatClientAgentLogMessages string chatHistoryProviderName, string agentId, string agentName); + + /// + /// Logs a warning when is + /// and is , + /// but no is found in the custom chat client stack. + /// + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Agent {AgentId}/{AgentName}: PersistChatHistoryAtEndOfRun is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no ChatHistoryPersistingChatClient was found in the pipeline. All messages will be persisted at the end of the run without marking. This setup is not supported with some other features, e.g. handoffs. Consider adding a ChatHistoryPersistingChatClient to the pipeline using the UseChatHistoryPersisting extension method.")] + public static partial void LogAgentChatClientMissingPersistingClient( + this ILogger logger, + string agentId, + string agentName); + + /// + /// Logs a warning when per-service-call persistence falls back to end-of-run persistence + /// because the run involves background responses (continuation token resumption or + /// AllowBackgroundResponses). Per-service-call persistence is + /// unreliable in these scenarios because the caller may stop consuming the stream before + /// the decorator's post-stream persistence code can execute. + /// + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Agent {AgentId}/{AgentName}: Per-service-call persistence is falling back to end-of-run persistence because the run involves background responses. Messages will be marked during the run and persisted at the end.")] + public static partial void LogAgentChatClientBackgroundResponseFallback( + this ILogger logger, + string agentId, + string agentName); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index 38cad40bbe..8df9112446 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -89,6 +91,56 @@ public sealed class ChatClientAgentOptions /// public bool ThrowOnChatHistoryProviderConflict { get; set; } = true; + /// + /// Gets or sets a value indicating whether to persist chat history only at the end of the full agent run + /// rather than after each individual service call. + /// + /// + /// + /// By default, persists request and response messages either via + /// a , or the underlying AI service's chat history storage. + /// Persistence is done immediately after each call to the AI service within the function invocation loop. + /// When storing in the underlying AI service, the session's + /// is also updated after each service call, keeping it in sync with the service-side conversation state. + /// + /// + /// Setting this property to causes messages to be marked during the function + /// invocation loop but persisted only at the end of the full agent run, providing atomic run semantics. + /// Updating the is likewise deferred and + /// updated only at the end of the run, consistent with atomic run semantics. + /// A decorator is inserted into the chat client pipeline + /// in mark-only mode, and the persists only the marked messages at the + /// end of the run. + /// + /// + /// When this option is (the default), the + /// decorator persists messages and updates the + /// immediately after each service call. This may leave chat history in a state where + /// is required to start a new run if the last successful service + /// call returned . + /// + /// + /// This option has no effect when is . + /// When using a custom chat client stack, you can add a + /// manually via the + /// extension method. + /// + /// + /// Note that when using single threaded service stored chat history, like OpenAI Conversations, + /// there is only one id, so even if the conversation id is not updated after each service call, + /// the chat history will still contain intermediate messages. Setting this property to + /// in this case will therefore have no real effect. Setting this property to when using + /// OpenAI Responses with response ids on the other hand, allows atomic run semantics, since + /// each service request produces a new response id, and if the run fails mid-loop, the session will + /// still contain the pre-run respnose id, allowing the next run to start with a clean slate. + /// + /// + /// + /// Default is . + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public bool PersistChatHistoryAtEndOfRun { get; set; } + /// /// Creates a new instance of with the same values as this instance. /// @@ -105,5 +157,6 @@ public sealed class ChatClientAgentOptions ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict, WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict, ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict, + PersistChatHistoryAtEndOfRun = this.PersistChatHistoryAtEndOfRun, }; } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs index ee782dce52..a1e8b5f8a5 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs @@ -2,8 +2,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -82,4 +84,46 @@ public static class ChatClientBuilderExtensions options: options, loggerFactory: loggerFactory, services: services); + + /// + /// Adds a to the chat client pipeline. + /// + /// + /// + /// This decorator should be positioned between the and the leaf + /// in the pipeline. It intercepts service calls to either persist messages + /// immediately or mark them for later persistence, depending on the parameter. + /// + /// + /// If is set to , the + /// should be configured with set to + /// as without this combination, messages will never be persisted when using a for + /// chat history persistence. + /// + /// + /// This extension method is intended for use with custom chat client stacks when + /// is . + /// When is (the default), + /// the automatically injects this decorator. + /// + /// + /// This decorator only works within the context of a running and will throw an + /// exception if used in any other stack. + /// + /// + /// The to add the decorator to. + /// + /// When , messages are marked with metadata but not persisted immediately, + /// and the session's is not updated. + /// The will persist only the marked messages and update the + /// conversation ID at the end of the run. + /// When (the default), messages are persisted and the conversation ID + /// is updated immediately after each service call. + /// + /// The for chaining. + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public static ChatClientBuilder UseChatHistoryPersisting(this ChatClientBuilder builder, bool markOnly = false) + { + return builder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly)); + } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index 8290c39974..fffac628a6 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -63,6 +63,15 @@ public static class ChatClientExtensions }); } + // ChatHistoryPersistingChatClient is registered after FunctionInvokingChatClient so that it sits + // between FIC and the leaf client. ChatClientBuilder.Build applies factories in reverse order, + // making the first Use() call outermost. By adding our decorator second, the resulting pipeline is: + // FunctionInvokingChatClient → ChatHistoryPersistingChatClient → leaf IChatClient + // This allows the decorator to persist messages after each individual service call within + // FIC's function invocation loop, or to mark them for later persistence at the end of the run. + bool markOnly = options?.PersistChatHistoryAtEndOfRun is true; + chatBuilder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly)); + var agentChatClient = chatBuilder.Build(services); if (options?.ChatOptions?.Tools is { Count: > 0 }) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs new file mode 100644 index 0000000000..e733b778eb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs @@ -0,0 +1,351 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// A delegating chat client that notifies and +/// instances of request and response messages after each individual call to the inner chat client, +/// or marks messages for later persistence depending on the configured mode. +/// +/// +/// +/// This decorator is intended to operate between the and the leaf +/// in a pipeline. +/// +/// +/// In persist mode (the default), it ensures that providers are notified and the session's +/// is updated after each service call, so that +/// intermediate messages (e.g., tool calls and results) are saved even if the process is interrupted +/// mid-loop. +/// +/// +/// In mark-only mode ( is ), it marks messages with metadata +/// but does not notify providers or update the . +/// Both are deferred to the at the end of the run, providing atomic +/// run semantics. +/// +/// +/// This chat client must be used within the context of a running . It retrieves the +/// current agent and session from , which is set automatically when an agent's +/// or +/// +/// method is called. The ensures the run context always contains a resolved session, +/// even when the caller passes null. An is thrown if no run context is +/// available or if the agent is not a . +/// +/// +internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient +{ + /// + /// The key used in and + /// to mark messages and their content as already persisted to chat history. + /// + internal const string PersistedMarkerKey = "_chatHistoryPersisted"; + + /// + /// A sentinel value set on by + /// when per-service-call persistence is active and no real conversation ID exists. + /// + /// + /// + /// This signals to that the chat history is being managed + /// externally (by this decorator), which prevents it from adding duplicate + /// messages into the request during approval-response processing. Without this sentinel, + /// would reconstruct function-call messages from approval + /// responses and append them to the original messages — but the loaded history already contains + /// those same function calls, causing duplicate tool-call entries that the model rejects. + /// + /// + /// This decorator strips the sentinel before forwarding requests to the inner client, so the + /// underlying model never sees it. + /// + /// + internal const string LocalHistoryConversationId = "_agent_local_history"; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying chat client that will handle the core operations. + /// + /// When , messages are marked with metadata but not persisted immediately, + /// and the session's is not updated. + /// The will persist only the marked messages and update the + /// conversation ID at the end of the run. + /// When (the default), messages are persisted and the conversation ID + /// is updated immediately after each service call. + /// + public ChatHistoryPersistingChatClient(IChatClient innerClient, bool markOnly = false) + : base(innerClient) + { + this.MarkOnly = markOnly; + } + + /// + /// Gets a value indicating whether this decorator is in mark-only mode. + /// + /// + /// When , messages are marked with metadata but not persisted immediately, + /// and the session's is not updated. + /// Both are deferred to the at the end of the run. + /// When , messages are persisted and the conversation ID is updated + /// after each service call. + /// + public bool MarkOnly { get; } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); + + ChatResponse response; + try + { + response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + var newRequestMessagesOnFailure = GetNewRequestMessages(messages); + await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false); + throw; + } + + var newRequestMessages = GetNewRequestMessages(messages); + + if (this.ShouldDeferPersistence(options)) + { + // In mark-only mode or when resuming from a continuation token, just mark messages + // for later persistence by ChatClientAgent. Conversation ID and provider notification + // are deferred to end-of-run. For continuation tokens, the end-of-run handler needs + // to send the combined data from both the previous and current runs. + MarkAsPersisted(newRequestMessages); + MarkAsPersisted(response.Messages); + } + else + { + // In persist mode, persist immediately and update conversation ID. + agent.UpdateSessionConversationId(session, response.ConversationId, cancellationToken); + await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, response.Messages, options, cancellationToken).ConfigureAwait(false); + MarkAsPersisted(newRequestMessages); + MarkAsPersisted(response.Messages); + } + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); + + List responseUpdates = []; + + IAsyncEnumerator enumerator; + try + { + enumerator = base.GetStreamingResponseAsync(messages, options, cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (Exception ex) + { + var newRequestMessagesOnFailure = GetNewRequestMessages(messages); + await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false); + throw; + } + + bool hasUpdates; + try + { + hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + var newRequestMessagesOnFailure = GetNewRequestMessages(messages); + await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false); + throw; + } + + while (hasUpdates) + { + var update = enumerator.Current; + responseUpdates.Add(update); + yield return update; + + try + { + hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + var newRequestMessagesOnFailure = GetNewRequestMessages(messages); + await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false); + throw; + } + } + + var chatResponse = responseUpdates.ToChatResponse(); + var newRequestMessages = GetNewRequestMessages(messages); + + if (this.ShouldDeferPersistence(options)) + { + // In mark-only mode or when resuming from a continuation token, just mark messages + // for later persistence by ChatClientAgent. Conversation ID and provider notification + // are deferred to end-of-run. For continuation tokens, the end-of-run handler needs + // to send the combined data from both the previous and current runs. + MarkAsPersisted(newRequestMessages); + MarkAsPersisted(chatResponse.Messages); + } + else + { + // In persist mode, persist immediately and update conversation ID. + agent.UpdateSessionConversationId(session, chatResponse.ConversationId, cancellationToken); + await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, chatResponse.Messages, options, cancellationToken).ConfigureAwait(false); + MarkAsPersisted(newRequestMessages); + MarkAsPersisted(chatResponse.Messages); + } + } + + /// + /// Gets the current and from the run context. + /// + private static (ChatClientAgent Agent, ChatClientAgentSession Session) GetRequiredAgentAndSession() + { + var runContext = AIAgent.CurrentRunContext + ?? throw new InvalidOperationException( + $"{nameof(ChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " + + "Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call."); + + var chatClientAgent = runContext.Agent.GetService() + ?? throw new InvalidOperationException( + $"{nameof(ChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " + + $"The current agent is of type '{runContext.Agent.GetType().Name}'."); + + if (runContext.Session is not ChatClientAgentSession chatClientAgentSession) + { + throw new InvalidOperationException( + $"{nameof(ChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " + + $"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'."); + } + + return (chatClientAgent, chatClientAgentSession); + } + + /// + /// Determines whether persistence should be deferred to end-of-run instead of happening immediately. + /// + /// + /// when in mode, when the call is resuming from + /// a continuation token (since the end-of-run handler needs to combine data from the previous + /// and current runs), or when background responses are allowed (since the caller may stop + /// consuming the stream mid-run, preventing the post-stream persistence code from executing). + /// + private bool ShouldDeferPersistence(ChatOptions? options) + { + return this.MarkOnly || options?.ContinuationToken is not null || options?.AllowBackgroundResponses is true; + } + + /// + /// Returns only the request messages that have not yet been persisted to chat history. + /// + /// + /// A message is considered already persisted if any of the following is true: + /// + /// It has the in its . + /// It has an of + /// (indicating it was loaded from chat history and does not need to be re-persisted). + /// It has and all of its items have the + /// in their . This handles the + /// streaming case where reconstructs objects + /// independently via ToChatResponse(), producing different object references that share the same + /// underlying instances. + /// + /// + /// A list of request messages that have not yet been persisted. + /// The full set of request messages to filter. + private static List GetNewRequestMessages(IEnumerable messages) + { + return messages.Where(m => !IsAlreadyPersisted(m)).ToList(); + } + + /// + /// Determines whether a message has already been persisted to chat history by this decorator. + /// + private static bool IsAlreadyPersisted(ChatMessage message) + { + if (message.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true) + { + return true; + } + + if (message.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.ChatHistory) + { + return true; + } + + // In streaming mode, FunctionInvokingChatClient reconstructs ChatMessage objects via ToChatResponse() + // independently, producing different ChatMessage instances. However, the underlying AIContent objects + // (e.g., FunctionCallContent, FunctionResultContent) are shared references. Checking for markers on + // AIContent handles dedup in this case. + if (message.Contents.Count > 0 && message.Contents.All(c => c.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true)) + { + return true; + } + + return false; + } + + /// + /// Marks the given messages as persisted by setting a marker on both the + /// and each of its items. + /// + /// + /// Both levels are marked because may reconstruct + /// objects in streaming mode (losing the message-level marker), + /// but the references are shared and retain their markers. + /// + /// The messages to mark as persisted. + private static void MarkAsPersisted(IEnumerable messages) + { + foreach (var message in messages) + { + message.AdditionalProperties ??= new(); + message.AdditionalProperties[PersistedMarkerKey] = true; + + foreach (var content in message.Contents) + { + content.AdditionalProperties ??= new(); + content.AdditionalProperties[PersistedMarkerKey] = true; + } + } + } + + /// + /// If the carry the sentinel, + /// returns a clone with the conversation ID cleared so the inner client never sees it. + /// Otherwise returns the original unchanged. + /// + private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options) + { + if (options?.ConversationId == LocalHistoryConversationId) + { + options = options.Clone(); + options.ConversationId = null; + } + + return options; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatStrategyExtensions.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatStrategyExtensions.cs new file mode 100644 index 0000000000..b7f224d751 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatStrategyExtensions.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Provides extension methods for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class ChatStrategyExtensions +{ + /// + /// Returns an that applies this to reduce a list of messages. + /// + /// The compaction strategy to wrap as an . + /// + /// An that, on each call to , builds a + /// from the supplied messages and applies the strategy's compaction logic, + /// returning the resulting included messages. + /// + /// + /// This allows any to be used wherever an is expected, + /// bridging the compaction pipeline into systems bound to the Microsoft.Extensions.AI contract. + /// + public static IChatReducer AsChatReducer(this CompactionStrategy strategy) + { + Throw.IfNull(strategy); + + return new CompactionStrategyChatReducer(strategy); + } + + /// + /// An adapter that delegates to a . + /// + private sealed class CompactionStrategyChatReducer : IChatReducer + { + private readonly CompactionStrategy _strategy; + + public CompactionStrategyChatReducer(CompactionStrategy strategy) + { + this._strategy = strategy; + } + + /// + public async Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + CompactionMessageIndex index = CompactionMessageIndex.Create([.. messages]); + await this._strategy.CompactAsync(index, cancellationToken: cancellationToken).ConfigureAwait(false); + return index.GetIncludedMessages(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index 9b4dbb6b16..6bb2ed26f6 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -30,6 +31,12 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// +/// A custom can be supplied to override the default YAML-like +/// summary format. The formatter receives the being collapsed +/// and must return the replacement summary string. is the +/// built-in default and can be reused inside a custom formatter when needed. +/// +/// /// is a hard floor: even if the /// has not been reached, compaction will not touch the last non-system groups. /// @@ -62,7 +69,10 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy /// An optional target condition that controls when compaction stops. When , /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. /// - public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null) + public ToolResultCompactionStrategy( + CompactionTrigger trigger, + int minimumPreservedGroups = DefaultMinimumPreserved, + CompactionTrigger? target = null) : base(trigger, target) { this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups); @@ -74,6 +84,13 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy /// public int MinimumPreservedGroups { get; } + /// + /// An optional custom formatter that converts a into a summary string. + /// When , is used, which produces a YAML-like + /// block listing each tool name and its results. + /// + public Func? ToolCallFormatter { get; init; } + /// protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { @@ -120,7 +137,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy int idx = eligibleIndices[e] + offset; CompactionMessageGroup group = index.Groups[idx]; - string summary = BuildToolCallSummary(group); + string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group); // Exclude the original group and insert a collapsed replacement group.IsExcluded = true; @@ -145,14 +162,18 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy } /// - /// Builds a concise summary string for a tool call group, including tool names, + /// The default formatter that produces a YAML-like summary of tool call groups, including tool names, /// results, and deduplication counts for repeated tool names. /// - private static string BuildToolCallSummary(CompactionMessageGroup group) + /// + /// This is the formatter used when no custom is supplied. + /// It can be referenced directly in a custom formatter to augment or wrap the default output. + /// + public static string DefaultToolCallFormatter(CompactionMessageGroup group) { // Collect function calls (callId, name) and results (callId → result text) List<(string CallId, string Name)> functionCalls = []; - Dictionary resultsByCallId = new(); + Dictionary resultsByCallId = []; List plainTextResults = []; foreach (ChatMessage message in group.Messages) @@ -187,7 +208,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy // grouping by tool name while preserving first-seen order. int plainTextIdx = 0; List orderedNames = []; - Dictionary> groupedResults = new(); + Dictionary> groupedResults = []; foreach ((string callId, string name) in functionCalls) { diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index 6881f7303f..2bc8408a26 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -7,6 +7,7 @@ using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Extensions.VectorData; using Microsoft.Shared.Diagnostics; @@ -80,7 +81,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo private readonly VectorStoreCollection> _collection; private readonly int _maxResults; private readonly string _contextPrompt; - private readonly bool _enableSensitiveTelemetryData; + private readonly Redactor _redactor; private readonly ChatHistoryMemoryProviderOptions.SearchBehavior _searchTime; private readonly string _toolName; private readonly string _toolDescription; @@ -118,7 +119,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo options ??= new ChatHistoryMemoryProviderOptions(); this._maxResults = options.MaxResults.HasValue ? Throw.IfLessThanOrEqual(options.MaxResults.Value, 0) : DefaultMaxResults; this._contextPrompt = options.ContextPrompt ?? DefaultContextPrompt; - this._enableSensitiveTelemetryData = options.EnableSensitiveTelemetryData; + this._redactor = options.EnableSensitiveTelemetryData ? NullRedactor.Instance : (options.Redactor ?? new ReplacingRedactor("")); this._searchTime = options.SearchTime; this._logger = loggerFactory?.CreateLogger(); this._toolName = options.FunctionToolName ?? DefaultFunctionToolName; @@ -485,7 +486,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo GC.SuppressFinalize(this); } - private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + private string SanitizeLogData(string? data) => this._redactor.Redact(data); /// /// Rebinds a filter expression's body to use the specified shared parameter, diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs index a9c5b93928..db1731a54d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI; @@ -46,8 +47,22 @@ public sealed class ChatHistoryMemoryProviderOptions /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. /// /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// public bool EnableSensitiveTelemetryData { get; set; } + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Gets or sets the key used to store provider state in the . /// diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 93b228d29e..70da404a61 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -8,6 +8,7 @@ true true + true true true true @@ -22,6 +23,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs new file mode 100644 index 0000000000..5f0a66808d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for all agent skills. +/// +/// +/// +/// A skill represents a domain-specific capability with instructions, resources, and scripts. +/// Concrete implementations include (filesystem-backed). +/// +/// +/// Skill metadata follows the Agent Skills specification. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkill +{ + /// + /// Gets the frontmatter metadata for this skill. + /// + /// + /// Contains the L1 discovery metadata (name, description, license, compatibility, etc.) + /// as defined by the Agent Skills specification. + /// + public abstract AgentSkillFrontmatter Frontmatter { get; } + + /// + /// Gets the full skill content. + /// + /// + /// For file-based skills this is the raw SKILL.md file content. + /// + public abstract string Content { get; } + + /// + /// Gets the resources associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific resources. + /// + public virtual IReadOnlyList? Resources => null; + + /// + /// Gets the scripts associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific scripts. + /// + public virtual IReadOnlyList? Scripts => null; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs new file mode 100644 index 0000000000..df087ff2bb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the YAML frontmatter metadata parsed from a SKILL.md file. +/// +/// +/// +/// Frontmatter is the L1 (discovery) layer of the +/// Agent Skills specification. +/// It contains the minimal metadata needed to advertise a skill in the system prompt +/// without loading the full skill content. +/// +/// +/// The constructor validates the name and description against specification rules +/// and throws if either value is invalid. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillFrontmatter +{ + /// + /// Maximum allowed length for the skill name. + /// + internal const int MaxNameLength = 64; + + /// + /// Maximum allowed length for the skill description. + /// + internal const int MaxDescriptionLength = 1024; + + /// + /// Maximum allowed length for the compatibility field. + /// + internal const int MaxCompatibilityLength = 500; + + // Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter): + // lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens. + private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + + private string? _compatibility; + + /// + /// Initializes a new instance of the class. + /// + /// Skill name in kebab-case. + /// Skill description for discovery. + /// Optional compatibility information (max 500 chars). + /// + /// Thrown when , , or violates the + /// Agent Skills specification rules. + /// + public AgentSkillFrontmatter(string name, string description, string? compatibility = null) + { + if (!ValidateName(name, out string? reason) || + !ValidateDescription(description, out reason) || + !ValidateCompatibility(compatibility, out reason)) + { + throw new ArgumentException(reason); + } + + this.Name = name; + this.Description = description; + this._compatibility = compatibility; + } + + /// + /// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens. + /// + public string Name { get; } + + /// + /// Gets the skill description. Used for discovery in the system prompt. + /// + public string Description { get; } + + /// + /// Gets or sets an optional license name or reference. + /// + public string? License { get; set; } + + /// + /// Gets or sets optional compatibility information (max 500 chars). + /// + /// + /// Thrown when the value exceeds characters. + /// + public string? Compatibility + { + get => this._compatibility; + set + { + if (!ValidateCompatibility(value, out string? reason)) + { + throw new ArgumentException(reason); + } + + this._compatibility = value; + } + } + + /// + /// Gets or sets optional space-delimited list of pre-approved tools. + /// + public string? AllowedTools { get; set; } + + /// + /// Gets or sets the arbitrary key-value metadata for this skill. + /// + public AdditionalPropertiesDictionary? Metadata { get; set; } + + /// + /// Validates a skill name against specification rules. + /// + /// The skill name to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the name is valid; otherwise, . + public static bool ValidateName( + string? name, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(name)) + { + reason = "Skill name is required."; + return false; + } + + if (name.Length > MaxNameLength) + { + reason = $"Skill name must be {MaxNameLength} characters or fewer."; + return false; + } + + if (!s_validNameRegex.IsMatch(name)) + { + reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates a skill description against specification rules. + /// + /// The skill description to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the description is valid; otherwise, . + public static bool ValidateDescription( + string? description, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(description)) + { + reason = "Skill description is required."; + return false; + } + + if (description.Length > MaxDescriptionLength) + { + reason = $"Skill description must be {MaxDescriptionLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates an optional skill compatibility value against specification rules. + /// + /// The optional compatibility value to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the value is valid; otherwise, . + public static bool ValidateCompatibility( + string? compatibility, + [NotNullWhen(false)] out string? reason) + { + if (compatibility?.Length > MaxCompatibilityLength) + { + reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs new file mode 100644 index 0000000000..b3cfc3f117 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (e.g., relative path or identifier). + /// An optional description of the resource. + protected AgentSkillResource(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the resource name. + /// + public string Name { get; } + + /// + /// Gets the optional resource description. + /// + public string? Description { get; } + + /// + /// Reads the resource content asynchronously. + /// + /// Optional service provider for dependency injection. + /// Cancellation token. + /// The resource content. + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs new file mode 100644 index 0000000000..ad647d2eb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill scripts. A script represents an executable action associated with a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillScript +{ + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// An optional description of the script. + protected AgentSkillScript(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the script name. + /// + public string Name { get; } + + /// + /// Gets the optional script description. + /// + public string? Description { get; } + + /// + /// Runs the script with the given arguments. + /// + /// The skill that owns this script. + /// Arguments for script execution. + /// Cancellation token. + /// The script execution result. + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs new file mode 100644 index 0000000000..f2f87851c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -0,0 +1,383 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that exposes agent skills from one or more instances. +/// +/// +/// +/// This provider implements the progressive disclosure pattern from the +/// Agent Skills specification: +/// +/// +/// Advertise — skill names and descriptions are injected into the system prompt. +/// Load — the full skill body is returned via the load_skill tool. +/// Read resources — supplementary content is read on demand via the read_skill_resource tool. +/// Run scripts — scripts are executed via the run_skill_script tool (when scripts exist). +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class AgentSkillsProvider : AIContextProvider +{ + /// + /// Placeholder token for the generated skills list in the prompt template. + /// + private const string SkillsPlaceholder = "{skills}"; + + /// + /// Placeholder token for the script instructions in the prompt template. + /// + private const string ScriptInstructionsPlaceholder = "{script_instructions}"; + + /// + /// Placeholder token for the resource instructions in the prompt template. + /// + private const string ResourceInstructionsPlaceholder = "{resource_instructions}"; + + private const string DefaultSkillsInstructionPrompt = + """ + You have access to skills containing domain-specific knowledge and capabilities. + Each skill provides specialized instructions, reference documents, and assets for specific tasks. + + + {skills} + + + When a task aligns with a skill's domain, follow these steps in exact order: + - Use `load_skill` to retrieve the skill's instructions. + - Follow the provided guidance. + {resource_instructions} + {script_instructions} + Only load what is needed, when it is needed. + """; + + private readonly AgentSkillsSource _source; + private readonly AgentSkillsProviderOptions? _options; + private readonly ILogger _logger; + private Task? _contextTask; + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from a single directory. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Path to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from multiple directories. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Paths to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + new DeduplicatingAgentSkillsSource( + new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory), + loggerFactory), + options, + loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// from a custom . Unlike other constructors, this one does not + /// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline. + /// + /// The skill source providing skills. + /// Optional configuration. + /// Optional logger factory. + public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + { + this._source = Throw.IfNull(source); + this._options = options; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + if (options?.SkillsInstructionPrompt is string prompt) + { + ValidatePromptTemplate(prompt, nameof(options)); + } + } + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._options?.DisableCaching == true) + { + return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async Task CreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + if (skills is not { Count: > 0 }) + { + return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 }); + bool hasResources = skills.Any(s => s.Resources is { Count: > 0 }); + + return new AIContext + { + Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources), + Tools = this.BuildTools(skills, hasScripts, hasResources), + }; + } + + private async Task GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing) + { + return await existing.ConfigureAwait(false); + } + + try + { + var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + tcs.SetResult(result); + return result; + } + catch (Exception ex) + { + this._contextTask = null; + tcs.TrySetException(ex); + throw; + } + } + + private IList BuildTools(IList skills, bool hasScripts, bool hasResources) + { + IList tools = + [ + AIFunctionFactory.Create( + (string skillName) => this.LoadSkill(skills, skillName), + name: "load_skill", + description: "Loads the full content of a specific skill"), + ]; + + if (hasResources) + { + tools.Add(AIFunctionFactory.Create( + (string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) => + this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken), + name: "read_skill_resource", + description: "Reads a resource associated with a skill, such as references, assets, or dynamic data.")); + } + + if (!hasScripts) + { + return tools; + } + + AIFunction scriptFunction = AIFunctionFactory.Create( + (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), + name: "run_skill_script", + description: "Runs a script associated with a skill."); + + if (this._options?.ScriptApproval == true) + { + return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)]; + } + + return [.. tools, scriptFunction]; + } + + private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions, bool includeResourceInstructions) + { + string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; + + var sb = new StringBuilder(); + foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + { + sb.AppendLine(" "); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); + sb.AppendLine(" "); + } + + string resourceInstruction = includeResourceInstructions + ? """ + - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed + (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`). + """ + : string.Empty; + + string scriptInstruction = includeScriptInstructions + ? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed." + : string.Empty; + + return new StringBuilder(promptTemplate) + .Replace(SkillsPlaceholder, sb.ToString().TrimEnd()) + .Replace(ResourceInstructionsPlaceholder, resourceInstruction) + .Replace(ScriptInstructionsPlaceholder, scriptInstruction) + .ToString(); + } + + private string LoadSkill(IList skills, string skillName) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + LogSkillLoading(this._logger, skillName); + + return skill.Content; + } + + private async Task ReadSkillResourceAsync(IList skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(resourceName)) + { + return "Error: Resource name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName); + if (resource is null) + { + return $"Error: Resource '{resourceName}' not found in skill '{skillName}'."; + } + + try + { + return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogResourceReadError(this._logger, skillName, resourceName, ex); + return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; + } + } + + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(scriptName)) + { + return "Error: Script name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName); + if (script is null) + { + return $"Error: Script '{scriptName}' not found in skill '{skillName}'."; + } + + try + { + return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogScriptExecutionError(this._logger, skillName, scriptName, ex); + return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'."; + } + } + + /// + /// Validates that a custom prompt template contains the required placeholder tokens. + /// + private static void ValidatePromptTemplate(string template, string paramName) + { + if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.", + paramName); + } + + if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.", + paramName); + } + + if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.", + paramName); + } + } + + [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] + private static partial void LogSkillLoading(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] + private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); + + [LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")] + private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs new file mode 100644 index 0000000000..17d7f2d6f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Fluent builder for constructing an backed by a composite source. +/// +/// +/// +/// var provider = new AgentSkillsProviderBuilder() +/// .UseFileSkills("/path/to/skills") +/// .Build(); +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderBuilder +{ + private readonly List> _sourceFactories = []; + private AgentSkillsProviderOptions? _options; + private ILoggerFactory? _loggerFactory; + private AgentFileSkillScriptRunner? _scriptRunner; + private Func? _filter; + + /// + /// Adds a file-based skill source that discovers skills from a filesystem directory. + /// + /// Path to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + return this.UseFileSkills([skillPath], options, scriptRunner); + } + + /// + /// Adds a file-based skill source that discovers skills from multiple filesystem directories. + /// + /// Paths to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + this._sourceFactories.Add((builderScriptRunner, loggerFactory) => + { + var resolvedRunner = scriptRunner + ?? builderScriptRunner + ?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}."); + return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory); + }); + return this; + } + + /// + /// Adds a custom skill source. + /// + /// The custom skill source. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source) + { + _ = Throw.IfNull(source); + this._sourceFactories.Add((_, _) => source); + return this; + } + + /// + /// Sets a custom system prompt template. + /// + /// The prompt template with {skills} placeholder for the skills list, + /// {resource_instructions} for optional resource instructions, + /// and {script_instructions} for optional script instructions. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) + { + this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate; + return this; + } + + /// + /// Enables or disables the script approval gate. + /// + /// Whether script execution requires approval. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true) + { + this.GetOrCreateOptions().ScriptApproval = enabled; + return this; + } + + /// + /// Sets the runner for file-based skill scripts. + /// + /// The delegate that runs file-based scripts. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner) + { + this._scriptRunner = Throw.IfNull(runner); + return this; + } + + /// + /// Sets the logger factory. + /// + /// The logger factory. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) + { + this._loggerFactory = loggerFactory; + return this; + } + + /// + /// Sets a filter predicate that controls which skills are included. + /// + /// + /// Skills for which the predicate returns are kept; + /// others are excluded. Only one filter is supported; calling this method + /// again replaces any previously set filter. + /// + /// A predicate that determines which skills to include. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFilter(Func predicate) + { + _ = Throw.IfNull(predicate); + this._filter = predicate; + return this; + } + + /// + /// Configures the using the provided delegate. + /// + /// A delegate to configure the options. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseOptions(Action configure) + { + _ = Throw.IfNull(configure); + configure(this.GetOrCreateOptions()); + return this; + } + + /// + /// Builds the . + /// + /// A configured . + public AgentSkillsProvider Build() + { + var resolvedSources = new List(this._sourceFactories.Count); + foreach (var factory in this._sourceFactories) + { + resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory)); + } + + AgentSkillsSource source; + if (resolvedSources.Count == 1) + { + source = resolvedSources[0]; + } + else + { + source = new AggregatingAgentSkillsSource(resolvedSources); + } + + // Apply user-specified filter, then dedup. + if (this._filter != null) + { + source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory); + } + + source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory); + + return new AgentSkillsProvider(source, this._options, this._loggerFactory); + } + + private AgentSkillsProviderOptions GetOrCreateOptions() + { + return this._options ??= new AgentSkillsProviderOptions(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs new file mode 100644 index 0000000000..2f89ebfda6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderOptions +{ + /// + /// Gets or sets a custom system prompt template for advertising skills. + /// The template must contain {skills} as the placeholder for the generated skills list, + /// {resource_instructions} for resource instructions, + /// and {script_instructions} for script instructions. + /// When , a default template is used. + /// + public string? SkillsInstructionPrompt { get; set; } + + /// + /// Gets or sets a value indicating whether script execution requires approval. + /// When , script execution is blocked until approved. + /// Defaults to . + /// + public bool ScriptApproval { get; set; } + + /// + /// Gets or sets a value indicating whether caching of tools and instructions is disabled. + /// When (the default), the provider caches the tools and instructions + /// after the first build and returns the cached instance on subsequent calls. + /// Set to to rebuild tools and instructions on every invocation. + /// + public bool DisableCaching { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs new file mode 100644 index 0000000000..6a72d0c01a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill sources. A skill source provides skills from a specific origin +/// (filesystem, remote server, database, in-memory, etc.). +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillsSource +{ + /// + /// Gets the skills provided by this source. + /// + /// Cancellation token. + /// A collection of skills from this source. + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs new file mode 100644 index 0000000000..7dc468742f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source that aggregates multiple child sources, preserving their registration order. +/// +/// +/// Skills from each child source are returned in the order the sources were registered, +/// with each source's skills appended sequentially. No deduplication or filtering is applied. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource +{ + private readonly IEnumerable _sources; + + /// + /// Initializes a new instance of the class. + /// + /// The child sources to aggregate. + public AggregatingAgentSkillsSource(IEnumerable sources) + { + this._sources = Throw.IfNull(sources); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = new List(); + foreach (var source in this._sources) + { + var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + allSkills.AddRange(skills); + } + + return allSkills; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs new file mode 100644 index 0000000000..bf943daae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source to deduplicate. + /// Optional logger factory. + public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var deduplicated = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in allSkills) + { + if (seen.Add(skill.Frontmatter.Name)) + { + deduplicated.Add(skill); + } + else + { + LogDuplicateSkillName(this._logger, skill.Frontmatter.Name); + } + } + + return deduplicated; + } + + [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")] + private static partial void LogDuplicateSkillName(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs new file mode 100644 index 0000000000..920ad0428b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an abstract base class for skill sources that delegate operations to an inner source +/// while allowing for extensibility and customization. +/// +/// +/// implements the decorator pattern for , +/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication, +/// filtering, etc.) while delegating core operations to an underlying source. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource +{ + /// + /// Initializes a new instance of the class with the specified inner source. + /// + /// The underlying skill source that will handle the core operations. + protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource) + { + this.InnerSource = Throw.IfNull(innerSource); + } + + /// + /// Gets the inner skill source that receives delegated operations. + /// + protected AgentSkillsSource InnerSource { get; } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + => this.InnerSource.GetSkillsAsync(cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs new file mode 100644 index 0000000000..2bd26acce2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that filters skills using a caller-supplied predicate. +/// +/// +/// Skills for which the predicate returns are included in the result; +/// skills for which it returns are excluded and logged at debug level. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source whose skills will be filtered. + /// + /// A predicate that determines which skills to include. Skills for which the predicate + /// returns are kept; others are excluded. + /// + /// Optional logger factory. + public FilteringAgentSkillsSource( + AgentSkillsSource innerSource, + Func predicate, + ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._predicate = Throw.IfNull(predicate); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var filtered = new List(); + foreach (var skill in allSkills) + { + if (this._predicate(skill)) + { + filtered.Add(skill); + } + else + { + LogSkillFiltered(this._logger, skill.Frontmatter.Name); + } + } + + return filtered; + } + + [LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")] + private static partial void LogSkillFiltered(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs new file mode 100644 index 0000000000..4bb62e99a8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An discovered from a filesystem directory backed by a SKILL.md file. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkill : AgentSkill +{ + private readonly IReadOnlyList _resources; + private readonly IReadOnlyList _scripts; + + /// + /// Initializes a new instance of the class. + /// + /// The parsed frontmatter metadata for this skill. + /// The full raw SKILL.md file content including YAML frontmatter. + /// Absolute path to the directory containing this skill. + /// Resources discovered for this skill. + /// Scripts discovered for this skill. + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, + string content, + string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this.Content = Throw.IfNull(content); + this.Path = Throw.IfNullOrWhitespace(path); + this._resources = resources ?? []; + this._scripts = scripts ?? []; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the directory path where the skill was discovered. + /// + public string Path { get; } + + /// + public override IReadOnlyList Resources => this._resources; + + /// + public override IReadOnlyList Scripts => this._scripts; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs new file mode 100644 index 0000000000..9ba5b7e24a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory. +/// +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (relative path within the skill directory). + /// The absolute file path to the resource. + public AgentFileSkillResource(string name, string fullPath) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + } + + /// + /// Gets the absolute file path to the resource. + /// + public string FullPath { get; } + + /// + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { +#if NET8_0_OR_GREATER + return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + using var reader = new StreamReader(this.FullPath, Encoding.UTF8); + return await reader.ReadToEndAsync().ConfigureAwait(false); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs new file mode 100644 index 0000000000..116847126f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptRunner? _runner; + + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// The absolute file path to the script. + /// Optional external runner for running the script. An is thrown from if no runner is provided. + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + this._runner = runner; + } + + /// + /// Gets the absolute file path to the script. + /// + public string FullPath { get; } + + /// + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + if (skill is not AgentFileSkill fileSkill) + { + throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'."); + } + + if (this._runner is null) + { + throw new InvalidOperationException( + $"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " + + $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); + } + + return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs new file mode 100644 index 0000000000..c19d19e056 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Delegate for running file-based skill scripts. +/// +/// +/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// +/// The skill that owns the script. +/// The file-based script to run. +/// Optional arguments for the script, provided by the agent/LLM. +/// Cancellation token. +/// The script execution result. +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs similarity index 51% rename from dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs rename to dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 18fa87999a..c6dc3bc629 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -2,151 +2,135 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// -/// Discovers, parses, and validates SKILL.md files from filesystem directories. +/// A skill source that discovers skills from filesystem directories containing SKILL.md files. /// /// -/// Searches directories recursively (up to levels) for SKILL.md files. -/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill +/// Searches directories recursively (up to 2 levels deep) for SKILL.md files. +/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill /// directory for files with matching extensions. Invalid resources are skipped with logged warnings. -/// Resource paths are checked against path traversal and symlink escape attacks. +/// Resource and script paths are checked against path traversal and symlink escape attacks. /// -internal sealed partial class FileAgentSkillLoader +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - private const int MaxNameLength = 64; - private const int MaxDescriptionLength = 1024; + + private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; + private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. - // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. + // Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools), + // Group 2 = quoted value, Group 3 = unquoted value. // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. - // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _), - // "description: \"A skill\"" → (description, A skill, _) - private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Validates skill names: lowercase letters, numbers, and hyphens only; - // must not start or end with a hyphen; must not contain consecutive hyphens. - // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗, "my--skill" ✗ - private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + // Matches a "metadata:" line followed by indented sub-key/value pairs. + // Group 1 captures the entire indented block beneath the metadata key. + private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - private readonly ILogger _logger; + // Matches indented YAML "key: value" lines within a metadata block. + // Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value. + private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; + private readonly HashSet _allowedScriptExtensions; + private readonly AgentFileSkillScriptRunner? _scriptRunner; + private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The logger instance. - /// File extensions to recognize as skill resources. When , defaults are used. - internal FileAgentSkillLoader(ILogger logger, IEnumerable? allowedResourceExtensions = null) + /// Path to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([skillPath], scriptRunner, options, loggerFactory) { - this._logger = logger; - - ValidateExtensions(allowedResourceExtensions); - - this._allowedResourceExtensions = new HashSet( - allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"], - StringComparer.OrdinalIgnoreCase); } /// - /// Discovers skill directories and loads valid skills from them. + /// Initializes a new instance of the class. /// - /// Paths to search for skills. Each path can point to an individual skill folder or a parent folder. - /// A dictionary of loaded skills keyed by skill name. - internal Dictionary DiscoverAndLoadSkills(IEnumerable skillPaths) + /// Paths to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { - var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + this._skillPaths = Throw.IfNull(skillPaths); - var discoveredPaths = DiscoverSkillDirectories(skillPaths); + var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); + + ValidateExtensions(resolvedOptions.AllowedResourceExtensions); + ValidateExtensions(resolvedOptions.AllowedScriptExtensions); + + this._allowedResourceExtensions = new HashSet( + resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions, + StringComparer.OrdinalIgnoreCase); + + this._allowedScriptExtensions = new HashSet( + resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, + StringComparer.OrdinalIgnoreCase); + + this._scriptRunner = scriptRunner; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var discoveredPaths = DiscoverSkillDirectories(this._skillPaths); LogSkillsDiscovered(this._logger, discoveredPaths.Count); + var skills = new List(); + foreach (string skillPath in discoveredPaths) { - FileAgentSkill? skill = this.ParseSkillFile(skillPath); + AgentFileSkill? skill = this.ParseSkillDirectory(skillPath); if (skill is null) { continue; } - if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing)) - { - LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath); - - // Skip duplicate skill names, keeping the first one found. - continue; - } - - skills[skill.Frontmatter.Name] = skill; + skills.Add(skill); LogSkillLoaded(this._logger, skill.Frontmatter.Name); } LogSkillsLoadedTotal(this._logger, skills.Count); - return skills; - } - - /// - /// Reads a resource file from disk with path traversal and symlink guards. - /// - /// The skill that owns the resource. - /// Relative path of the resource within the skill directory. - /// Cancellation token. - /// The UTF-8 text content of the resource file. - /// - /// The resource is not registered, resolves outside the skill directory, or does not exist. - /// - internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) - { - resourceName = NormalizeResourcePath(resourceName); - - if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase))) - { - throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName)); - string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar; - - if (!IsPathWithinDirectory(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory."); - } - - if (!File.Exists(fullPath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - if (HasSymlinkInPath(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory."); - } - - LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name); - -#if NET - return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); -#else - return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false); -#endif + return Task.FromResult(skills as IList); } private static List DiscoverSkillDirectories(IEnumerable skillPaths) @@ -185,30 +169,30 @@ internal sealed partial class FileAgentSkillLoader } } - private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath) + private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath) { string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName); - string content = File.ReadAllText(skillFilePath, Encoding.UTF8); - if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter)) { return null; } - List resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name); - return new FileAgentSkill( + return new AgentFileSkill( frontmatter: frontmatter, - body: body, - sourcePath: skillDirectoryFullPath, - resourceNames: resourceNames); + content: content, + path: skillDirectoryFullPath, + resources: resources, + scripts: scripts); } - private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter) { - frontmatter = null!; - body = null!; + frontmatter = null; Match match = s_frontmatterRegex.Match(content); if (!match.Success) @@ -217,10 +201,13 @@ internal sealed partial class FileAgentSkillLoader return false; } + string yamlContent = match.Groups[1].Value.Trim(); + string? name = null; string? description = null; - - string yamlContent = match.Groups[1].Value.Trim(); + string? license = null; + string? compatibility = null; + string? allowedTools = null; foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) { @@ -235,50 +222,62 @@ internal sealed partial class FileAgentSkillLoader { description = value; } + else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase)) + { + license = value; + } + else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase)) + { + compatibility = value; + } + else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase)) + { + allowedTools = value; + } } - if (string.IsNullOrWhiteSpace(name)) + // Parse metadata block (indented key-value pairs under "metadata:"). + AdditionalPropertiesDictionary? metadata = null; + Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent); + if (metadataMatch.Success) { - LogMissingFrontmatterField(this._logger, skillFilePath, "name"); + metadata = []; + foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value)) + { + metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value; + } + } + + if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) || + !AgentSkillFrontmatter.ValidateDescription(description, out validationReason)) + { + LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason); return false; } - if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name)) + frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility) { - LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."); - return false; - } + License = license, + AllowedTools = allowedTools, + Metadata = metadata, + }; // skillFilePath is e.g. "/skills/my-skill/SKILL.md". // GetDirectoryName strips the filename → "/skills/my-skill". // GetFileName then extracts the last segment → "my-skill". // This gives us the skill's parent directory name to validate against the frontmatter name. string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty; - if (!string.Equals(name, directoryName, StringComparison.Ordinal)) + if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal)) { if (this._logger.IsEnabled(LogLevel.Error)) { - LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName)); + LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName)); } + frontmatter = null; return false; } - if (string.IsNullOrWhiteSpace(description)) - { - LogMissingFrontmatterField(this._logger, skillFilePath, "description"); - return false; - } - - if (description.Length > MaxDescriptionLength) - { - LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer."); - return false; - } - - frontmatter = new SkillFrontmatter(name, description); - body = content.Substring(match.Index + match.Length).TrimStart(); - return true; } @@ -287,15 +286,15 @@ internal sealed partial class FileAgentSkillLoader /// /// /// Recursively walks and collects files whose extension - /// matches , excluding SKILL.md itself. Each candidate + /// matches the allowed set, excluding SKILL.md itself. Each candidate /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with /// a warning. /// - private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) + private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); + var resources = new List(); #if NET var enumerationOptions = new EnumerationOptions @@ -326,21 +325,21 @@ internal sealed partial class FileAgentSkillLoader { LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); } + continue; } // Normalize the enumerated path to guard against non-canonical forms - // (redundant separators, 8.3 short names, etc.) that would produce - // malformed relative resource names. string resolvedFilePath = Path.GetFullPath(filePath); // Path containment check - if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath)) + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { if (this._logger.IsEnabled(LogLevel.Warning)) { LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } @@ -351,30 +350,86 @@ internal sealed partial class FileAgentSkillLoader { LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } // Compute relative path and normalize to forward slashes - string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length); - resources.Add(NormalizeResourcePath(relativePath)); + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); } return resources; } /// - /// Checks that is under , - /// guarding against path traversal attacks. + /// Scans a skill directory for script files matching the configured extensions. /// - private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath) + /// + /// Recursively walks the skill directory and collects files whose extension + /// matches the allowed set. Each candidate is validated against path-traversal + /// and symlink-escape checks; unsafe files are skipped with a warning. + /// + private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { - return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase); + string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; + var scripts = new List(); + +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) +#endif + { + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) + { + continue; + } + + // Normalize the enumerated path to guard against non-canonical forms + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment check + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Symlink check + if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Compute relative path and normalize to forward slashes + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + } + + return scripts; } /// - /// Checks whether any segment in (relative to - /// ) is a symlink (reparse point). - /// Uses which is available on all target frameworks. + /// Checks whether any segment in the path (relative to the directory) is a symlink. /// private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) { @@ -399,11 +454,10 @@ internal sealed partial class FileAgentSkillLoader } /// - /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing - /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are - /// treated as the same resource. + /// Normalizes a relative path by replacing backslashes with forward slashes + /// and trimming a leading "./" prefix. /// - private static string NormalizeResourcePath(string path) + private static string NormalizePath(string path) { if (path.IndexOf('\\') >= 0) { @@ -419,8 +473,7 @@ internal sealed partial class FileAgentSkillLoader } /// - /// Replaces control characters in a file path with '?' to prevent log injection - /// via crafted filenames (e.g., filenames containing newlines on Linux). + /// Replaces control characters in a file path with '?' to prevent log injection. /// private static string SanitizePathForLog(string path) { @@ -449,7 +502,7 @@ internal sealed partial class FileAgentSkillLoader if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal)) { #pragma warning disable CA2208 // Instantiate argument exceptions correctly - throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions)); + throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions"); #pragma warning restore CA2208 // Instantiate argument exceptions correctly } } @@ -467,9 +520,6 @@ internal sealed partial class FileAgentSkillLoader [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")] private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")] - private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")] private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason); @@ -479,15 +529,15 @@ internal sealed partial class FileAgentSkillLoader [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")] private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")] - private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath); - [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")] - private static partial void LogResourceReading(ILogger logger, string fileName, string skillName); - [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")] + private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] + private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs new file mode 100644 index 0000000000..edaec327fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for file-based skill sources. +/// +/// +/// Use this class to configure file-based skill discovery without relying on +/// positional constructor or method parameters. New options can be added here +/// without breaking existing callers. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillsSourceOptions +{ + /// + /// Gets or sets the allowed file extensions for skill resources. + /// When , defaults to .md, .json, .yaml, + /// .yml, .csv, .xml, .txt. + /// + public IEnumerable? AllowedResourceExtensions { get; set; } + + /// + /// Gets or sets the allowed file extensions for skill scripts. + /// When , defaults to .py, .js, .sh, + /// .ps1, .cs, .csx. + /// + public IEnumerable? AllowedScriptExtensions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs deleted file mode 100644 index f28bad3ab0..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Represents a loaded Agent Skill discovered from a filesystem directory. -/// -/// -/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description) -/// and a markdown body with instructions. Resource files referenced in the body are validated at -/// discovery time and read from disk on demand. -/// -internal sealed class FileAgentSkill -{ - /// - /// Initializes a new instance of the class. - /// - /// Parsed YAML frontmatter (name and description). - /// The SKILL.md content after the closing --- delimiter. - /// Absolute path to the directory containing this skill. - /// Relative paths of resource files referenced in the skill body. - public FileAgentSkill( - SkillFrontmatter frontmatter, - string body, - string sourcePath, - IReadOnlyList? resourceNames = null) - { - this.Frontmatter = Throw.IfNull(frontmatter); - this.Body = Throw.IfNull(body); - this.SourcePath = Throw.IfNullOrWhitespace(sourcePath); - this.ResourceNames = resourceNames ?? []; - } - - /// - /// Gets the parsed YAML frontmatter (name and description). - /// - public SkillFrontmatter Frontmatter { get; } - - /// - /// Gets the SKILL.md body content (without the YAML frontmatter). - /// - public string Body { get; } - - /// - /// Gets the directory path where the skill was discovered. - /// - public string SourcePath { get; } - - /// - /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). - /// - public IReadOnlyList ResourceNames { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs deleted file mode 100644 index cd64cdc723..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// An that discovers and exposes Agent Skills from filesystem directories. -/// -/// -/// -/// This provider implements the progressive disclosure pattern from the -/// Agent Skills specification: -/// -/// -/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill). -/// Load — the full SKILL.md body is returned via the load_skill tool. -/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool. -/// -/// -/// Skills are discovered by searching the configured directories for SKILL.md files. -/// Referenced resources are validated at initialization; invalid skills are excluded and logged. -/// -/// -/// Security: this provider only reads static content. Skill metadata is XML-escaped -/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape. -/// Only use skills from trusted sources. -/// -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class FileAgentSkillsProvider : AIContextProvider -{ - private const string DefaultSkillsInstructionPrompt = - """ - You have access to skills containing domain-specific knowledge and capabilities. - Each skill provides specialized instructions, reference documents, and assets for specific tasks. - - - {0} - - - When a task aligns with a skill's domain: - 1. Use `load_skill` to retrieve the skill's instructions - 2. Follow the provided guidance - 3. Use `read_skill_resource` to read any references or other files mentioned by the skill - - Only load what is needed, when it is needed. - """; - - private readonly Dictionary _skills; - private readonly ILogger _logger; - private readonly FileAgentSkillLoader _loader; - private readonly AITool[] _tools; - private readonly string? _skillsInstructionPrompt; - - /// - /// Initializes a new instance of the class that searches a single directory for skills. - /// - /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - : this([skillPath], options, loggerFactory) - { - } - - /// - /// Initializes a new instance of the class that searches multiple directories for skills. - /// - /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - { - _ = Throw.IfNull(skillPaths); - - this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); - - this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions); - this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); - - this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); - - this._tools = - [ - AIFunctionFactory.Create( - this.LoadSkill, - name: "load_skill", - description: "Loads the full instructions for a specific skill."), - AIFunctionFactory.Create( - this.ReadSkillResourceAsync, - name: "read_skill_resource", - description: "Reads a file associated with a skill, such as references or assets."), - ]; - } - - /// - protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - if (this._skills.Count == 0) - { - return base.ProvideAIContextAsync(context, cancellationToken); - } - - return new ValueTask(new AIContext - { - Instructions = this._skillsInstructionPrompt, - Tools = this._tools - }); - } - - private string LoadSkill(string skillName) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - LogSkillLoading(this._logger, skillName); - - return skill.Body; - } - - private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (string.IsNullOrWhiteSpace(resourceName)) - { - return "Error: Resource name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - try - { - return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - LogResourceReadError(this._logger, skillName, resourceName, ex); - return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; - } - } - - private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) - { - string promptTemplate = DefaultSkillsInstructionPrompt; - - if (options?.SkillsInstructionPrompt is { } optionsInstructions) - { - try - { - _ = string.Format(optionsInstructions, string.Empty); - promptTemplate = optionsInstructions; - } - catch (FormatException ex) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').", - nameof(options), - ex); - } - } - - if (skills.Count == 0) - { - return null; - } - - var sb = new StringBuilder(); - - // Order by name for deterministic prompt output across process restarts - // (Dictionary enumeration order is not guaranteed and varies with hash randomization). - foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) - { - sb.AppendLine(" "); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); - sb.AppendLine(" "); - } - - return string.Format(promptTemplate, sb.ToString().TrimEnd()); - } - - [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] - private static partial void LogSkillLoading(ILogger logger, string skillName); - - [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] - private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs deleted file mode 100644 index 600c5b964c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI; - -/// -/// Configuration options for . -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class FileAgentSkillsProviderOptions -{ - /// - /// Gets or sets a custom system prompt template for advertising skills. - /// Use {0} as the placeholder for the generated skills list. - /// When , a default template is used. - /// - public string? SkillsInstructionPrompt { get; set; } - - /// - /// Gets or sets the file extensions recognized as discoverable skill resources. - /// Each value must start with a '.' character (for example, .md), and - /// extension comparisons are performed in a case-insensitive manner. - /// Files in the skill directory (and its subdirectories) whose extension matches - /// one of these values will be automatically discovered as resources. - /// When , a default set of extensions is used - /// (.md, .json, .yaml, .yml, .csv, .xml, .txt). - /// - public IEnumerable? AllowedResourceExtensions { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs deleted file mode 100644 index 123a6c43f4..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. -/// -internal sealed class SkillFrontmatter -{ - /// - /// Initializes a new instance of the class. - /// - /// Skill name. - /// Skill description. - public SkillFrontmatter(string name, string description) - { - this.Name = Throw.IfNullOrWhitespace(name); - this.Description = Throw.IfNullOrWhitespace(description); - } - - /// - /// Gets the skill name. Lowercase letters, numbers, and hyphens only. - /// - public string Name { get; } - - /// - /// Gets the skill description. Used for discovery in the system prompt. - /// - public string Description { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs index e389b02294..7a48243ef6 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -62,6 +63,7 @@ public sealed class TextSearchProvider : MessageAIContextProvider private readonly string _contextPrompt; private readonly string _citationsPrompt; private readonly Func, string>? _contextFormatter; + private readonly Redactor _redactor; /// /// Initializes a new instance of the class. @@ -89,6 +91,7 @@ public sealed class TextSearchProvider : MessageAIContextProvider this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; this._citationsPrompt = options?.CitationsPrompt ?? DefaultCitationsPrompt; this._contextFormatter = options?.ContextFormatter; + this._redactor = options?.EnableSensitiveTelemetryData == true ? NullRedactor.Instance : (options?.Redactor ?? new ReplacingRedactor("")); // Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling) this._tools = @@ -180,7 +183,7 @@ public sealed class TextSearchProvider : MessageAIContextProvider if (this._logger?.IsEnabled(LogLevel.Trace) is true) { - this._logger.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", input, formatted); + this._logger.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", this.SanitizeLogData(input), this.SanitizeLogData(formatted)); } return [new ChatMessage(ChatRole.User, formatted)]; @@ -249,7 +252,7 @@ public sealed class TextSearchProvider : MessageAIContextProvider if (this._logger.IsEnabled(LogLevel.Trace)) { - this._logger.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", userQuestion, outputText); + this._logger.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", this.SanitizeLogData(userQuestion), this.SanitizeLogData(outputText)); } } @@ -325,6 +328,8 @@ public sealed class TextSearchProvider : MessageAIContextProvider public object? RawRepresentation { get; set; } } + private string SanitizeLogData(string? data) => this._redactor.Redact(data); + /// /// Represents the per-session state of a stored in the . /// diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs index 879e34121d..9c6845ff21 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI; @@ -117,6 +118,26 @@ public sealed class TextSearchProviderOptions /// public List? RecentMessageRolesIncluded { get; set; } + /// + /// Gets or sets a value indicating whether sensitive data such as user queries and search results may appear in logs. + /// + /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// + public bool EnableSensitiveTelemetryData { get; set; } + + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Behavior choices for the provider. /// 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/Redaction/README.md b/dotnet/src/Shared/Redaction/README.md new file mode 100644 index 0000000000..01683d0a54 --- /dev/null +++ b/dotnet/src/Shared/Redaction/README.md @@ -0,0 +1,30 @@ +# Redaction + +Log data redaction utilities built on `Microsoft.Extensions.Compliance.Redaction.Redactor`. + +Provides `ReplacingRedactor`, an internal `Redactor` implementation that replaces +any input with a fixed replacement string (e.g. `""`). + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` + +You will also need to add a package reference to `Microsoft.Extensions.Compliance.Abstractions`: + +```xml + + + +``` + +And finally, this also depends on the shared Throw class, so when using redaction, InjectSharedThrow should also be enabled: + +```xml + + true + +``` \ No newline at end of file diff --git a/dotnet/src/Shared/Redaction/ReplacingRedactor.cs b/dotnet/src/Shared/Redaction/ReplacingRedactor.cs new file mode 100644 index 0000000000..3e97e7318e --- /dev/null +++ b/dotnet/src/Shared/Redaction/ReplacingRedactor.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A that replaces the entire input with a fixed replacement string. +/// +internal sealed class ReplacingRedactor : Redactor +{ + private readonly string _replacementText; + + /// + /// Initializes a new instance of the class. + /// + /// The text to substitute for any input value. + /// Thrown when is . + public ReplacingRedactor(string replacementText) + { + this._replacementText = Throw.IfNull(replacementText); + } + + /// + public override int GetRedactedLength(ReadOnlySpan input) => this._replacementText.Length; + + /// + public override int Redact(ReadOnlySpan source, Span destination) + { + this._replacementText.AsSpan().CopyTo(destination); + return this._replacementText.Length; + } +} 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..425197853e 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 @@ -188,6 +188,134 @@ public class AIProjectClientCreateTests } } + /// + /// Validates that an agent version created with an OpenAPI tool definition via the native + /// Azure.AI.Projects SDK and then wrapped with AsAIAgent(agentVersion) correctly + /// invokes the server-side OpenAPI function through RunAsync. + /// Regression test for https://github.com/microsoft/agent-framework/issues/4883. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = "For manual testing only")] + public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync() + { + // Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types. + string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("OpenAPITestAgent"); + const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; + + const string CountriesOpenApiSpec = """ + { + "openapi": "3.1.0", + "info": { + "title": "REST Countries API", + "description": "Retrieve information about countries by currency code", + "version": "v3.1" + }, + "servers": [ + { + "url": "https://restcountries.com/v3.1" + } + ], + "paths": { + "/currency/{currency}": { + "get": { + "description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)", + "operationId": "GetCountriesByCurrency", + "parameters": [ + { + "name": "currency", + "in": "path", + "description": "Currency code (e.g., USD, EUR, GBP)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response with list of countries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "404": { + "description": "No countries found for the currency" + } + } + } + } + } + } + """; + + // Step 1: Create the OpenAPI function definition and agent version using native SDK types. + var openApiFunction = new OpenApiFunctionDefinition( + "get_countries", + BinaryData.FromString(CountriesOpenApiSpec), + new OpenAPIAnonymousAuthenticationDetails()) + { + Description = "Retrieve information about countries by currency code" + }; + + var definition = new PromptAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = AgentInstructions, + Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) } + }; + + AgentVersionCreationOptions creationOptions = new(definition); + AgentVersion agentVersion = await this._client.Agents.CreateAgentVersionAsync(AgentName, creationOptions); + + try + { + // Step 2: Wrap the agent version using AsAIAgent extension. + ChatClientAgent agent = this._client.AsAIAgent(agentVersion); + + // Assert the agent was created correctly and retains version metadata. + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + var retrievedVersion = agent.GetService(); + Assert.NotNull(retrievedVersion); + + // Step 3: Call RunAsync to trigger the server-side OpenAPI function. + var result = await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them."); + + // Step 4: Validate the OpenAPI tool was invoked server-side. + // Note: Server-side OpenAPI tools (executed within the Responses API via AgentReference) + // do not surface as FunctionCallContent in the MEAI abstraction — the API handles the full + // tool loop internally. We validate tool invocation by asserting the response contains + // multiple specific country names that the model would need API data to enumerate accurately. + var text = result.ToString(); + Assert.NotEmpty(text); + + // The response must mention multiple well-known Eurozone countries — requiring several + // correct entries makes it highly unlikely the model answered purely from parametric knowledge. + int matchCount = 0; + foreach (var country in new[] { "Germany", "France", "Italy", "Spain", "Portugal", "Netherlands", "Belgium", "Austria", "Ireland", "Finland" }) + { + if (text.Contains(country, StringComparison.OrdinalIgnoreCase)) + { + matchCount++; + } + } + + Assert.True( + matchCount >= 3, + $"Expected response to list at least 3 Eurozone countries from the OpenAPI tool, but found {matchCount}. Response: {text}"); + } + finally + { + // Cleanup. + await this._client.Agents.DeleteAgentAsync(AgentName); + } + } + [Theory] [InlineData("CreateWithChatClientAgentOptionsAsync")] public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) 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..487dcd6878 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunStreamingTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunStreamingTests.cs @@ -4,6 +4,7 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +[Trait("Category", "Integration")] public class AzureAIAgentsChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs index b2f75c536e..79a7c24c69 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs @@ -4,6 +4,7 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +[Trait("Category", "Integration")] public class AzureAIAgentsChatClientAgentRunTests() : ChatClientAgentRunTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs index 20f6a4cda4..971bf57cbc 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; @@ -12,6 +14,7 @@ using Shared.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +[Trait("Category", "Integration")] 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..1ba0ec991e 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunStreamingTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunStreamingTests.cs @@ -4,6 +4,7 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +[Trait("Category", "Integration")] public class AzureAIAgentsPersistentRunStreamingTests() : RunStreamingTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs index 3e6032401d..446b27b817 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs @@ -4,6 +4,7 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +[Trait("Category", "Integration")] public class AzureAIAgentsPersistentRunTests() : RunTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs index 0fa20f18ac..6ec4620ce3 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs @@ -5,6 +5,7 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +[Trait("Category", "Integration")] 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.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; 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/ProjectResponsesClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs new file mode 100644 index 0000000000..d10ef861c9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using Azure.AI.Extensions.OpenAI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class ProjectResponsesClientExtensionsTests +{ + private static ProjectResponsesClient CreateTestClient() + { + return new ProjectResponsesClient(new FakeAuthenticationTokenProvider()); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled throws ArgumentNullException when client is null. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((ProjectResponsesClient)null!).AsIChatClientWithStoredOutputDisabled()); + + Assert.Equal("responseClient", exception.ParamName); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled wraps the original ProjectResponsesClient, + /// which remains accessible via the service chain. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_InnerResponsesClientIsAccessible() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Assert - the inner ProjectResponsesClient should be accessible via GetService + var innerClient = chatClient.GetService(); + Assert.NotNull(innerClient); + Assert.Same(responseClient, innerClient); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent false + /// wraps the original ProjectResponsesClient, which remains accessible via the service chain. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_InnerResponsesClientIsAccessible() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false); + + // Assert - the inner ProjectResponsesClient should be accessible via GetService + var innerClient = chatClient.GetService(); + Assert.NotNull(innerClient); + Assert.Same(responseClient, innerClient); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with default parameter (includeReasoningEncryptedContent = true) + /// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_Default_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent explicitly set to true + /// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningTrue_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: true); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent set to false + /// configures StoredOutputEnabled to false and does not include ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_ConfiguresStoredOutputDisabledWithoutReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithDeploymentName_ConfiguresStoredOutputDisabled() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(deploymentName: "my-deployment"); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Extracts the produced by the ConfigureOptions pipeline + /// by using reflection to access the configure action and invoking it on a test . + /// + private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient) + { + var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(configureField); + + var configureAction = configureField.GetValue(chatClient) as Action; + Assert.NotNull(configureAction); + + var options = new ChatOptions(); + configureAction(options); + + Assert.NotNull(options.RawRepresentationFactory); + return options.RawRepresentationFactory(chatClient) as CreateResponseOptions; + } +} 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.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj index 1fc964e702..7c0113faab 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj @@ -8,7 +8,6 @@ - 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.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj index 42d8682870..3ea3d11e05 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj @@ -10,7 +10,6 @@ - diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj index 6b909fd4f2..490f816cd4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj @@ -6,7 +6,6 @@ true - true 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 bd88c55cb8..b15f6e8f42 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -21,6 +21,12 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi private const string RedisPort = "6379"; private static readonly string s_dotnetTargetFramework = GetTargetFramework(); + +#if DEBUG + private const string BuildConfiguration = "Debug"; +#else + private const string BuildConfiguration = "Release"; +#endif private static readonly HttpClient s_sharedHttpClient = new(); private static readonly IConfiguration s_configuration = new ConfigurationBuilder() @@ -797,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 = []; @@ -805,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); @@ -818,44 +826,12 @@ 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}", - 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() { FileName = "dotnet", - Arguments = $"run --no-build -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}", + Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}", WorkingDirectory = samplePath, UseShellExecute = false, RedirectStandardOutput = true, @@ -913,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 d5ea083894..a7f2f51156 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -5,6 +5,8 @@ using System.Reflection; using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; /// @@ -20,6 +22,12 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : private const string DtsPort = "8080"; private static readonly string s_dotnetTargetFramework = GetTargetFramework(); + +#if DEBUG + private const string BuildConfiguration = "Debug"; +#else + private const string BuildConfiguration = "Release"; +#endif private static readonly HttpClient s_sharedHttpClient = new(); private static readonly IConfiguration s_configuration = new ConfigurationBuilder() @@ -30,7 +38,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( @@ -229,6 +237,114 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }); } + [Fact] + public async Task WorkflowMcpToolSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool"); + await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) => + { + // Connect to the MCP endpoint exposed by the Azure Functions host + IClientTransport clientTransport = new HttpClientTransport(new() + { + Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp") + }); + + await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport); + + // Verify both workflow tools are listed + IList tools = await mcpClient.ListToolsAsync(); + this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}"); + + Assert.Single(tools, t => t.Name == "Translate"); + Assert.Single(tools, t => t.Name == "OrderLookup"); + + // Invoke the Translate workflow via MCP tool (returns a string result) + this._outputHelper.WriteLine("Invoking MCP tool 'Translate'..."); + CallToolResult translateResult = await mcpClient.CallToolAsync( + "Translate", + arguments: new Dictionary { { "input", "hello world" } }); + + Assert.NotEmpty(translateResult.Content); + string translateResponse = Assert.IsType(translateResult.Content[0]).Text; + this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}"); + Assert.NotEmpty(translateResponse); + Assert.Contains("HELLO WORLD", translateResponse); + + // Invoke the OrderLookup workflow via MCP tool (returns a POCO serialized as JSON) + this._outputHelper.WriteLine("Invoking MCP tool 'OrderLookup'..."); + CallToolResult orderResult = await mcpClient.CallToolAsync( + "OrderLookup", + arguments: new Dictionary { { "input", "ORD-2025-42" } }); + + Assert.NotEmpty(orderResult.Content); + string orderResponse = Assert.IsType(orderResult.Content[0]).Text; + this._outputHelper.WriteLine($"OrderLookup MCP tool response: {orderResponse}"); + Assert.NotEmpty(orderResponse); + Assert.Contains("ORD-2025-42", orderResponse); + + // Verify executor activities ran in the logs + lock (logs) + { + Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("[Activity] LookupOrder:")), "LookupOrder activity not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("[Activity] EnrichOrder:")), "EnrichOrder activity not found in logs."); + } + }); + } + + [Fact] + public async Task WorkflowAndAgentsSampleValidationAsync() + { + string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents"); + await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) => + { + // Connect to the MCP endpoint exposed by the Azure Functions host + IClientTransport clientTransport = new HttpClientTransport(new() + { + Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp") + }); + + await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport); + + // Verify both the agent and workflow tools are listed + IList tools = await mcpClient.ListToolsAsync(); + this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}"); + + Assert.Single(tools, t => t.Name == "Assistant"); + Assert.Single(tools, t => t.Name == "Translate"); + + // Invoke the Translate workflow via MCP tool + this._outputHelper.WriteLine("Invoking MCP tool 'Translate'..."); + CallToolResult translateResult = await mcpClient.CallToolAsync( + "Translate", + arguments: new Dictionary { { "input", "hello world" } }); + + Assert.NotEmpty(translateResult.Content); + string translateResponse = Assert.IsType(translateResult.Content[0]).Text; + this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}"); + Assert.Contains("HELLO WORLD", translateResponse); + + // Invoke the Assistant agent via MCP tool + this._outputHelper.WriteLine("Invoking MCP tool 'Assistant'..."); + CallToolResult assistantResult = await mcpClient.CallToolAsync( + "Assistant", + arguments: new Dictionary { { "query", "What is 2 + 2?" } }); + + Assert.NotEmpty(assistantResult.Content); + string assistantResponse = Assert.IsType(assistantResult.Content[0]).Text; + this._outputHelper.WriteLine($"Assistant MCP tool response: {assistantResponse}"); + Assert.NotEmpty(assistantResponse); + + // Verify workflow executor activities ran in the logs + lock (logs) + { + Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs."); + Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs."); + } + }); + } + [Fact] public async Task ConcurrentWorkflowSampleValidationAsync() { @@ -419,11 +535,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 @@ -437,7 +559,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}", + Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}", WorkingDirectory = samplePath, UseShellExecute = false, RedirectStandardOutput = true, @@ -498,29 +620,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() diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs index 7d3a2ec13e..82824e0d8c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableAgentFunctionMetadataTransformerTests.cs @@ -148,6 +148,45 @@ public sealed class DurableAgentFunctionMetadataTransformerTests } } + [Fact] + public void Transform_SkipsAgents_WithoutExplicitOptions() + { + // Arrange: two agents in the dictionary, but only one has explicit FunctionsAgentOptions. + // This simulates a workflow-auto-registered agent (workflowAgent) alongside a standalone agent. + Dictionary> agents = new() + { + { "standaloneAgent", _ => new TestAgent("standaloneAgent", "Standalone agent") }, + { "workflowAgent", _ => new TestAgent("workflowAgent", "Auto-registered by workflow") } + }; + + FunctionsAgentOptions standaloneOptions = new(); + standaloneOptions.HttpTrigger.IsEnabled = true; + + // Only standaloneAgent has explicit options; workflowAgent does not. + IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary + { + { "standaloneAgent", standaloneOptions } + }); + + List metadataList = []; + + DurableAgentFunctionMetadataTransformer transformer = new( + agents, + NullLogger.Instance, + new FakeServiceProvider(), + agentOptionsProvider); + + // Act + transformer.Transform(metadataList); + + // Assert: only standaloneAgent should have triggers (entity + http = 2). + // workflowAgent should be skipped entirely. + Assert.Equal(2, metadataList.Count); + Assert.Contains(metadataList, m => m.Name == "dafx-standaloneAgent"); + Assert.Contains(metadataList, m => m.Name == "http-standaloneAgent"); + Assert.DoesNotContain(metadataList, m => m.Name!.Contains("workflowAgent")); + } + private static List BuildFunctionMetadataList(int numberOfFunctions) { List list = []; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs new file mode 100644 index 0000000000..a777c69480 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionMetadataFactoryTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; + +public sealed class FunctionMetadataFactoryTests +{ + [Fact] + public void CreateEntityTrigger_SetsCorrectNameAndBindings() + { + DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateEntityTrigger("myAgent"); + + Assert.Equal("dafx-myAgent", metadata.Name); + Assert.Equal("dotnet-isolated", metadata.Language); + Assert.Equal(BuiltInFunctions.RunAgentEntityFunctionEntryPoint, metadata.EntryPoint); + Assert.NotNull(metadata.RawBindings); + Assert.Equal(2, metadata.RawBindings.Count); + Assert.Contains("entityTrigger", metadata.RawBindings[0]); + Assert.Contains("durableClient", metadata.RawBindings[1]); + } + + [Fact] + public void CreateHttpTrigger_SetsCorrectNameRouteAndDefaults() + { + DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger( + "myWorkflow", "workflows/myWorkflow/run", BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint); + + Assert.Equal("http-myWorkflow", metadata.Name); + Assert.Equal("dotnet-isolated", metadata.Language); + Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, metadata.EntryPoint); + Assert.NotNull(metadata.RawBindings); + Assert.Equal(3, metadata.RawBindings.Count); + Assert.Contains("httpTrigger", metadata.RawBindings[0]); + Assert.Contains("workflows/myWorkflow/run", metadata.RawBindings[0]); + Assert.Contains("\"post\"", metadata.RawBindings[0]); + Assert.Contains("http", metadata.RawBindings[1]); + Assert.Contains("durableClient", metadata.RawBindings[2]); + } + + [Fact] + public void CreateHttpTrigger_RespectsCustomMethods() + { + DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger( + "status", "workflows/status/{runId}", BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, methods: "\"get\""); + + Assert.NotNull(metadata.RawBindings); + Assert.Contains("\"get\"", metadata.RawBindings[0]); + Assert.DoesNotContain("\"post\"", metadata.RawBindings[0]); + } + + [Fact] + public void CreateActivityTrigger_SetsCorrectNameAndBindings() + { + DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateActivityTrigger("dafx-MyExecutor"); + + Assert.Equal("dafx-MyExecutor", metadata.Name); + Assert.Equal("dotnet-isolated", metadata.Language); + Assert.Equal(BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, metadata.EntryPoint); + Assert.NotNull(metadata.RawBindings); + Assert.Equal(2, metadata.RawBindings.Count); + Assert.Contains("activityTrigger", metadata.RawBindings[0]); + Assert.Contains("durableClient", metadata.RawBindings[1]); + } + + [Fact] + public void CreateOrchestrationTrigger_SetsCorrectNameAndBindings() + { + DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateOrchestrationTrigger( + "dafx-MyWorkflow", BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint); + + Assert.Equal("dafx-MyWorkflow", metadata.Name); + Assert.Equal("dotnet-isolated", metadata.Language); + Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, metadata.EntryPoint); + Assert.NotNull(metadata.RawBindings); + Assert.Single(metadata.RawBindings); + Assert.Contains("orchestrationTrigger", metadata.RawBindings[0]); + } + + [Fact] + public void CreateWorkflowMcpToolTrigger_SetsCorrectNameAndBindings() + { + DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("Translate", "Translate text"); + + Assert.Equal("mcptool-Translate", metadata.Name); + Assert.Equal("dotnet-isolated", metadata.Language); + Assert.Equal(BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, metadata.EntryPoint); + Assert.NotNull(metadata.RawBindings); + Assert.Equal(3, metadata.RawBindings.Count); + + // Verify all bindings are valid JSON + foreach (string binding in metadata.RawBindings) + { + JsonDocument.Parse(binding); + } + + // mcpToolTrigger binding + Assert.Contains("mcpToolTrigger", metadata.RawBindings[0]); + Assert.Contains("\"toolName\":\"Translate\"", metadata.RawBindings[0]); + Assert.Contains("\"description\":\"Translate text\"", metadata.RawBindings[0]); + Assert.Contains("toolProperties", metadata.RawBindings[0]); + + // mcpToolProperty binding for input + Assert.Contains("mcpToolProperty", metadata.RawBindings[1]); + Assert.Contains("\"propertyName\":\"input\"", metadata.RawBindings[1]); + Assert.Contains("\"isRequired\":true", metadata.RawBindings[1]); + + // durableClient binding + Assert.Contains("durableClient", metadata.RawBindings[2]); + } + + [Fact] + public void CreateWorkflowMcpToolTrigger_UsesDefaultDescription_WhenNull() + { + DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("MyWorkflow", description: null); + + Assert.NotNull(metadata.RawBindings); + Assert.Contains("Run the MyWorkflow workflow", metadata.RawBindings[0]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs new file mode 100644 index 0000000000..406f0a32e1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for ChatCompletionRequestMessage.ToChatMessage() role preservation. +/// Verifies that each message type correctly maps its role to the corresponding ChatRole. +/// +public sealed class ChatCompletionRequestMessageToChatMessageTests +{ + [Theory] + [InlineData("system", """{"role":"system","content":"You are a helpful assistant."}""")] + [InlineData("developer", """{"role":"developer","content":"Follow these rules."}""")] + [InlineData("user", """{"role":"user","content":"Hello!"}""")] + [InlineData("assistant", """{"role":"assistant","content":"Hi there!"}""")] + [InlineData("tool", """{"role":"tool","content":"result","tool_call_id":"call_123"}""")] + public void ToChatMessage_PreservesRole_ForTextContent(string expectedRole, string json) + { + // Arrange + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal(expectedRole, message.Role); + Assert.Equal(new ChatRole(expectedRole), chatMessage.Role); + } + + [Fact] + public void ToChatMessage_FunctionMessage_PreservesRole() + { + // Arrange + const string Json = """{"role":"function","name":"get_weather","content":"sunny"}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal("function", message.Role); + Assert.Equal(new ChatRole("function"), chatMessage.Role); + } + + [Theory] + [InlineData("system")] + [InlineData("developer")] + [InlineData("user")] + [InlineData("assistant")] + public void ToChatMessage_PreservesRole_ForMultiPartContent(string expectedRole) + { + // Arrange + string json = $$"""{"role":"{{expectedRole}}","content":[{"type":"text","text":"Hello!"}]}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal(expectedRole, message.Role); + Assert.Equal(new ChatRole(expectedRole), chatMessage.Role); + } + + [Fact] + public void ToChatMessage_MultiTurnConversation_PreservesAllRoles() + { + // Arrange - simulate a multi-turn conversation + string[] jsons = + [ + """{"role":"system","content":"You are a helpful assistant."}""", + """{"role":"user","content":"Hello!"}""", + """{"role":"assistant","content":"Hi there! How can I help?"}""", + """{"role":"user","content":"What did I just say?"}""" + ]; + + string[] expectedRoles = ["system", "user", "assistant", "user"]; + + // Act + ChatMessage[] chatMessages = jsons + .Select(j => JsonSerializer.Deserialize( + j, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!) + .Select(m => m.ToChatMessage()) + .ToArray(); + + // Assert + Assert.Equal(expectedRoles.Length, chatMessages.Length); + for (int i = 0; i < expectedRoles.Length; i++) + { + Assert.Equal(new ChatRole(expectedRoles[i]), chatMessages[i].Role); + } + } + + [Fact] + public void ToChatMessage_PreservesTextContent() + { + // Arrange + const string Json = """{"role":"system","content":"You are a helpful assistant."}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Contains(chatMessage.Contents, c => c is TextContent tc && tc.Text == "You are a helpful assistant."); + } +} 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.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index 3374270861..a959faa515 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -148,11 +148,15 @@ public sealed class Mem0ProviderTests : IDisposable } [Theory] - [InlineData(false, false, 4)] - [InlineData(true, false, 4)] - [InlineData(false, true, 2)] - [InlineData(true, true, 2)] - public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + [InlineData(false, false, false, 4)] + [InlineData(false, false, true, 4)] + [InlineData(true, false, false, 4)] + [InlineData(true, false, true, 4)] + [InlineData(false, true, false, 2)] + [InlineData(false, true, true, 2)] + [InlineData(true, true, false, 2)] + [InlineData(true, true, true, 2)] + public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations) { // Arrange if (requestThrows) @@ -171,7 +175,11 @@ public sealed class Mem0ProviderTests : IDisposable ThreadId = "session", UserId = "user" }; - var options = new Mem0ProviderOptions { EnableSensitiveTelemetryData = enableSensitiveTelemetryData }; + var options = new Mem0ProviderOptions + { + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null + }; var mockSession = new TestAgentSession(); var sut = new Mem0Provider(this._httpClient, _ => new Mem0Provider.State(storageScope), options: options, loggerFactory: this._loggerFactoryMock.Object); @@ -180,7 +188,8 @@ public sealed class Mem0ProviderTests : IDisposable // Act await sut.InvokingAsync(invokingContext, CancellationToken.None); - // Assert + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor + string expectedRedaction = enableSensitiveTelemetryData ? "user" : (useCustomRedactor ? "***" : ""); Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); foreach (var logInvocation in this._loggerMock.Invocations) { @@ -191,18 +200,18 @@ public sealed class Mem0ProviderTests : IDisposable var state = Assert.IsType>>(logInvocation.Arguments[2], exactMatch: false); var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; - Assert.Equal(enableSensitiveTelemetryData ? "user" : "", userIdValue); + Assert.Equal(expectedRedaction, userIdValue); var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value; if (inputValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : "", inputValue); + Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : expectedRedaction, inputValue); } var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value; if (messageTextValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : "", messageTextValue); + Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : expectedRedaction, messageTextValue); } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs index 3b06bbb772..5e65c4a1a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIContextProviderDecorators/AIContextProviderChatClientTests.cs @@ -250,6 +250,129 @@ public class AIContextProviderChatClientTests #endregion + #region Shared Options Tests + + [Fact] + public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync() + { + // Arrange: track tool count seen by the inner client on each call + var toolCountsSeenByInner = new List(); + + var innerClient = CreateMockChatClient( + onGetResponse: (_, options, _) => + { + toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0); + return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + }); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var sharedOptions = new ChatOptions + { + Tools = new List { new TestAITool() } + }; + + // Act: make 3 calls reusing the same ChatOptions + for (int i = 0; i < 3; i++) + { + await RunWithAgentContextAsync(chatClient, sharedOptions); + } + + // Assert: each call should see exactly 2 tools (1 baseline + 1 injected) + Assert.Equal(3, toolCountsSeenByInner.Count); + Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count)); + } + + [Fact] + public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync() + { + // Arrange + var innerClient = CreateMockChatClient( + onGetResponse: (_, _, _) => + Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]))); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var baselineTool = new TestAITool(); + var originalTools = new List { baselineTool }; + var sharedOptions = new ChatOptions + { + Tools = originalTools + }; + + // Act + await RunWithAgentContextAsync(chatClient, sharedOptions); + + // Assert: the original list should still contain only the baseline tool + Assert.Single(originalTools); + Assert.Same(baselineTool, originalTools[0]); + Assert.Same(originalTools, sharedOptions.Tools); + Assert.Same(baselineTool, originalTools[0]); + } + + [Fact] + public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync() + { + // Arrange + var toolCountsSeenByInner = new List(); + + var innerClient = CreateMockStreamingChatClient( + onGetStreamingResponse: (_, options, _) => + { + toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0); + return ToAsyncEnumerableAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Response")); + }); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var sharedOptions = new ChatOptions + { + Tools = new List { new TestAITool() } + }; + + // Act: make 3 streaming calls reusing the same ChatOptions + for (int i = 0; i < 3; i++) + { + await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions); + } + + // Assert: each call should see exactly 2 tools (1 baseline + 1 injected) + Assert.Equal(3, toolCountsSeenByInner.Count); + Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count)); + } + + [Fact] + public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync() + { + // Arrange + var innerClient = CreateMockStreamingChatClient( + onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Response"))); + + var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]); + var chatClient = new AIContextProviderChatClient(innerClient, [provider]); + + var baselineTool = new TestAITool(); + var originalTools = new List { baselineTool }; + var sharedOptions = new ChatOptions + { + Tools = originalTools + }; + + // Act + await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions); + + // Assert: the original list should still contain only the baseline tool + Assert.Single(originalTools); + Assert.Same(baselineTool, originalTools[0]); + } + + #endregion + #region Builder Extension Tests [Fact] @@ -341,6 +464,44 @@ public class AIContextProviderChatClientTests await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); } + /// + /// Runs a chat client within an agent context with the specified options. + /// + private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options) + { + var agent = new TestAIAgent + { + RunAsyncFunc = async (messages, session, agentOptions, ct) => + { + var response = await chatClient.GetResponseAsync(messages, options, ct); + return new AgentResponse(response); + } + }; + + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + } + + /// + /// Runs a streaming chat client within an agent context with the specified options. + /// + private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List updates, ChatOptions options) + { + var agent = new TestAIAgent + { + RunAsyncFunc = async (messages, session, agentOptions, ct) => + { + await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct)) + { + updates.Add(update); + } + + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]); + } + }; + + await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession); + } + private static IChatClient CreateMockChatClient( Func, ChatOptions?, CancellationToken, Task> onGetResponse) { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs new file mode 100644 index 0000000000..eb4f706f30 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentFileSkillScriptTests +{ + [Fact] + public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); + var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); + + // Act & Assert + await Assert.ThrowsAsync( + () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync() + { + // Arrange + var runnerCalled = false; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + runnerCalled = true; + return Task.FromResult("executed"); + } + var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A file skill"), + "---\nname: my-skill\n---\nContent", + "/skills/my-skill"); + + // Act + var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(runnerCalled); + Assert.Equal("executed", result); + } + + [Fact] + public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() + { + // Arrange + AgentFileSkill? capturedSkill = null; + AgentFileSkillScript? capturedScript = null; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + capturedSkill = skill; + capturedScript = scriptArg; + return Task.FromResult(null); + } + var script = CreateScript("capture", "/scripts/capture.py", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("owner-skill", "Owner"), + "Content", + "/skills/owner-skill"); + + // Act + await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.Same(fileSkill, capturedSkill); + Assert.Same(script, capturedScript); + } + + [Fact] + public void Script_HasCorrectNameAndPath() + { + // Arrange & Act + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); + + // Assert + Assert.Equal("my-script", script.Name); + Assert.Equal("/path/to/my-script.py", script.FullPath); + } + + /// + /// Helper to create an via reflection since the constructor is internal. + /// + private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor) + { + var ctor = typeof(AgentFileSkillScript).GetConstructor( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + null, + [typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)], + null) ?? throw new InvalidOperationException("Could not find internal constructor."); + + return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs new file mode 100644 index 0000000000..ef8f7780a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for script discovery and execution in . +/// +public sealed class AgentFileSkillsSourceScriptTests : IDisposable +{ + private static readonly string[] s_rubyExtension = new[] { ".rb" }; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + + private readonly string _testRoot; + + public AgentFileSkillsSourceScriptTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var skill = skills[0]; + Assert.NotNull(skill.Scripts); + Assert.Single(skill.Scripts!); + Assert.Equal("scripts/convert.py", skill.Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.sh", "echo 'sh'"); + CreateFile(skillDir, "scripts/run.js", "console.log('js')"); + CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'"); + CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();"); + CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(6, scriptNames.Count); + Assert.Contains("scripts/run.cs", scriptNames); + Assert.Contains("scripts/run.csx", scriptNames); + Assert.Contains("scripts/run.js", scriptNames); + Assert.Contains("scripts/run.ps1", scriptNames); + Assert.Contains("scripts/run.py", scriptNames); + Assert.Contains("scripts/run.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body."); + CreateFile(skillDir, "scripts/data.txt", "text data"); + CreateFile(skillDir, "scripts/config.json", "{}"); + CreateFile(skillDir, "scripts/notes.md", "# Notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync() + { + // Arrange + CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Scripts); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync() + { + // Arrange — scripts at any depth in the skill directory are discovered + string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); + CreateFile(skillDir, "convert.py", "print('root')"); + CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(2, scriptNames.Count); + Assert.Contains("convert.py", scriptNames); + Assert.Contains("tools/helper.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')"); + var executorCalled = false; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + executorCalled = true; + Assert.Equal("exec-skill", skill.Frontmatter.Name); + Assert.Equal("scripts/test.py", script.Name); + Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath); + return Task.FromResult("executed"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(executorCalled); + Assert.Equal("executed", scriptResult); + } + + [Fact] + public void Constructor_NullExecutor_DoesNotThrow() + { + // Arrange & Act & Assert — null runner is allowed when skills have no scripts + var source = new AgentFileSkillsSource(this._testRoot, null); + Assert.NotNull(source); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body."); + CreateFile(skillDir, "scripts/run.sh", "echo 'hello'"); + var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null); + + // Act — discovery succeeds even without a runner + var skills = await source.GetSkillsAsync(CancellationToken.None); + var script = skills[0].Scripts![0]; + + // Assert — running the script throws because no runner was provided + await Assert.ThrowsAsync(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.rb", "puts 'rb'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Single(skills[0].Scripts!); + Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')"); + AIFunctionArguments? capturedArgs = null; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + capturedArgs = args; + return Task.FromResult("done"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var arguments = new AIFunctionArguments + { + ["value"] = 26.2, + ["factor"] = 1.60934 + }; + await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None); + + // Assert + Assert.NotNull(capturedArgs); + Assert.Equal(26.2, capturedArgs["value"]); + Assert.Equal(1.60934, capturedArgs["factor"]); + } + + private static string CreateSkillDir(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + return skillDir; + } + + private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent) + { + string skillDir = CreateSkillDir(root, name, description, body); + CreateFile(skillDir, scriptRelativePath, scriptContent); + } + + private static void CreateFile(string root, string relativePath, string content) + { + string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs new file mode 100644 index 0000000000..c0f8412655 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for validation. +/// +public sealed class AgentSkillFrontmatterValidatorTests +{ + [Theory] + [InlineData("my-skill")] + [InlineData("a")] + [InlineData("skill123")] + [InlineData("a1b2c3")] + public void ValidateName_ValidName_ReturnsTrue(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Theory] + [InlineData("-leading-hyphen")] + [InlineData("trailing-hyphen-")] + [InlineData("has spaces")] + [InlineData("UPPERCASE")] + [InlineData("consecutive--hyphens")] + [InlineData("special!chars")] + public void ValidateName_InvalidName_ReturnsFalse(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateName_NameExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longName = new('a', 65); + + // Act + bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateDescription_ValidDescription_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longDesc = new('x', 1025); + + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description) + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateCompatibility_Null_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_WithinMaxLength_ReturnsTrue() + { + // Arrange + string compatibility = new('x', 500); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse() + { + // Arrange + string compatibility = new('x', 501); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData("UPPERCASE")] + [InlineData("-leading")] + [InlineData("trailing-")] + [InlineData("consecutive--hyphens")] + public void Constructor_InvalidName_ThrowsArgumentException(string name) + { + // Act & Assert + var ex = Assert.Throws(() => new AgentSkillFrontmatter(name, "A valid description.")); + Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_NameExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longName = new('a', 65); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(longName, "A valid description.")); + } + + [Fact] + public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longDesc = new('x', 1025); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", longDesc)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(name!, "A valid description.")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", description!)); + } + + [Fact] + public void Compatibility_ExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => frontmatter.Compatibility = longCompatibility); + } + + [Fact] + public void Compatibility_WithinMaxLength_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string compatibility = new('x', 500); + + // Act + frontmatter.Compatibility = compatibility; + + // Assert + Assert.Equal(compatibility, frontmatter.Compatibility); + } + + [Fact] + public void Compatibility_Null_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + + // Act + frontmatter.Compatibility = null; + + // Assert + Assert.Null(frontmatter.Compatibility); + } + + [Fact] + public void Constructor_WithCompatibility_SetsValue() + { + // Arrange & Act + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+"); + + // Assert + Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility); + } + + [Fact] + public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs new file mode 100644 index 0000000000..85335256a7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillsProviderBuilderTests +{ + private readonly TestAIAgent _agent = new(); + + private AIContextProvider.InvokingContext CreateInvokingContext() + { + return new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + } + + [Fact] + public void Build_NoSourceConfigured_Succeeds() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Build_WithCustomSource_Succeeds() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("custom", "Custom skill", "Instructions.")); + var builder = new AgentSkillsProviderBuilder() + .UseSource(source); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void UseSource_NullSource_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseSource(null!)); + } + + [Fact] + public void UseFilter_NullPredicate_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFilter(null!)); + } + + [Fact] + public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFileScriptRunner(null!)); + } + + [Fact] + public void UseOptions_NullConfigure_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseOptions(null!)); + } + + [Fact] + public async Task Build_WithFilter_AppliesFilterToSkillsAsync() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseFilter(skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert — the instructions should mention "keep-me" but not "drop-me" + Assert.NotNull(result.Instructions); + Assert.Contains("keep-me", result.Instructions); + Assert.DoesNotContain("drop-me", result.Instructions); + } + + [Fact] + public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should be called each time (dedup still calls through) + Assert.True(countingSource.CallCount >= 2); + } + + [Fact] + public async Task Build_WithCacheEnabled_CachesSkillsAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should only be called once due to caching + Assert.Equal(1, countingSource.CallCount); + } + + [Fact] + public void Build_FluentChaining_ReturnsSameBuilder() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — all fluent methods should return the same builder + var result = builder + .UseSource(source) + .UseScriptApproval(false) + .UsePromptTemplate("Skills:\n{skills}\n{resource_instructions}\n{script_instructions}"); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void Build_UseOptions_ConfiguresOptions() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — UseOptions should not throw and successfully configure + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(opts => opts.ScriptApproval = true) + .Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public async Task Build_WithMultipleCustomSources_AggregatesAllAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource( + new TestAgentSkill("from-one", "Source 1", "Instructions 1.")); + var source2 = new TestAgentSkillsSource( + new TestAgentSkill("from-two", "Source 2", "Instructions 2.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source1) + .UseSource(source2) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("from-one", result.Instructions); + Assert.Contains("from-two", result.Instructions); + } + + /// + /// A test source that counts how many times GetSkillsAsync is called. + /// + private sealed class CountingSource : AgentSkillsSource + { + private readonly AgentSkill[] _skills; + private int _callCount; + + public CountingSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + public int CallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult>(this._skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs new file mode 100644 index 0000000000..6dfc45918a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -0,0 +1,765 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class with . +/// +public sealed class AgentSkillsProviderTests : IDisposable +{ + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + + public AgentSkillsProviderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() + { + // Arrange + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Original instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("Original instructions", result.Instructions); + Assert.Null(result.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() + { + // Arrange + this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Base instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("Base instructions", result.Instructions); + Assert.Contains("provider-skill", result.Instructions); + Assert.Contains("Provider skill test", result.Instructions); + + // Should have load_skill tool (no resources, so no read_skill_resource) + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.DoesNotContain("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() + { + // Arrange + this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("null-instr-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() + { + // Arrange + this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Custom template: {skills}\n{resource_instructions}\n{script_instructions}" + }; + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.StartsWith("Custom template:", result.Instructions); + Assert.Contains("custom-prompt-skill", result.Instructions); + Assert.Contains("Custom prompt", result.Instructions); + } + + [Fact] + public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "No skills placeholder here {resource_instructions} {script_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{skills}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} but no runner instructions {resource_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{script_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithBothPlaceholders_Succeeds() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Skills: {skills}\nResources: {resource_instructions}\nRunner: {script_instructions}" + }; + + // Act — should not throw + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Constructor_PromptWithoutResourceInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} and runner {script_instructions} but no resource instructions" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{resource_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() + { + // Arrange — description with XML-sensitive characters + string skillDir = Path.Combine(this._testRoot, "xml-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("<tags>", result.Instructions); + Assert.Contains("&", result.Instructions); + } + + [Fact] + public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + // Act + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor)); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Assert + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() + { + // Arrange + this.CreateSkill("tools-skill", "Tools test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + + var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); + var inputContext = new AIContext { Tools = new[] { existingTool } }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — existing tool should be preserved alongside the new skill tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("existing_tool", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() + { + // Arrange — create skills in reverse alphabetical order + this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); + this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); + this.CreateSkill("mike-skill", "Mike skill", "Body M."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — skills should appear in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); + int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); + int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); + Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); + Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); + } + + [Fact] + public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.") + ]); + var provider = new AgentSkillsProvider(source); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act — invoke concurrently from multiple threads + var tasks = Enumerable.Range(0, 10) + .Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask()) + .ToArray(); + await Task.WhenAll(tasks); + + // Assert — GetSkillsAsync should have been called exactly once (provider-level caching) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "script-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: script-skill\ndescription: Skill with scripts\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "test.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync() + { + // Arrange + this.CreateSkill("no-script-skill", "No scripts", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.DoesNotContain("run_skill_script", toolNames); + } + + [Fact] + public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot); + + // Act & Assert + Assert.Throws(() => builder.Build()); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync() + { + // Arrange + this.CreateSkill("opts-skill", "Options skill", "Options body."); + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("opts-skill", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "multi-opts-1"); + string dir2 = Path.Combine(this._testRoot, "multi-opts-2"); + CreateSkillIn(dir1, "skill-x", "Skill X", "Body X."); + CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y."); + + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkills(new[] { dir1, dir2 }, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-x", result.Instructions); + Assert.Contains("skill-y", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync() + { + // Arrange — create a skill with both .md and .json resources + string skillDir = Path.Combine(this._testRoot, "res-filter-opts"); + CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body."); + File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8); + File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8); + + // Only allow .json resources + var options = new AgentFileSkillsSourceOptions + { + AllowedResourceExtensions = [".json"], + }; + var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fileSkill = Assert.IsType(skills[0]); + Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name)); + } + + private void CreateSkill(string name, string description, string body) + { + CreateSkillIn(this._testRoot, name, description, body); + } + + [Fact] + public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync() + { + // Arrange + this.CreateSkill("content-skill", "Content test", "Skill body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + + // Act + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "content-skill" })); + + // Assert — should contain frontmatter and body + var text = content!.ToString()!; + Assert.Contains("---", text); + Assert.Contains("name: content-skill", text); + Assert.Contains("Skill body.", text); + } + + [Fact] + public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync() + { + // Arrange — create a skill with a script file + string skillDir = Path.Combine(this._testRoot, "builder-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: builder-skill\ndescription: Builder test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('ok')"); + + var executorCalled = false; + + // Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario) + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot) + .UseFileScriptRunner((skill, script, args, ct) => + { + executorCalled = true; + return Task.FromResult("executed"); + }) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be present and executor should work + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + + var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction; + await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary + { + ["skillName"] = "builder-skill", + ["scriptName"] = "scripts/run.py", + })); + + Assert.True(executorCalled); + } + + private static void CreateSkillIn(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } + + [Fact] + public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("no-cache-skill", "No cache test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called more than once since caching is disabled + Assert.True(source.GetSkillsCallCount > 1); + } + + [Fact] + public async Task Build_WithCachingEnabled_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("cached-skill", "Cached test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called exactly once (caching is on by default) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task Build_DefaultOptions_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("default-skill", "Default test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — default behavior caches + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync() + { + // Arrange — create a skill with a script and enable ScriptApproval + string skillDir = Path.Combine(this._testRoot, "approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: approval-skill\ndescription: Approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { ScriptApproval = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be wrapped in ApprovalRequiredAIFunction + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunScriptToolAsync() + { + // Arrange — create a skill with a script, default options (no approval) + string skillDir = Path.Combine(this._testRoot, "no-approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: no-approval-skill\ndescription: No approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should NOT be wrapped + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsNotType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreSharedWhenCachedAsync() + { + // Arrange — with default caching, tools should be the same reference + this.CreateSkill("cached-tools-skill", "Cached tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should be the same reference (cached) + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedWhenCachingDisabledAsync() + { + // Arrange — with caching disabled, tools should be rebuilt per invocation + this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { DisableCaching = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should not be the same reference + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.NotSame(result1.Tools, result2.Tools); + } + + [Fact] + public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync() + { + // Arrange + this.CreateSkill("file-ctor-skill", "File ctor test", "File body."); + var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("file-ctor-skill", result.Instructions); + Assert.NotNull(result.Tools); + Assert.Contains(result.Tools!, t => t.Name == "load_skill"); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync() + { + // Arrange — same skill name in two directories + string dir1 = Path.Combine(this._testRoot, "dup1"); + string dir2 = Path.Combine(this._testRoot, "dup2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "dup-skill", "First", "Body 1."); + CreateSkillIn(dir2, "dup-skill", "Second", "Body 2."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-skill" })); + + // Assert — only first occurrence should survive + Assert.NotNull(content); + Assert.Contains("Body 1.", content!.ToString()!); + } + + /// + /// A test skill source that counts how many times is called. + /// + private sealed class CountingAgentSkillsSource : AgentSkillsSource + { + private readonly IList _skills; + private int _callCount; + + public CountingAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + public int GetSkillsCallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(this._skills); + } + } + + private sealed class TestAgentSkill : AgentSkill + { + private readonly string _content; + + public TestAgentSkill(string name, string description, string content) + { + this.Frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + public override AgentSkillFrontmatter Frontmatter { get; } + + public override string Content => this._content; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..860f402005 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class DeduplicatingAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() + { + // Arrange + var skills = new AgentSkill[] + { + new TestAgentSkill("dupe", "First", "Instructions 1."), + new TestAgentSkill("dupe", "Second", "Instructions 2."), + new TestAgentSkill("unique", "Unique", "Instructions 3."), + }; + var inner = new TestAgentSkillsSource(skills); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description); + Assert.Contains(result, s => s.Frontmatter.Name == "unique"); + } + + [Fact] + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() + { + // Arrange — use a custom source that returns skills with same name but different casing + var inner = new FakeDuplicateCaseSource(); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal("First", result[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(System.Array.Empty()); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + /// + /// A fake source that returns skills with names differing only by case. + /// + private sealed class FakeDuplicateCaseSource : AgentSkillsSource + { + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + // AgentSkillFrontmatter validates names must be lowercase, so we build + // two skills with the same lowercase name to test case-insensitive dedup. + var skills = new List + { + new TestAgentSkill("my-skill", "First", "Instructions 1."), + new TestAgentSkill("my-skill", "Second", "Instructions 2."), + }; + return Task.FromResult>(skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index 6134b04feb..e9dc2e0358 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -4,25 +4,25 @@ using System; using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for the class. +/// Unit tests for the skill discovery and parsing logic. /// public sealed class FileAgentSkillLoaderTests : IDisposable { - private static readonly string[] s_traversalResource = new[] { "../secret.txt" }; + private static readonly string[] s_customExtensions = [".custom"]; + private static readonly string[] s_validExtensions = [".md", ".json", ".custom"]; + private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"]; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; - private readonly FileAgentSkillLoader _loader; public FileAgentSkillLoaderTests() { this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(this._testRoot); - this._loader = new FileAgentSkillLoader(NullLogger.Instance); } public void Dispose() @@ -34,23 +34,23 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill() + public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync() { // Arrange _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("my-skill")); - Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description); - Assert.Equal("Use this skill to do things.", skills["my-skill"].Body); + Assert.Equal("my-skill", skills[0].Frontmatter.Name); + Assert.Equal("A test skill", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() + public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "quoted-skill"); @@ -58,33 +58,35 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name); - Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description); + Assert.Equal("quoted-skill", skills[0].Frontmatter.Name); + Assert.Equal("A quoted description", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill() + public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "bad-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() + public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-name"); @@ -92,16 +94,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\ndescription: A skill without a name\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() + public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-desc"); @@ -109,9 +112,10 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: no-desc\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); @@ -123,7 +127,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [InlineData("trailing-hyphen-")] [InlineData("has spaces")] [InlineData("consecutive--hyphens")] - public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) + public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName) { // Arrange string skillDir = Path.Combine(this._testRoot, invalidName); @@ -136,16 +140,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {invalidName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() + public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync() { // Arrange string dir1 = Path.Combine(this._testRoot, "dupe"); @@ -162,34 +167,37 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: dupe\ndescription: Second\n---\nSecond body."); + var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var source = new DeduplicatingAgentSkillsSource(fileSource); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert – filesystem enumeration order is not guaranteed, so we only // verify that exactly one of the two duplicates was kept. Assert.Single(skills); - string desc = skills["dupe"].Frontmatter.Description; + string desc = skills[0].Frontmatter.Description; Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}"); } [Fact] - public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill() + public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync() { // Arrange — directory name differs from the frontmatter name _ = this.CreateSkillDirectoryWithRawContent( "wrong-dir-name", "---\nname: actual-skill-name\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources() + public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { // Arrange — create resource files in the skill directory string skillDir = Path.Combine(this._testRoot, "resource-skill"); @@ -200,20 +208,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered() + public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { // Arrange — create a file with an extension not in the default list string skillDir = Path.Combine(this._testRoot, "ext-skill"); @@ -223,19 +232,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: ext-skill\ndescription: Extension test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.json", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.json", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource() + public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync() { // Arrange — the SKILL.md file itself should not be in the resource list string skillDir = Path.Combine(this._testRoot, "selfref-skill"); @@ -244,19 +254,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: selfref-skill\ndescription: Self ref test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["selfref-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("notes.md", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("notes.md", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered() + public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync() { // Arrange — resource files in nested subdirectories string skillDir = Path.Combine(this._testRoot, "nested-res-skill"); @@ -266,26 +277,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["nested-res-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); } - private static readonly string[] s_customExtensions = new[] { ".custom" }; - private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" }; - private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" }; - [Fact] - public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() + public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a loader with custom extensions - var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions); + // Arrange — use a source with custom extensions string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data"); @@ -293,15 +300,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions }); // Act - var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — only .custom files should be discovered, not .json Assert.Single(skills); - var skill = skills["custom-ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.custom", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.custom", skill.Resources![0].Name); } [Theory] @@ -311,39 +319,39 @@ public sealed class FileAgentSkillLoaderTests : IDisposable public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension) { // Arrange & Act & Assert - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } })); } [Fact] - public void Constructor_NullExtensions_UsesDefaults() + public async Task Constructor_NullExtensions_UsesDefaultsAsync() { // Arrange & Act - var loader = new FileAgentSkillLoader(NullLogger.Instance, null); string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body."); File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Assert — default extensions include .md - var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - Assert.Single(skills["null-ext"].ResourceNames); + var skills = await source.GetSkillsAsync(); + Assert.Single(skills[0].Resources!); } [Fact] public void Constructor_ValidExtensions_DoesNotThrow() { // Arrange & Act & Assert — should not throw - var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions); - Assert.NotNull(loader); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions }); + Assert.NotNull(source); } [Fact] public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException() { // Arrange & Act & Assert — one bad extension in the list should cause failure - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions)); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions })); } [Fact] - public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync() { // Arrange — resource file directly in the skill directory (not in a subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); @@ -353,54 +361,62 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: root-resource-skill\ndescription: Root resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — both root-level resource files should be discovered Assert.Single(skills); - var skill = skills["root-resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames() + public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync() { // Arrange — skill with no resource files _ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Empty(skills["no-resources"].ResourceNames); + Assert.Empty(skills[0].Resources!); } [Fact] - public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Enumerable.Empty(), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty()); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit() + public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync() { // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1) string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill"); @@ -408,13 +424,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: nested-skill\ndescription: Nested\n---\nNested body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("nested-skill")); + Assert.Equal("nested-skill", skills[0].Frontmatter.Name); } [Fact] @@ -425,54 +442,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable string refsDir = Path.Combine(skillDir, "refs"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["read-skill"]; + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var skills = await source.GetSkillsAsync(); + var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); // Act - string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + var content = await resource.ReadAsync(); // Assert Assert.Equal("Document content here.", content); } [Fact] - public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["simple-skill"]; - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "unknown.md")); - } - - [Fact] - public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — skill with a legitimate resource, then try to read a traversal path at read time - string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "legit"); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["traverse-read"]; - - // Manually construct a skill with the traversal resource in its list to bypass discovery validation - var tampered = new FileAgentSkill( - skill.Frontmatter, - skill.Body, - skill.SourcePath, - s_traversalResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt")); - } - - [Fact] - public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync() { // Arrange — name longer than 64 characters string longName = new('a', 65); @@ -481,16 +463,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {longName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync() { // Arrange — description longer than 1024 characters string longDesc = new('x', 1025); @@ -499,71 +482,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } - [Fact] - public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with bare path, caller uses ./ prefix - string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["dotslash-read"]; - - // Act — caller passes ./refs/doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md"); - - // Assert - Assert.Equal("Document content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses backslashes - string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Backslash content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["backslash-read"]; - - // Act — caller passes refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md"); - - // Assert - Assert.Equal("Backslash content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes - string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["mixed-sep-read"]; - - // Act — caller passes .\refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md"); - - // Assert - Assert.Equal("Mixed separator content.", content); - } - #if NET [Fact] - public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources() + public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync() { // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); @@ -588,71 +518,179 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — skill should still load, but symlinked resources should be excluded - Assert.True(skills.ContainsKey("symlink-escape-skill")); - var skill = skills["symlink-escape-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("legit.md", skill.ResourceNames[0]); - } - - private static readonly string[] s_symlinkResource = ["refs/data.md"]; - - [Fact] - public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — build a skill with a symlinked subdirectory - string skillDir = Path.Combine(this._testRoot, "symlink-read-skill"); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(skillDir); - - string outsideDir = Path.Combine(this._testRoot, "outside-read"); - Directory.CreateDirectory(outsideDir); - File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data"); - - try - { - Directory.CreateSymbolicLink(refsDir, outsideDir); - } - catch (IOException) - { - // Symlink creation requires elevation on some platforms; skip gracefully. - return; - } - - // Manually construct a skill that bypasses discovery validation - var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); - var skill = new FileAgentSkill( - frontmatter: frontmatter, - body: "See [doc](refs/data.md).", - sourcePath: skillDir, - resourceNames: s_symlinkResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md")); + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill"); + Assert.NotNull(skill); + Assert.Single(skill.Resources!); + Assert.Equal("legit.md", skill.Resources![0].Name); } #endif [Fact] - public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() + public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync() { // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter _ = this.CreateSkillDirectoryWithRawContent( "bom-skill", "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("bom-skill")); - Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description); - Assert.Equal("Body content.", skills["bom-skill"].Body); + Assert.Equal("bom-skill", skills[0].Frontmatter.Name); + Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "licensed-skill", + "---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("MIT", skills[0].Frontmatter.License); + } + + [Fact] + public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "compat-skill", + "---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility); + } + + [Fact] + public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "tools-skill", + "---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools); + } + + [Fact] + public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "meta-skill", + "---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString()); + Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "quoted-meta", + "---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString()); + Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync() + { + // Arrange + string content = string.Join( + "\n", + "---", + "name: full-skill", + "description: A skill with all fields", + "license: Apache-2.0", + "compatibility: Requires Python 3.10+", + "allowed-tools: grep glob view", + "metadata:", + " org: contoso", + " tier: premium", + "---", + "Full body content."); + _ = this.CreateSkillDirectoryWithRawContent("full-skill", content); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Equal("full-skill", fm.Name); + Assert.Equal("A skill with all fields", fm.Description); + Assert.Equal("Apache-2.0", fm.License); + Assert.Equal("Requires Python 3.10+", fm.Compatibility); + Assert.Equal("grep glob view", fm.AllowedTools); + Assert.NotNull(fm.Metadata); + Assert.Equal("contoso", fm.Metadata!["org"]?.ToString()); + Assert.Equal("premium", fm.Metadata!["tier"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() + { + // Arrange + _ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Null(fm.License); + Assert.Null(fm.Compatibility); + Assert.Null(fm.AllowedTools); + Assert.Null(fm.Metadata); } private string CreateSkillDirectory(string name, string description, string body) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs deleted file mode 100644 index 92dc5a5418..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.UnitTests.AgentSkills; - -/// -/// Unit tests for the class. -/// -public sealed class FileAgentSkillsProviderTests : IDisposable -{ - private readonly string _testRoot; - private readonly TestAIAgent _agent = new(); - - public FileAgentSkillsProviderTests() - { - this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(this._testRoot); - } - - public void Dispose() - { - if (Directory.Exists(this._testRoot)) - { - Directory.Delete(this._testRoot, recursive: true); - } - } - - [Fact] - public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() - { - // Arrange - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Original instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.Equal("Original instructions", result.Instructions); - Assert.Null(result.Tools); - } - - [Fact] - public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() - { - // Arrange - this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Base instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("Base instructions", result.Instructions); - Assert.Contains("provider-skill", result.Instructions); - Assert.Contains("Provider skill test", result.Instructions); - - // Should have load_skill and read_skill_resource tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() - { - // Arrange - this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("null-instr-skill", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() - { - // Arrange - this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Custom template: {0}" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.StartsWith("Custom template:", result.Instructions); - Assert.Contains("custom-prompt-skill", result.Instructions); - Assert.Contains("Custom prompt", result.Instructions); - } - - [Fact] - public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() - { - // Arrange — template with unescaped braces and no valid {0} placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Bad template with {unescaped} braces" - }; - - // Act & Assert - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("SkillsInstructionPrompt", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() - { - // Arrange — description with XML-sensitive characters - string skillDir = Path.Combine(this._testRoot, "xml-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("<tags>", result.Instructions); - Assert.Contains("&", result.Instructions); - } - - [Fact] - public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() - { - // Arrange - string dir1 = Path.Combine(this._testRoot, "dir1"); - string dir2 = Path.Combine(this._testRoot, "dir2"); - CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); - CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); - - // Act - var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 }); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Assert - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - Assert.NotNull(result.Instructions); - Assert.Contains("skill-a", result.Instructions); - Assert.Contains("skill-b", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() - { - // Arrange - this.CreateSkill("tools-skill", "Tools test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - - var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); - var inputContext = new AIContext { Tools = new[] { existingTool } }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — existing tool should be preserved alongside the new skill tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("existing_tool", toolNames); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() - { - // Arrange — create skills in reverse alphabetical order - this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); - this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); - this.CreateSkill("mike-skill", "Mike skill", "Body M."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — skills should appear in alphabetical order in the prompt - Assert.NotNull(result.Instructions); - int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); - int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); - int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); - Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); - Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); - } - - private void CreateSkill(string name, string description, string body) - { - CreateSkillIn(this._testRoot, name, description, body); - } - - private static void CreateSkillIn(string root, string name, string description, string body) - { - string skillDir = Path.Combine(root, name); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - $"---\nname: {name}\ndescription: {description}\n---\n{body}"); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..de145004e0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class FilteringAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => false); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions."), + new TestAgentSkill("keep-also", "KeepAlso", "Instructions.")); + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name)); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void Constructor_NullPredicate_Throws() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(inner, null!)); + } + + [Fact] + public void Constructor_NullInnerSource_Throws() + { + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(null!, _ => true)); + } + + [Fact] + public async Task GetSkillsAsync_PreservesOrderAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("alpha", "Alpha", "Instructions."), + new TestAgentSkill("beta", "Beta", "Instructions."), + new TestAgentSkill("gamma", "Gamma", "Instructions."), + new TestAgentSkill("delta", "Delta", "Instructions.")); + + // Keep only alpha and gamma + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name is "alpha" or "gamma"); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("alpha", result[0].Frontmatter.Name); + Assert.Equal("gamma", result[1].Frontmatter.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs new file mode 100644 index 0000000000..8c97a31ae4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkill : AgentSkill +{ + private readonly AgentSkillFrontmatter _frontmatter; + private readonly string _content; + + /// + /// Initializes a new instance of the class. + /// + /// Kebab-case skill name. + /// Skill description. + /// Full skill content (body text). + public TestAgentSkill(string name, string description, string content) + { + this._frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + /// + public override AgentSkillFrontmatter Frontmatter => this._frontmatter; + + /// + public override string Content => this._content; + + /// + public override IReadOnlyList? Resources => null; + + /// + public override IReadOnlyList? Scripts => null; +} + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkillsSource : AgentSkillsSource +{ + private readonly IList _skills; + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(this._skills); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs new file mode 100644 index 0000000000..a3d2bd0c6a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Shared test helper for integration tests that verify +/// end-to-end behavior with and +/// . +/// +internal static class ChatClientAgentTestHelper +{ + /// + /// Represents an expected service call during a test: an optional input verifier and the response to return. + /// + /// The the mock service should return for this call. + /// Optional callback to verify the messages sent to the service on this call. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ServiceCallExpectation( + ChatResponse Response, + Action>? VerifyInput = null); +#pragma warning restore CA1812 + + /// + /// Describes the expected shape of a message in the persisted history for structural comparison. + /// + /// The expected role of the message. + /// Optional substring that the message text should contain. + /// Optional array of expected types in the message. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ExpectedMessage( + ChatRole Role, + string? TextContains = null, + Type[]? ContentTypes = null); +#pragma warning restore CA1812 + + /// + /// The result of a RunAsync invocation, containing the response, session, agent, + /// captured service inputs, and call counts for detailed verification. + /// + public sealed record RunResult( + AgentResponse Response, + ChatClientAgentSession Session, + ChatClientAgent Agent, + Mock MockService, + int TotalServiceCalls, + List> CapturedServiceInputs); + + /// + /// Creates a mock that returns responses in sequence, + /// captures input messages, and optionally verifies inputs. + /// + /// The ordered sequence of expected service calls. + /// Shared call index counter (allows reuse across multiple RunAsync calls). + /// List that captured service inputs are appended to. + /// The configured mock. + public static Mock CreateSequentialMock( + List expectations, + Ref callIndex, + List> capturedInputs) + { + Mock mock = new(); + mock.Setup(s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ChatOptions?, CancellationToken>((msgs, _, _) => + { + int idx = callIndex.Value++; + var messageList = msgs.ToList(); + capturedInputs.Add(messageList); + + if (idx >= expectations.Count) + { + throw new InvalidOperationException( + $"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected."); + } + + var expectation = expectations[idx]; + expectation.VerifyInput?.Invoke(messageList); + return Task.FromResult(expectation.Response); + }); + return mock; + } + + /// + /// Runs the agent with the given inputs, automatically verifying service call count + /// and optional expected history, and returns the result for further assertions. + /// + /// Messages to pass to RunAsync. + /// Ordered service call expectations for the mock. + /// Options for configuring the agent. If null, defaults are used. + /// An existing session to reuse (for multi-turn tests). If null, a new session is created. + /// An existing agent to reuse (for multi-turn tests). If null, a new agent is created. + /// An existing mock to reuse (for multi-turn tests). If null, a new mock is created. + /// Shared call index for multi-turn tests. If null, a new counter is created. + /// Shared captured inputs list for multi-turn tests. If null, a new list is created. + /// Optional initial chat history to pre-populate in . + /// Optional to pass to RunAsync. + /// + /// If provided, asserts the total number of service calls matches. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// + /// If provided, asserts that the persisted history matches these expected messages. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// A containing the response, session, agent, mock, and captured inputs. + public static async Task RunAsync( + List inputMessages, + List serviceCallExpectations, + ChatClientAgentOptions? agentOptions = null, + ChatClientAgentSession? existingSession = null, + ChatClientAgent? existingAgent = null, + Mock? existingMock = null, + Ref? callIndex = null, + List>? capturedInputs = null, + List? initialChatHistory = null, + AgentRunOptions? runOptions = null, + int? expectedServiceCallCount = null, + List? expectedHistory = null) + { + callIndex ??= new Ref(0); + capturedInputs ??= []; + var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs); + agentOptions ??= new ChatClientAgentOptions(); + + var agent = existingAgent ?? new ChatClientAgent( + mock.Object, + options: agentOptions, + services: new ServiceCollection().BuildServiceProvider()); + + var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!; + + // Pre-populate initial chat history if provided. + if (initialChatHistory is not null) + { + (agent.ChatHistoryProvider as InMemoryChatHistoryProvider) + ?.SetMessages(session, new List(initialChatHistory)); + } + + var response = await agent.RunAsync(inputMessages, session, runOptions); + + var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs); + + // Auto-verify service call count if specified. + if (expectedServiceCallCount.HasValue) + { + Assert.Equal(expectedServiceCallCount.Value, callIndex.Value); + } + + // Auto-verify persisted history if specified. + if (expectedHistory is not null) + { + var history = GetPersistedHistory(agent, session); + AssertMessagesMatch(history, expectedHistory); + } + + return result; + } + + /// + /// Asserts that the actual message list matches the expected message patterns structurally. + /// Checks message count, roles, optional text content, and optional content types. + /// + /// The actual messages to verify. + /// The expected message patterns. + public static void AssertMessagesMatch(List actual, List expected) + { + Assert.True( + expected.Count == actual.Count, + $"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}"); + + for (int i = 0; i < expected.Count; i++) + { + var exp = expected[i]; + var act = actual[i]; + + Assert.True( + exp.Role == act.Role, + $"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}"); + + if (exp.TextContains is not null) + { + Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal); + } + + if (exp.ContentTypes is not null) + { + AssertContentTypes(act.Contents, exp.ContentTypes, i); + } + } + } + + /// + /// Gets the persisted chat history from the agent's . + /// + /// The agent whose history provider to query. + /// The session to get history for. + /// The list of persisted messages, or an empty list if no provider is available. + public static List GetPersistedHistory(ChatClientAgent agent, AgentSession session) + { + var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider; + return provider?.GetMessages(session) ?? []; + } + + /// + /// Formats the contents of a message list as a diagnostic string for test failure messages. + /// + /// The messages to format. + /// A human-readable representation of the messages. + public static string FormatMessages(IEnumerable messages) + { + var sb = new StringBuilder(); + int index = 0; + foreach (var msg in messages) + { + sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]"); + index++; + } + + return sb.ToString(); + } + + /// + /// A simple mutable reference wrapper for value types, allowing shared state across callbacks. + /// + public sealed class Ref(T value) where T : struct + { + public T Value { get; set; } = value; + } + + /// + /// Asserts that a message's content collection contains the expected content types. + /// + private static void AssertContentTypes(IList contents, Type[] expectedTypes, int messageIndex) + { + Assert.True( + contents.Count >= expectedTypes.Length, + $"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " + + $"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + + foreach (var expectedType in expectedTypes) + { + Assert.True( + contents.Any(c => expectedType.IsInstanceOfType(c)), + $"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 2b3cfe43e8..7241ca763e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -379,18 +379,23 @@ public partial class ChatClientAgentTests } /// - /// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions. + /// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions. + /// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions + /// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client. /// [Fact] - public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync() + public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync() { // Arrange + ChatOptions? capturedOptions = null; Mock mockService = new(); mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), - null, - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); ChatClientAgent agent = new(mockService.Object); var runOptions = new AgentRunOptions(); @@ -398,13 +403,9 @@ public partial class ChatClientAgentTests // Act await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions); - // Assert - mockService.Verify( - x => x.GetResponseAsync( - It.IsAny>(), - null, - It.IsAny()), - Times.Once); + // Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped) + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs new file mode 100644 index 0000000000..6300942c9d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests that verify the end-to-end approval flow behavior of the +/// class with , +/// ensuring that chat history is correctly persisted across multi-turn approval interactions. +/// +public class ChatClientAgent_ApprovalsTests +{ + #region Per-Service-Call Persistence Approval Tests + + /// + /// Verifies that with per-service-call persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history: + /// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller. + /// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again. + /// Final history: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model returns a function call (FICC will convert to approval request) + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after approval, FICC invokes the function and calls the model again + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1: initial request + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + // Verify Turn 1 returns exactly one approval request + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(1, result1.TotalServiceCalls); + + // Verify service received user message on first call + Assert.Single(capturedInputs); + Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + + // Act — Turn 2: send approval response + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + + // Verify second service call received the full conversation (user + FCC + FRC) + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + } + + #endregion + + #region End-of-Run Persistence Approval Tests + + /// + /// Verifies that with end-of-run persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = true, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + // End-of-run persistence retains the approval request from Turn 1 + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + #endregion + + #region Service-Stored History Approval Tests + + /// + /// Verifies that with service-stored history (ConversationId returned) and an approval-required tool, + /// the two-turn approval flow completes without errors and the session gets the ConversationId. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync() + { + // Arrange + const string ConversationId = "thread-456"; + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])]) + { + ConversationId = ConversationId, + }), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")]) + { + ConversationId = ConversationId, + }), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(ConversationId, result1.Session.ConversationId); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — session should retain the ConversationId, response should be correct + Assert.Equal(ConversationId, result2.Session.ConversationId); + Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C."); + } + + #endregion + + #region Approval Rejected Tests + + /// + /// Verifies that when an approval is rejected, the rejection result is persisted in the history + /// and the model receives the rejection information. + /// + [Fact] + public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model requests function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after rejection, model gets the rejection info and responds accordingly + new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2: reject the approval + var rejectionMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: rejectionMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — history should contain the rejection result (FRC with rejection) + var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session); + Assert.True( + history.Count >= 3, + $"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}"); + Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + Assert.Contains(history, m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + Assert.Contains(history, m => m.Role == ChatRole.Assistant && + m.Text == "I'm sorry, I cannot check the weather without your approval."); + + // Verify the second service call received the rejection FRC + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs index cc9b7acb19..3e54dbc06e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -500,4 +500,158 @@ public class ChatClientAgent_ChatHistoryManagementTests } #endregion + + #region End-to-End Chat History Persistence Tests + + /// + /// Verifies that with per-service-call persistence (default), a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with per-service-call persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + // First call: model requests a function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Second call: model returns final response after seeing function result + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that with end-of-run persistence, a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with end-of-run persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that when the service returns a ConversationId (service-stored history), + /// the session gets the ConversationId and no errors occur during the run. + /// + [Fact] + public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync() + { + // Arrange & Act + var result = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1); + + // Assert — session should have the conversation id from the service + Assert.Equal("thread-123", result.Session.ConversationId); + Assert.Contains(result.Response.Messages, m => m.Text == "Hi there"); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs index 6dda0f0278..28d38ea36a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs @@ -176,10 +176,12 @@ public class ChatClientAgent_ChatOptionsMergingTests } /// - /// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions. + /// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId + /// when both agent and request have no ChatOptions. The sentinel conversation ID is set for + /// per-service-call persistence and stripped before reaching the inner client. /// [Fact] - public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync() + public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync() { // Arrange Mock mockService = new(); @@ -189,7 +191,7 @@ public class ChatClientAgent_ChatOptionsMergingTests It.IsAny>(), It.IsAny(), It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + .Callback, ChatOptions?, CancellationToken>((msgs, opts, ct) => capturedChatOptions = opts) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); @@ -199,8 +201,9 @@ public class ChatClientAgent_ChatOptionsMergingTests // Act await agent.RunAsync(messages); - // Assert - Assert.Null(capturedChatOptions); + // Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped) + Assert.NotNull(capturedChatOptions); + Assert.Null(capturedChatOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs new file mode 100644 index 0000000000..e7f91ab5d7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs @@ -0,0 +1,933 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests for the decorator, +/// verifying that it persists messages via the after each +/// individual service call by default, or marks messages for end-of-run persistence when the +/// option is enabled. +/// +public class ChatHistoryPersistingChatClientTests +{ + /// + /// Verifies that by default (PersistChatHistoryAtEndOfRun is false), + /// the ChatHistoryProvider receives messages after a successful non-streaming call. + /// + [Fact] + public async Task RunAsync_PersistsMessagesPerServiceCall_ByDefaultAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — InvokedCoreAsync should be called by the decorator (per service call) + mockChatHistoryProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.RequestMessages.Any(m => m.Text == "test") && + x.ResponseMessages!.Any(m => m.Text == "response")), + ItExpr.IsAny()); + } + + /// + /// Verifies that when per-service-call persistence is active (default), + /// the ChatHistoryProvider receives messages at the end of the run. + /// + [Fact] + public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionEnabledAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = true, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — InvokedCoreAsync should be called once by the agent (end of run) + mockChatHistoryProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.RequestMessages.Any(m => m.Text == "test") && + x.ResponseMessages!.Any(m => m.Text == "response")), + ItExpr.IsAny()); + } + + /// + /// Verifies that when per-service-call persistence is active (default) and the service call fails, + /// the ChatHistoryProvider is notified with the exception. + /// + [Fact] + public async Task RunAsync_NotifiesProviderOfFailure_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Service failed"); + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ThrowsAsync(expectedException); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session)); + + // Assert — the decorator should have notified the provider of the failure + mockChatHistoryProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.InvokeException != null && + x.InvokeException.Message == "Service failed"), + ItExpr.IsAny()); + } + + /// + /// Verifies that the decorator is injected in persist mode by default + /// and can be discovered via GetService. + /// + [Fact] + public void ChatClient_ContainsDecorator_InPersistMode_ByDefault() + { + // Arrange + Mock mockService = new(); + + // Act + ChatClientAgent agent = new(mockService.Object, options: new()); + + // Assert + var decorator = agent.ChatClient.GetService(); + Assert.NotNull(decorator); + Assert.False(decorator.MarkOnly); + } + + /// + /// Verifies that the decorator is injected in mark-only mode when PersistChatHistoryAtEndOfRun is true. + /// + [Fact] + public void ChatClient_ContainsDecorator_InMarkOnlyMode_WhenPersistAtEndOfRun() + { + // Arrange + Mock mockService = new(); + + // Act + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = true, + }); + + // Assert + var decorator = agent.ChatClient.GetService(); + Assert.NotNull(decorator); + Assert.True(decorator.MarkOnly); + } + + /// + /// Verifies that the decorator is NOT injected when UseProvidedChatClientAsIs is true. + /// + [Fact] + public void ChatClient_DoesNotContainDecorator_WhenUseProvidedChatClientAsIs() + { + // Arrange + Mock mockService = new(); + + // Act + ChatClientAgent agent = new(mockService.Object, options: new() + { + UseProvidedChatClientAsIs = true, + }); + + // Assert + var decorator = agent.ChatClient.GetService(); + Assert.Null(decorator); + } + + /// + /// Verifies that the PersistChatHistoryAtEndOfRun option is included in Clone(). + /// + [Fact] + public void ChatClientAgentOptions_Clone_IncludesPersistChatHistoryAtEndOfRun() + { + // Arrange + var options = new ChatClientAgentOptions + { + PersistChatHistoryAtEndOfRun = true, + }; + + // Act + var cloned = options.Clone(); + + // Assert + Assert.True(cloned.PersistChatHistoryAtEndOfRun); + } + + /// + /// Verifies that when per-service-call persistence is active (default) and the service call + /// involves a function invocation loop, the ChatHistoryProvider is called after each individual + /// service call (not just once at the end). + /// + [Fact] + public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + // First call returns a tool call + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])])); + } + + // Second call returns a final response + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")])); + }); + + var invokedContexts = new List(); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx)) + .Returns(() => new ValueTask()); + + // Define a simple tool + var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Tools = [tool] }, + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = false, + }, services: new ServiceCollection().BuildServiceProvider()); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + Exception? caughtException = null; + try + { + await agent.RunAsync([new(ChatRole.User, "test")], session); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Diagnostic: check if there was an unexpected exception + Assert.Null(caughtException); + + // Assert — the decorator should have been called twice (once per service call in the function invocation loop) + Assert.Equal(2, serviceCallCount); + Assert.Equal(2, invokedContexts.Count); + + // First invocation should have the user message as request and tool call response + Assert.NotNull(invokedContexts[0].ResponseMessages); + var firstRequestMessages = invokedContexts[0].RequestMessages.ToList(); + Assert.Contains(firstRequestMessages, m => m.Text == "test"); + Assert.Contains(invokedContexts[0].ResponseMessages!, m => m.Contents.OfType().Any()); + + // Second invocation: request messages should NOT include the original user message (already notified). + // It should only include messages added since the first call (assistant tool call + tool result). + Assert.NotNull(invokedContexts[1].ResponseMessages); + var secondRequestMessages = invokedContexts[1].RequestMessages.ToList(); + Assert.DoesNotContain(secondRequestMessages, m => m.Text == "test"); + Assert.Contains(invokedContexts[1].ResponseMessages!, m => m.Text == "final response"); + } + + /// + /// Verifies that when per-service-call persistence is active (default) with streaming, + /// the ChatHistoryProvider receives messages after the stream completes. + /// + [Fact] + public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_ByDefaultAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(CreateAsyncEnumerableAsync( + new ChatResponseUpdate(ChatRole.Assistant, "streaming "), + new ChatResponseUpdate(ChatRole.Assistant, "response"))); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session)) + { + // Consume stream + } + + // Assert — InvokedCoreAsync should be called by the decorator + mockChatHistoryProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.RequestMessages.Any(m => m.Text == "test") && + x.ResponseMessages != null), + ItExpr.IsAny()); + } + + /// + /// Verifies that when per-service-call persistence is active (default), + /// AIContextProviders are also notified of new messages after a successful call. + /// + [Fact] + public async Task RunAsync_NotifiesAIContextProviders_ByDefaultAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockContextProvider = new(null, null, null); + mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]); + mockContextProvider + .Protected() + .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask(new AIContext())); + mockContextProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + AIContextProviders = [mockContextProvider.Object], + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — InvokedCoreAsync should be called by the decorator for the AIContextProvider + mockContextProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.ResponseMessages != null && + x.ResponseMessages.Any(m => m.Text == "response")), + ItExpr.IsAny()); + } + + /// + /// Verifies that when per-service-call persistence is active (default) and the service fails, + /// AIContextProviders are notified of the failure. + /// + [Fact] + public async Task RunAsync_NotifiesAIContextProvidersOfFailure_ByDefaultAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Service failed"); + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ThrowsAsync(expectedException); + + Mock mockContextProvider = new(null, null, null); + mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]); + mockContextProvider + .Protected() + .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask(new AIContext())); + mockContextProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + AIContextProviders = [mockContextProvider.Object], + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session)); + + // Assert — the decorator should have notified the AIContextProvider of the failure + mockContextProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.InvokeException != null && + x.InvokeException.Message == "Service failed"), + ItExpr.IsAny()); + } + + /// + /// Verifies that when per-service-call persistence is active (default), + /// both ChatHistoryProvider and AIContextProviders are notified together. + /// + [Fact] + public async Task RunAsync_NotifiesBothProviders_ByDefaultAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask()); + + Mock mockContextProvider = new(null, null, null); + mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]); + mockContextProvider + .Protected() + .Setup>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask(new AIContext())); + mockContextProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + AIContextProviders = [mockContextProvider.Object], + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — both providers should have been notified + mockChatHistoryProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.ResponseMessages != null && + x.ResponseMessages.Any(m => m.Text == "response")), + ItExpr.IsAny()); + + mockContextProvider + .Protected() + .Verify("InvokedCoreAsync", Times.Once(), + ItExpr.Is(x => + x.ResponseMessages != null && + x.ResponseMessages.Any(m => m.Text == "response")), + ItExpr.IsAny()); + } + + /// + /// Verifies that during a FIC loop, response messages from the first call are not + /// re-notified as request messages on the second call. + /// + [Fact] + public async Task RunAsync_DoesNotReNotifyResponseMessagesAsRequestMessages_DuringFicLoopAsync() + { + // Arrange + int serviceCallCount = 0; + var assistantToolCallMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())]); + + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + return Task.FromResult(new ChatResponse([assistantToolCallMessage])); + } + + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")])); + }); + + var invokedContexts = new List(); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx)) + .Returns(() => new ValueTask()); + + var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Tools = [tool] }, + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = false, + }, services: new ServiceCollection().BuildServiceProvider()); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert + Assert.Equal(2, invokedContexts.Count); + + // The assistant tool call message was a response in call 1 + Assert.Contains(invokedContexts[0].ResponseMessages!, m => ReferenceEquals(m, assistantToolCallMessage)); + + // It should NOT appear as a request in call 2 (it was already notified as a response) + var secondRequestMessages = invokedContexts[1].RequestMessages.ToList(); + Assert.DoesNotContain(secondRequestMessages, m => ReferenceEquals(m, assistantToolCallMessage)); + } + + /// + /// Verifies that when a failure occurs on the second call in a FIC loop, + /// only new request messages (not previously notified) are sent in the failure notification. + /// + [Fact] + public async Task RunAsync_DeduplicatesRequestMessages_OnFailureDuringFicLoopAsync() + { + // Arrange + int serviceCallCount = 0; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => + { + serviceCallCount++; + if (serviceCallCount == 1) + { + return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary())])])); + } + + throw new InvalidOperationException("Service failure on second call"); + }); + + var invokedContexts = new List(); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx)) + .Returns(() => new ValueTask()); + + var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool"); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Tools = [tool] }, + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = false, + }, services: new ServiceCollection().BuildServiceProvider()); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await Assert.ThrowsAsync(() => + agent.RunAsync([new(ChatRole.User, "test")], session)); + + // Assert — should have 2 notifications: success on call 1, failure on call 2 + Assert.Equal(2, invokedContexts.Count); + + // First notification: success, has user message as request + Assert.Null(invokedContexts[0].InvokeException); + Assert.Contains(invokedContexts[0].RequestMessages, m => m.Text == "test"); + + // Second notification: failure, should NOT include the user message (already notified) + Assert.NotNull(invokedContexts[1].InvokeException); + var failureRequestMessages = invokedContexts[1].RequestMessages.ToList(); + Assert.DoesNotContain(failureRequestMessages, m => m.Text == "test"); + } + + /// + /// Verifies that after a successful run with per-service-call persistence, the notified + /// messages are stamped with the persisted marker so they are not re-notified. + /// + [Fact] + public async Task RunAsync_MarksNotifiedMessages_WithPersistedMarkerAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatHistoryProvider = new(null, null, null); + mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]); + mockChatHistoryProvider + .Protected() + .Setup>>("InvokingCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) => + new ValueTask>(ctx.RequestMessages.ToList())); + mockChatHistoryProvider + .Protected() + .Setup("InvokedCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask()); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatHistoryProvider = mockChatHistoryProvider.Object, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var inputMessage = new ChatMessage(ChatRole.User, "test"); + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([inputMessage], session); + + // Assert — input message should be marked as persisted + Assert.True( + inputMessage.AdditionalProperties?.ContainsKey(ChatHistoryPersistingChatClient.PersistedMarkerKey) == true, + "Input message should be marked as persisted after a successful run."); + } + + /// + /// Verifies that when per-service-call persistence is enabled and the inner client returns a + /// conversation ID, the session's ConversationId is updated after the service call. + /// + [Fact] + public async Task RunAsync_UpdatesSessionConversationId_WhenPerServiceCallPersistenceEnabledAsync() + { + // Arrange + const string ExpectedConversationId = "conv-123"; + + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) + { + ConversationId = ExpectedConversationId, + }); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — session should have the conversation ID returned by the inner client + Assert.Equal(ExpectedConversationId, session!.ConversationId); + } + + private static async IAsyncEnumerable CreateAsyncEnumerableAsync(params ChatResponseUpdate[] updates) + { + foreach (var update in updates) + { + yield return update; + } + + await Task.CompletedTask; + } + + /// + /// Verifies that when per-service-call persistence is active and no real conversation ID exists, + /// sets the + /// sentinel on the chat options and strips it before + /// forwarding to the inner client. + /// + [Fact] + public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when end-of-run persistence is enabled + /// (mark-only mode), since the issue only applies to per-service-call persistence. + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = true, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should see options but NOT the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when a real conversation ID is already present + /// on the session (indicating server-side history management). + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync() + { + // Arrange + const string RealConversationId = "real-conv-123"; + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) + { + ConversationId = RealConversationId, + }); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Create a session with a real conversation ID. + var session = await agent.CreateSessionAsync(RealConversationId); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — the inner client should see the real conversation ID, not the sentinel + Assert.NotNull(capturedOptions); + Assert.Equal(RealConversationId, capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is set and stripped correctly in the streaming path. + /// + [Fact] + public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response"))); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")])) + { + // Consume the stream. + } + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the session's conversation ID is NOT set to the sentinel after the run. + /// The sentinel should only exist transiently on the ChatOptions for the pipeline. + /// + [Fact] + public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — session should NOT have the sentinel conversation ID + Assert.Null(session!.ConversationId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatStrategyExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatStrategyExtensionsTests.cs new file mode 100644 index 0000000000..195d5756e5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatStrategyExtensionsTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public class ChatStrategyExtensionsTests +{ + [Fact] + public void AsChatReducerNullStrategyThrows() + { + // Act & Assert + Assert.Throws(() => ((CompactionStrategy)null!).AsChatReducer()); + } + + [Fact] + public void AsChatReducerReturnsIChatReducer() + { + // Arrange + ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always); + + // Act + IChatReducer reducer = strategy.AsChatReducer(); + + // Assert + Assert.NotNull(reducer); + } + + [Fact] + public async Task ReduceAsyncReturnsAllMessagesWhenStrategyDoesNotCompactAsync() + { + // Arrange — trigger never fires, so no compaction occurs + ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Never); + IChatReducer reducer = strategy.AsChatReducer(); + + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi!"), + ]; + + // Act + IEnumerable result = await reducer.ReduceAsync(messages, CancellationToken.None); + + // Assert + Assert.Equal(messages, result); + } + + [Fact] + public async Task ReduceAsyncCompactsMessagesWhenStrategyFiresAsync() + { + // Arrange — reducer keeps only the last message + ChatReducerCompactionStrategy strategy = new( + new TakeLastReducer(1), + CompactionTriggers.Always); + IChatReducer reducer = strategy.AsChatReducer(); + + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.Assistant, "Response 1"), + new(ChatRole.User, "Second"), + ]; + + // Act + IEnumerable result = await reducer.ReduceAsync(messages, CancellationToken.None); + + // Assert + List resultList = [.. result]; + Assert.Single(resultList); + Assert.Equal("Second", resultList[0].Text); + } + + [Fact] + public async Task ReduceAsyncPassesCancellationTokenToStrategyAsync() + { + // Arrange + using CancellationTokenSource cts = new(); + CancellationToken capturedToken = default; + + CapturingReducer capturingReducer = new(token => capturedToken = token); + ChatReducerCompactionStrategy strategy = new(capturingReducer, CompactionTriggers.Always); + IChatReducer reducer = strategy.AsChatReducer(); + + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.User, "World"), + ]; + + // Act + await reducer.ReduceAsync(messages, cts.Token); + + // Assert + Assert.Equal(cts.Token, capturedToken); + } + + [Fact] + public async Task ReduceAsyncEmptyMessagesReturnsEmptyAsync() + { + // Arrange + ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always); + IChatReducer reducer = strategy.AsChatReducer(); + + // Act + IEnumerable result = await reducer.ReduceAsync([], CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + /// + /// An that returns messages unchanged. + /// + private sealed class IdentityReducer : IChatReducer + { + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + => Task.FromResult(messages); + } + + /// + /// An that keeps only the last n messages. + /// + private sealed class TakeLastReducer : IChatReducer + { + private readonly int _count; + + public TakeLastReducer(int count) => this._count = count; + + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + => Task.FromResult(messages.Reverse().Take(this._count)); + } + + /// + /// An that captures the passed to . + /// + private sealed class CapturingReducer : IChatReducer + { + private readonly Action _capture; + + public CapturingReducer(Action capture) => this._capture = capture; + + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + this._capture(cancellationToken); + IEnumerable reducedMessages = [messages.Reverse().First()]; + return Task.FromResult(reducedMessages); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index b941439988..c4006a925f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; @@ -348,4 +349,90 @@ public class ToolResultCompactionStrategyTests List included = [.. groups.GetIncludedMessages()]; Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text); } + + [Fact] + public async Task CompactAsyncUsesCustomFormatterAsync() + { + // Arrange — custom formatter that produces a collapsed message count + static string CustomFormatter(CompactionMessageGroup group) => + $"[Collapsed: {group.Messages.Count} messages]"; + + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreservedGroups: 1) + { + ToolCallFormatter = CustomFormatter, + }; + + CompactionMessageIndex groups = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, "Sunny"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert — custom formatter output used instead of default YAML-like format + Assert.True(result); + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal("[Collapsed: 2 messages]", included[1].Text); + } + + [Fact] + public void ToolCallFormatterPropertyIsNullWhenNoneProvided() + { + // Arrange + ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always); + + // Assert — ToolCallFormatter is null when no custom formatter is provided + Assert.Null(strategy.ToolCallFormatter); + } + + [Fact] + public void ToolCallFormatterPropertyReturnsCustomFormatterWhenProvided() + { + // Arrange + Func customFormatter = static _ => "custom"; + ToolResultCompactionStrategy strategy = new( + CompactionTriggers.Always) + { + ToolCallFormatter = customFormatter + }; + + // Assert — ToolCallFormatter is the injected custom function + Assert.Same(customFormatter, strategy.ToolCallFormatter); + } + + [Fact] + public async Task CompactAsyncCustomFormatterCanDelegateToDefaultAsync() + { + // Arrange — custom formatter that wraps the default output + static string WrappingFormatter(CompactionMessageGroup group) => + $"CUSTOM_PREFIX\n{ToolResultCompactionStrategy.DefaultToolCallFormatter(group)}"; + + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreservedGroups: 1) + { + ToolCallFormatter = WrappingFormatter + }; + + CompactionMessageIndex groups = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, "result"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(groups); + + // Assert — wrapped default output + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal("CUSTOM_PREFIX\n[Tool Calls]\nfn:\n - result", included[1].Text); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index a782993f6a..12bc57a3ee 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -85,7 +85,8 @@ public sealed class TextSearchProviderTests { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, ContextPrompt = overrideContextPrompt, - CitationsPrompt = overrideCitationsPrompt + CitationsPrompt = overrideCitationsPrompt, + EnableSensitiveTelemetryData = true }; var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null); @@ -164,6 +165,65 @@ public sealed class TextSearchProviderTests } } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool useCustomRedactor) + { + // Arrange + List results = + [ + new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" } + ]; + + Task> SearchDelegateAsync(string input, CancellationToken ct) + { + return Task.FromResult>(results); + } + + var options = new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null + }; + var provider = new TextSearchProvider(SearchDelegateAsync, options, this._loggerFactoryMock.Object); + + var invokingContext = new AIContextProvider.InvokingContext( + s_mockAgent, + new TestAgentSession(), + new AIContext { Messages = new List { new(ChatRole.User, "Sample user question?") } }); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor + var traceInvocation = this._loggerMock.Invocations + .Where(i => i.Method.Name == nameof(ILogger.Log)) + .FirstOrDefault(i => (LogLevel)i.Arguments[0]! == LogLevel.Trace); + Assert.NotNull(traceInvocation); + + var state = Assert.IsType>>(traceInvocation.Arguments[2], exactMatch: false); + var inputValue = state.First(kvp => kvp.Key == "Input").Value; + var messageTextValue = state.First(kvp => kvp.Key == "MessageText").Value; + + if (enableSensitiveTelemetryData) + { + // EnableSensitiveTelemetryData=true: raw data passes through regardless of Redactor + Assert.Equal("Sample user question?", inputValue); + Assert.Contains("Content of Doc1", messageTextValue?.ToString()!); + } + else + { + // EnableSensitiveTelemetryData=false: custom redactor or default placeholder + string expectedRedaction = useCustomRedactor ? "***" : ""; + Assert.Equal(expectedRedaction, inputValue); + Assert.Equal(expectedRedaction, messageTextValue); + } + } + [Theory] [InlineData(null, null, "Search", "Allows searching for additional information to help answer the user question.")] [InlineData("CustomSearch", "CustomDescription", "CustomSearch", "CustomDescription")] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs index 35c7f780b4..43cabebaed 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs @@ -270,16 +270,21 @@ public class ChatHistoryMemoryProviderTests } [Theory] - [InlineData(false, false, 0)] - [InlineData(true, false, 0)] - [InlineData(false, true, 2)] - [InlineData(true, true, 2)] - public async Task InvokedAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + [InlineData(false, false, false, 0)] + [InlineData(false, false, true, 0)] + [InlineData(true, false, false, 0)] + [InlineData(true, false, true, 0)] + [InlineData(false, true, false, 2)] + [InlineData(false, true, true, 2)] + [InlineData(true, true, false, 2)] + [InlineData(true, true, true, 2)] + public async Task InvokedAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations) { // Arrange var options = new ChatHistoryMemoryProviderOptions { - EnableSensitiveTelemetryData = enableSensitiveTelemetryData + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null }; if (requestThrows) @@ -309,7 +314,7 @@ public class ChatHistoryMemoryProviderTests // Act await provider.InvokedAsync(invokedContext, CancellationToken.None); - // Assert + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); foreach (var logInvocation in this._loggerMock.Invocations) { @@ -320,7 +325,8 @@ public class ChatHistoryMemoryProviderTests var state = Assert.IsType>>(logInvocation.Arguments[2], exactMatch: false); var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; - Assert.Equal(enableSensitiveTelemetryData ? "user1" : "", userIdValue); + string expectedRedaction = enableSensitiveTelemetryData ? "user1" : (useCustomRedactor ? "***" : ""); + Assert.Equal(expectedRedaction, userIdValue); } } @@ -526,17 +532,22 @@ public class ChatHistoryMemoryProviderTests } [Theory] - [InlineData(false, false, 2)] - [InlineData(true, false, 2)] - [InlineData(false, true, 2)] - [InlineData(true, true, 2)] - public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + [InlineData(false, false, false, 2)] + [InlineData(false, false, true, 2)] + [InlineData(true, false, false, 2)] + [InlineData(true, false, true, 2)] + [InlineData(false, true, false, 2)] + [InlineData(false, true, true, 2)] + [InlineData(true, true, false, 2)] + [InlineData(true, true, true, 2)] + public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations) { // Arrange var options = new ChatHistoryMemoryProviderOptions { SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke, - EnableSensitiveTelemetryData = enableSensitiveTelemetryData + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null }; var scope = new ChatHistoryMemoryProviderScope @@ -578,7 +589,8 @@ public class ChatHistoryMemoryProviderTests // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); - // Assert + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor + string expectedRedaction = enableSensitiveTelemetryData ? "user1" : (useCustomRedactor ? "***" : ""); Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); foreach (var logInvocation in this._loggerMock.Invocations) { @@ -589,18 +601,18 @@ public class ChatHistoryMemoryProviderTests var state = Assert.IsType>>(logInvocation.Arguments[2], exactMatch: false); var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; - Assert.Equal(enableSensitiveTelemetryData ? "user1" : "", userIdValue); + Assert.Equal(expectedRedaction, userIdValue); var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value; if (inputValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : "", inputValue); + Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : expectedRedaction, inputValue); } var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value; if (messageTextValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : "", messageTextValue); + Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : expectedRedaction, messageTextValue); } } } 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.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index d37dd58c8c..37c0fa98cf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -16,7 +16,6 @@ - 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.Generators.UnitTests/ExecutorRouteGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs index d2160486cc..8433dd5e3e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs @@ -651,7 +651,7 @@ public class ExecutorRouteGeneratorTests } [Fact] - public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides() + public void PartialClass_SendsYieldsInBothFiles_GeneratesAllOverrides() { // File 1: Partial with one handler var file1 = """ @@ -700,7 +700,7 @@ public class ExecutorRouteGeneratorTests generated.Should().RegisterSentMessageType("string") .And.RegisterSentMessageType("int") .And.RegisterYieldedOutputType("string") - .And.RegisterYieldedOutputType("string"); + .And.RegisterYieldedOutputType("int"); } #endregion @@ -1046,6 +1046,85 @@ public class ExecutorRouteGeneratorTests .And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage"); } + [Fact] + public void ProtocolOnly_DerivesFromExecutorOfT_GeneratesBaseCall() + { + // A protocol-only partial executor deriving from Executor + // has a base class that already overrides ConfigureProtocol. The generator must emit + // "return base.ConfigureProtocol(protocolBuilder)" so inherited handler registrations + // are preserved — not "return protocolBuilder" which silently drops them. + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class FeedbackResult { } + + [SendsMessage(typeof(FeedbackResult))] + [YieldsOutput(typeof(string))] + public partial class FeedbackExecutor : Executor + { + public FeedbackExecutor() : base("feedback") { } + + public override System.Threading.Tasks.ValueTask HandleAsync(string message, IWorkflowContext context, System.Threading.CancellationToken cancellationToken = default) + => default; + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + result.RunResult.Diagnostics.Should().BeEmpty(); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + + // Base class Executor overrides ConfigureProtocol, so the generated override + // must chain to base to preserve the inherited handler registration. + generated.Should().Contain("return base.ConfigureProtocol(protocolBuilder)", + because: "Executor overrides ConfigureProtocol, so base must be called to preserve its handler registration"); + generated.Should().Contain(".SendsMessage()"); + generated.Should().Contain(".YieldsOutput()"); + } + + [Fact] + public void ProtocolOnly_DerivesDirectlyFromExecutor_DoesNotGenerateBaseCall() + { + // A protocol-only partial executor deriving directly from Executor (abstract base + // with no non-abstract ConfigureProtocol override) should generate "return protocolBuilder" + // rather than "return base.ConfigureProtocol(protocolBuilder)". + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class BroadcastMessage { } + + [SendsMessage(typeof(BroadcastMessage))] + public partial class BroadcastExecutor : Executor + { + public BroadcastExecutor() : base("broadcast") { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + result.RunResult.Diagnostics.Should().BeEmpty(); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + + // Executor's ConfigureProtocol is abstract — no base call needed. + generated.Should().Contain("return protocolBuilder", + because: "Executor base class has no non-abstract ConfigureProtocol, so no base call is needed"); + generated.Should().NotContain("base.ConfigureProtocol"); + } + #endregion #region Generic Executor Tests diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index 2ea117856f..9cd9eb45e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -10,20 +10,8 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; -public class AIAgentHostExecutorTests +public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase { - private const string TestAgentId = nameof(TestAgentId); - private const string TestAgentName = nameof(TestAgentName); - - private static readonly string[] s_messageStrings = [ - "", - "Hello world!", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." - ]; - - private static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); - [Theory] [InlineData(null, null)] [InlineData(null, true)] @@ -50,30 +38,7 @@ public class AIAgentHostExecutorTests bool expectingEvents = turnSetting ?? executorSetting ?? false; AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); - if (expectingEvents) - { - // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent - List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); - - updates.Should().HaveCount(expectedUpdateContents.Count); - for (int i = 0; i < updates.Length; i++) - { - AgentResponseUpdateEvent updateEvent = updates[i]; - AIContent expectedUpdateContent = expectedUpdateContents[i]; - - updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponseUpdate update = updateEvent.Update; - update.AuthorName.Should().Be(TestAgentName); - update.AgentId.Should().Be(TestAgentId); - update.Contents.Should().HaveCount(1); - update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingEvents, agent.GetDescriptiveId()); } [Theory] @@ -92,30 +57,7 @@ public class AIAgentHostExecutorTests // Assert AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); - if (executorSetting) - { - updates.Should().HaveCount(1); - - AgentResponseEvent responseEvent = updates[0]; - responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponse response = responseEvent.Response; - response.AgentId.Should().Be(TestAgentId); - response.Messages.Should().HaveCount(TestMessages.Count - 1); - - for (int i = 0; i < response.Messages.Count; i++) - { - ChatMessage responseMessage = response.Messages[i]; - ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message - - responseMessage.AuthorName.Should().Be(TestAgentName); - responseMessage.Text.Should().Be(expectedMessage.Text); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); } private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" }; @@ -229,7 +171,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/AIAgentHostingExecutorTestsBase.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs new file mode 100644 index 0000000000..2285074ce3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public abstract class AIAgentHostingExecutorTestsBase +{ + protected const string TestAgentId = nameof(TestAgentId); + protected const string TestAgentName = nameof(TestAgentName); + + private static readonly string[] s_messageStrings = [ + "", + "Hello world!", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." + ]; + + protected static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); + + protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId) + { + if (expectingEvents) + { + // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent + List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); + + updates.Should().HaveCount(expectedUpdateContents.Count); + for (int i = 0; i < updates.Length; i++) + { + AgentResponseUpdateEvent updateEvent = updates[i]; + AIContent expectedUpdateContent = expectedUpdateContents[i]; + + updateEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponseUpdate update = updateEvent.Update; + update.AuthorName.Should().Be(TestAgentName); + update.AgentId.Should().Be(TestAgentId); + update.Contents.Should().HaveCount(1); + update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); + } + } + else + { + updates.Should().BeEmpty(); + } + } + + protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId) + { + if (expectingResponse) + { + updates.Should().HaveCount(1); + + AgentResponseEvent responseEvent = updates[0]; + responseEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponse response = responseEvent.Response; + response.AgentId.Should().Be(TestAgentId); + response.Messages.Should().HaveCount(TestMessages.Count - 1); + + for (int i = 0; i < response.Messages.Count; i++) + { + ChatMessage responseMessage = response.Messages[i]; + ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message + + responseMessage.AuthorName.Should().Be(TestAgentName); + responseMessage.Text.Should().Be(expectedMessage.Text); + } + } + else + { + updates.Should().BeEmpty(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 77d8d0a88d..7f06145a8e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -147,7 +147,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(numAgents + 1, result.Count); @@ -225,7 +225,7 @@ public class AgentWorkflowBuilderTests barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); remaining.Value = 2; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.NotEmpty(updateText); Assert.NotNull(result); @@ -258,7 +258,7 @@ public class AgentWorkflowBuilderTests }), description: "nop")) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent1", updateText); Assert.NotNull(result); @@ -296,7 +296,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(initialAgent, nextAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent2", updateText); Assert.NotNull(result); @@ -406,7 +406,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Contains("Hello from agent3", updateText); @@ -604,7 +604,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent3", updateText); Assert.NotNull(result); @@ -651,7 +651,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(maxIterations + 1, result.Count); @@ -680,36 +680,211 @@ public class AgentWorkflowBuilderTests } } - private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( - Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + [Fact] + public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync() { - StringBuilder sb = new(); + int coordinatorCallCount = 0; - InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment(); - await using StreamingRun run = await environment.RunStreamingAsync(workflow, input); + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "specialist responded"))), + name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + + // Turn 2: without ReturnToPrevious, coordinator should be invoked again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(2, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "specialist responded")); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + Assert.Equal(1, specialistCallCount); + + // Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again + Assert.Equal(2, specialistCallCount); // specialist called again + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync() + { + int coordinatorCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + Assert.Fail("Specialist should not be invoked."); + return new(); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + // First turn with no prior handoff: should route to initial (coordinator) agent + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); + Assert.Equal(1, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + // First call: hand off to specialist + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + // Subsequent calls: respond without handoff + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + // Specialist hands back to coordinator + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithHandoff(specialist, coordinator) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator → specialist → coordinator (specialist hands back) + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback + Assert.Equal(1, specialistCallCount); // specialist called once, then handed back + + // Turn 2: after handoff back to coordinator, should route to coordinator (not specialist) + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2 + Assert.Equal(1, specialistCallCount); // specialist NOT called + } + + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint); + + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.TrySendMessageAsync(input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + StringBuilder sb = new(); WorkflowOutputEvent? output = null; + CheckpointInfo? lastCheckpoint = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentResponseUpdateEvent executorComplete) + switch (evt) { - sb.Append(executorComplete.Data); - } - else if (evt is WorkflowOutputEvent e) - { - output = e; - break; - } - else if (evt is WorkflowErrorEvent errorEvent) - { - Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + case AgentResponseUpdateEvent executorComplete: + sb.Append(executorComplete.Data); + break; + + case WorkflowOutputEvent e: + output = e; + break; + + case WorkflowErrorEvent errorEvent: + Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + break; + + case SuperStepCompletedEvent stepCompleted: + lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + break; } } - return (sb.ToString(), output?.As>()); + return new(sb.ToString(), output?.As>(), lastCheckpoint); } + private static Task RunWorkflowAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + => RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment()); + private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) { protected override async IAsyncEnumerable RunCoreStreamingAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs index 70210fac41..cc5d5a3c62 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -199,4 +200,43 @@ public class EdgeRunnerTests mapping.CheckDeliveries(["executor3"], ["part1", "part2", "final part"]); } } + + [Fact] + public async Task Test_FanInEdgeRunner_ConcurrentProcessingAsync() + { + // Arrange + const int SourceCount = 4; + const int Iterations = 50; + + string[] sourceIds = Enumerable.Range(0, SourceCount).Select(i => $"source{i}").ToArray(); + const string SinkId = "sink"; + + TestRunContext runContext = new(); + List executors = [.. sourceIds.Select(id => (Executor)new ForwardMessageExecutor(id)), new ForwardMessageExecutor(SinkId)]; + runContext.ConfigureExecutors(executors); + + FanInEdgeData edgeData = new(sourceIds.ToList(), SinkId, new EdgeId(0), null); + FanInEdgeRunner runner = new(runContext, edgeData); + + for (int iteration = 0; iteration < Iterations; iteration++) + { + // Act: send messages from all sources concurrently + using Barrier barrier = new(SourceCount); + Task[] tasks = sourceIds.Select(sourceId => Task.Run(async () => + { + barrier.SignalAndWait(); + return await runner.ChaseEdgeAsync(new($"msg-from-{sourceId}", sourceId), stepTracer: null, CancellationToken.None); + })).ToArray(); + + DeliveryMapping?[] results = await Task.WhenAll(tasks); + + // Assert: exactly one task should return a non-null mapping with all messages + DeliveryMapping?[] nonNullResults = results.Where(r => r is not null).ToArray(); + nonNullResults.Should().HaveCount(1, $"iteration {iteration}: exactly one thread should release the batch"); + + DeliveryMapping mapping = nonNullResults[0]!; + HashSet expectedMessages = [.. sourceIds.Select(id => (object)$"msg-from-{id}")]; + mapping.CheckDeliveries([SinkId], expectedMessages); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs index d21f9af035..10fbfddd64 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs @@ -9,49 +9,173 @@ using Microsoft.Agents.AI.Workflows.Checkpointing; namespace Microsoft.Agents.AI.Workflows.UnitTests; +internal sealed class TempDirectory : IDisposable +{ + public DirectoryInfo DirectoryInfo { get; } + + public TempDirectory() + { + string tempDirPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + this.DirectoryInfo = Directory.CreateDirectory(tempDirPath); + } + + public void Dispose() + { + this.DisposeInternal(); + GC.SuppressFinalize(this); + } + + private void DisposeInternal() + { + if (this.DirectoryInfo.Exists) + { + try + { + // Best efforts + this.DirectoryInfo.Delete(recursive: true); + } + catch { } + } + } + + ~TempDirectory() + { + // Best efforts + this.DisposeInternal(); + } + + public static implicit operator DirectoryInfo(TempDirectory tempDirectory) => tempDirectory.DirectoryInfo; + + public string FullName => this.DirectoryInfo.FullName; + + public bool IsParentOf(FileInfo candidate) + { + if (candidate.Directory is null) + { + return false; + } + + if (candidate.Directory.FullName == this.DirectoryInfo.FullName) + { + return true; + } + + return this.IsParentOf(candidate.Directory); + } + + public bool IsParentOf(DirectoryInfo candidate) + { + while (candidate.Parent is not null) + { + if (candidate.Parent.FullName == this.DirectoryInfo.FullName) + { + return true; + } + + candidate = candidate.Parent; + } + + return false; + } +} public sealed class FileSystemJsonCheckpointStoreTests { + public static JsonElement TestData => JsonSerializer.SerializeToElement(new { test = "data" }); + [Fact] public async Task CreateCheckpointAsync_ShouldPersistIndexToDiskBeforeDisposeAsync() { // Arrange - DirectoryInfo tempDir = new(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); - FileSystemJsonCheckpointStore? store = null; + using TempDirectory tempDirectory = new(); + using FileSystemJsonCheckpointStore? store = new(tempDirectory); - try + string runId = Guid.NewGuid().ToString("N"); + + // Act + CheckpointInfo checkpoint = await store.CreateCheckpointAsync(runId, TestData); + + // Assert - Check the file size before disposing to verify data was flushed to disk + // The index.jsonl file is held exclusively by the store, so we check via FileInfo + string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl"); + FileInfo indexFile = new(indexPath); + indexFile.Refresh(); + long fileSizeBeforeDispose = indexFile.Length; + + // Data should already be on disk (file size > 0) before we dispose + fileSizeBeforeDispose.Should().BeGreaterThan(0, "index.jsonl should be flushed to disk after CreateCheckpointAsync"); + + // Dispose to release file lock before final verification + store.Dispose(); + + string[] lines = File.ReadAllLines(indexPath); + lines.Should().HaveCount(1); + lines[0].Should().Contain(checkpoint.CheckpointId); + } + + private async ValueTask Run_EscapeRootFolderTestAsync(string escapingPath) + { + // Arrange + using TempDirectory tempDirectory = new(); + using FileSystemJsonCheckpointStore store = new(tempDirectory); + + string naivePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, escapingPath); + + // Check that the naive path is actually outside the temp directory to validate the test is meaningful + FileInfo naiveCheckpointFile = new(naivePath); + tempDirectory.IsParentOf(naiveCheckpointFile).Should().BeFalse("The naive path should be outside the root folder to validate that escaping is necessary."); + + // Act + CheckpointInfo checkpointInfo = await store.CreateCheckpointAsync(escapingPath, TestData); + + // Assert + string naivePathWithCheckpointId = Path.Combine(tempDirectory.DirectoryInfo.FullName, $"{escapingPath}_{checkpointInfo.CheckpointId}.json"); + new FileInfo(naivePathWithCheckpointId).Exists.Should().BeFalse("The naive path should not be used to save a checkpoint file."); + + string actualFileName = store.GetFileNameForCheckpoint(escapingPath, checkpointInfo); + string actualFilePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, actualFileName); + FileInfo actualFile = new(actualFilePath); + + tempDirectory.IsParentOf(actualFile).Should().BeTrue("The actual checkpoint should be saved inside the root folder."); + actualFile.Exists.Should().BeTrue("The actual path should be used to save a checkpoint file."); + } + + [Fact] + public async Task CreateCheckpointAsync_ShouldNotEscapeRootFolderAsync() + { + // The SessionId is used as part of the file name, but if it contains path characters such as /.. it can escape the root folder. + // Testing that such characters are escaped properly to prevent directory traversal attacks, etc. + + await this.Run_EscapeRootFolderTestAsync("../valid_suffix"); + +#if !NETFRAMEWORK + if (OperatingSystem.IsWindows()) { - store = new(tempDir); - string runId = Guid.NewGuid().ToString("N"); - JsonElement testData = JsonSerializer.SerializeToElement(new { test = "data" }); - - // Act - CheckpointInfo checkpoint = await store.CreateCheckpointAsync(runId, testData); - - // Assert - Check the file size before disposing to verify data was flushed to disk - // The index.jsonl file is held exclusively by the store, so we check via FileInfo - string indexPath = Path.Combine(tempDir.FullName, "index.jsonl"); - FileInfo indexFile = new(indexPath); - indexFile.Refresh(); - long fileSizeBeforeDispose = indexFile.Length; - - // Data should already be on disk (file size > 0) before we dispose - fileSizeBeforeDispose.Should().BeGreaterThan(0, "index.jsonl should be flushed to disk after CreateCheckpointAsync"); - - // Dispose to release file lock before final verification - store.Dispose(); - store = null; - - string[] lines = File.ReadAllLines(indexPath); - lines.Should().HaveCount(1); - lines[0].Should().Contain(checkpoint.CheckpointId); - } - finally - { - store?.Dispose(); - if (tempDir.Exists) - { - tempDir.Delete(recursive: true); - } + // Windows allows both \ and / as path separators, so we test both + await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix"); } +#else + // .NET Framework is always on Windows + await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix"); +#endif + } + + private const string InvalidPathCharsWin32 = "\\/:*?\"<>|"; + private const string InvalidPathCharsUnix = "/"; + private const string InvalidPathCharsMacOS = "/:"; + + [Theory] + [InlineData(InvalidPathCharsWin32)] + [InlineData(InvalidPathCharsUnix)] + [InlineData(InvalidPathCharsMacOS)] + public async Task CreateCheckpointAsync_EscapesInvalidCharsAsync(string invalidChars) + { + // Arrange + using TempDirectory tempDirectory = new(); + using FileSystemJsonCheckpointStore store = new(tempDirectory); + + string runId = $"prefix_{invalidChars}_suffix"; + + Func createCheckpointAction = async () => await store.CreateCheckpointAsync(runId, TestData); + await createCheckpointAction.Should().NotThrowAsync(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs new file mode 100644 index 0000000000..8bdbe23c5f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase +{ + [Theory] + [InlineData(null, null)] + [InlineData(null, true)] + [InlineData(null, false)] + [InlineData(true, null)] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, null)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: executorSetting, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(turnSetting), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + bool expectingStreamingUpdates = turnSetting ?? executorSetting ?? false; + + AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: executorSetting, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(false), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index c2a538b302..572f858f13 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -673,6 +673,55 @@ public class JsonSerializationTests ValidateCheckpoint(retrievedCheckpoint, prototype); } + [Fact] + public void Test_SessionState_JsonRoundtrip_WithPendingRequests() + { + // Arrange + Dictionary pendingRequests = new() + { + ["call-1"] = TestExternalRequest, + ["call-2"] = ExternalRequest.Create(TestPort, "Request2", "OtherData"), + }; + + WorkflowSession.SessionState prototype = new( + sessionId: "test-session-123", + lastCheckpoint: TestParentCheckpointInfo, + pendingRequests: pendingRequests); + + // Act + WorkflowSession.SessionState result = RunJsonRoundtrip(prototype); + + // Assert + result.SessionId.Should().Be(prototype.SessionId); + result.LastCheckpoint.Should().Be(prototype.LastCheckpoint); + result.StateBag.Should().NotBeNull(); + result.PendingRequests.Should().NotBeNull() + .And.HaveCount(pendingRequests.Count); + + foreach (string key in pendingRequests.Keys) + { + result.PendingRequests.Should().ContainKey(key); + ValidateExternalRequest(result.PendingRequests![key], pendingRequests[key]); + } + } + + [Fact] + public void Test_SessionState_JsonRoundtrip_WithoutPendingRequests() + { + // Arrange + WorkflowSession.SessionState prototype = new( + sessionId: "test-session-456", + lastCheckpoint: null); + + // Act + WorkflowSession.SessionState result = RunJsonRoundtrip(prototype); + + // Assert + result.SessionId.Should().Be(prototype.SessionId); + result.LastCheckpoint.Should().BeNull(); + result.PendingRequests.Should().BeNull(); + } + /// /// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails /// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs index 3d88ed22ab..993a6d462b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs @@ -20,7 +20,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix { IEnumerable? handoffs = chatClientOptions.ChatOptions .Tools? - .Where(tool => tool.Name?.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, + .Where(tool => tool.Name?.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.OrdinalIgnoreCase) is true); if (handoffs != null) @@ -58,7 +58,7 @@ internal static class Step12EntryPoint .Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i))) .ToArray(); - return new HandoffsWorkflowBuilder(echoAgents[0]) + return new HandoffWorkflowBuilder(echoAgents[0]) .WithHandoff(echoAgents[0], echoAgents[1]) .Build(); } 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/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 40e4f2098f..3fa17a34bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -28,7 +28,185 @@ public sealed class ExpectedException : Exception } } -public class WorkflowHostSmokeTests +/// +/// A simple agent that emits a FunctionCallContent or ToolApprovalRequestContent request. +/// Used to test that RequestInfoEvent handling preserves the original content type. +/// +internal sealed class RequestEmittingAgent : AIAgent +{ + private readonly AIContent _requestContent; + private readonly bool _completeOnResponse; + + /// + /// Creates a new that emits the given request content. + /// + /// The content to emit on each turn. + /// + /// When , the agent emits a text completion instead of re-emitting + /// the request when the incoming messages contain a + /// or . This models realistic agent behaviour + /// where the agent processes the tool result and produces a final answer. + /// + public RequestEmittingAgent(AIContent requestContent, bool completeOnResponse = false) + { + this._requestContent = requestContent; + this._completeOnResponse = completeOnResponse; + } + + private sealed class Session : AgentSession + { + public Session() { } + } + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (this._completeOnResponse && messages.Any(m => m.Contents.Any(c => + c is FunctionResultContent || c is ToolApprovalResponseContent))) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, [new TextContent("Request processed")]); + } + else + { + // Emit the request content + yield return new AgentResponseUpdate(ChatRole.Assistant, [this._requestContent]); + } + } +} + +internal sealed class KickoffOnStartExecutor : ChatProtocolExecutor +{ + private static readonly ChatProtocolExecutorOptions s_options = new() + { + AutoSendTurnToken = false, + }; + + private readonly string _downstreamExecutorId; + private readonly string _kickoffInputText; + private readonly string _kickoffMessageText; + private readonly string _regularResumeText; + private readonly string _regularProcessedText; + + public KickoffOnStartExecutor( + string id, + string downstreamExecutorId, + string kickoffInputText, + string kickoffMessageText, + string regularResumeText, + string regularProcessedText) + : base(id, s_options) + { + this._downstreamExecutorId = downstreamExecutorId; + this._kickoffInputText = kickoffInputText; + this._kickoffMessageText = kickoffMessageText; + this._regularResumeText = regularResumeText; + this._regularProcessedText = regularProcessedText; + } + + protected override async ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + { + List textContents = + [ + .. messages + .SelectMany(message => message.Contents.OfType()) + .Select(content => content.Text) + ]; + + if (textContents.Contains(this._kickoffInputText, StringComparer.Ordinal)) + { + await context.SendMessageAsync( + new List { new(ChatRole.User, this._kickoffMessageText) }, + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync( + new TurnToken(emitEvents), + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + } + + if (textContents.Contains(this._regularResumeText, StringComparer.Ordinal)) + { + AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._regularProcessedText)]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + ResponseId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + }; + + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + } + } +} + +/// +/// A start executor that always emits a response update on every turn, +/// useful for verifying that a TurnToken was delivered by the session. +/// On the first turn (user messages present), it kicks off a downstream executor. +/// +internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor +{ + private static readonly ChatProtocolExecutorOptions s_options = new() + { + AutoSendTurnToken = false, + }; + + private readonly string _downstreamExecutorId; + private readonly string _activatedMarker; + private int _activationCount; + + /// Gets the number of times this executor has been activated (i.e., called). + public int ActivationCount => this._activationCount; + + public TurnTrackingStartExecutor(string id, string downstreamExecutorId, string activatedMarker) + : base(id, s_options) + { + this._downstreamExecutorId = downstreamExecutorId; + this._activatedMarker = activatedMarker; + } + + protected override async ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._activationCount); + + // On the first turn, forward user messages and a TurnToken to the downstream executor. + if (messages.Any(m => m.Role == ChatRole.User)) + { + await context.SendMessageAsync( + messages, + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync( + new TurnToken(emitEvents), + this._downstreamExecutorId, + cancellationToken).ConfigureAwait(false); + } + + // Always emit a marker to prove this executor was activated. + AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._activatedMarker)]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + ResponseId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + }; + + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + } +} + +public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent { @@ -112,4 +290,511 @@ public class WorkflowHostSmokeTests hadErrorContent.Should().BeTrue(); } + + /// + /// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data, + /// the AgentResponseUpdate preserves the original FunctionCallContent type. + /// + [Fact] + public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync() + { + // Arrange + const string CallId = "test-call-id"; + const string FunctionName = "testFunction"; + FunctionCallContent originalContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(originalContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + AgentResponseUpdate? updateWithFunctionCall = updates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent)); + + updateWithFunctionCall.Should().NotBeNull("a FunctionCallContent should be present in the response updates"); + FunctionCallContent retrievedContent = updateWithFunctionCall!.Contents + .OfType() + .Should().ContainSingle() + .Which; + + retrievedContent.CallId.Should().NotBe(CallId); + retrievedContent.CallId.Should().EndWith($":{CallId}"); + retrievedContent.Name.Should().Be(FunctionName); + } + + /// + /// Tests that when a workflow emits a RequestInfoEvent with ToolApprovalRequestContent data, + /// the AgentResponseUpdate preserves the original ToolApprovalRequestContent type. + /// + [Fact] + public async Task Test_AsAgent_ToolApprovalRequestContentPreservedInRequestInfoAsync() + { + // Arrange + const string RequestId = "test-request-id"; + McpServerToolCallContent mcpCall = new("call-id", "testToolName", "http://localhost"); + ToolApprovalRequestContent originalContent = new(RequestId, mcpCall); + RequestEmittingAgent requestAgent = new(originalContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + AgentResponseUpdate? updateWithUserInput = updates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent)); + + updateWithUserInput.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates"); + ToolApprovalRequestContent retrievedContent = updateWithUserInput!.Contents + .OfType() + .Should().ContainSingle() + .Which; + + retrievedContent.Should().NotBeNull(); + retrievedContent.RequestId.Should().NotBe(RequestId); + retrievedContent.RequestId.Should().EndWith($":{RequestId}"); + } + + /// + /// Tests the full roundtrip: workflow emits a request, external caller responds, workflow processes response. + /// + [Fact] + public async Task Test_AsAgent_FunctionCallRoundtrip_ResponseIsProcessedAsync() + { + // Arrange: Create an agent that emits a FunctionCallContent request + const string CallId = "roundtrip-call-id"; + const string FunctionName = "testFunction"; + FunctionCallContent requestContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the FunctionCallContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a FunctionCallContent + AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent)); + updateWithRequest.Should().NotBeNull("a FunctionCallContent should be present in the response updates"); + + FunctionCallContent receivedRequest = updateWithRequest!.Contents + .OfType() + .First(); + receivedRequest.CallId.Should().EndWith($":{CallId}"); + + // Act 2: Send the response back + FunctionResultContent responseContent = new(receivedRequest.CallId, "test result"); + ChatMessage responseMessage = new(ChatRole.Tool, [responseContent]); + + // Act 2: Run the workflow with the response and capture the resulting updates + List secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync(); + + // Assert 2: The response should be processed and the original request should no longer be pending. + // Concretely, the workflow should not re-emit a FunctionCallContent with the same CallId. + secondCallUpdates.Should().NotBeNull("processing the response should produce updates"); + secondCallUpdates.Should().NotBeEmpty("processing the response should progress the workflow"); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == receivedRequest.CallId, "the external FunctionCallContent request should be cleared after processing the response"); + } + + /// + /// Tests the full roundtrip for ToolApprovalRequestContent: workflow emits request, external caller responds. + /// Verifying inbound ToolApprovalResponseContent conversion. + /// + [Fact] + public async Task Test_AsAgent_ToolApprovalRoundtrip_ResponseIsProcessedAsync() + { + // Arrange: Create an agent that emits a ToolApprovalRequestContent request + const string RequestId = "roundtrip-request-id"; + McpServerToolCallContent mcpCall = new("mcp-call-id", "testMcpTool", "http://localhost"); + ToolApprovalRequestContent requestContent = new(RequestId, mcpCall); + RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the ToolApprovalRequestContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a ToolApprovalRequestContent + AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent)); + updateWithRequest.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates"); + + ToolApprovalRequestContent receivedRequest = updateWithRequest!.Contents + .OfType() + .First(); + receivedRequest.RequestId.Should().EndWith($":{RequestId}"); + + // Act 2: Send the response back - use CreateResponse to get the right response type + ToolApprovalResponseContent responseContent = receivedRequest.CreateResponse(approved: true); + ChatMessage responseMessage = new(ChatRole.User, [responseContent]); + + // Act 2: Run the workflow again with the response and capture the updates + List secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync(); + + // Assert 2: The response should be applied so that the original request is no longer pending + secondCallUpdates.Should().NotBeEmpty("handling the user input response should produce follow-up updates"); + bool requestStillPresent = secondCallUpdates.Any(u => + u.RawRepresentation is RequestInfoEvent + && u.Contents.OfType().Any(r => r.RequestId == receivedRequest.RequestId)); + requestStillPresent.Should().BeFalse("the original ToolApprovalRequestContent should not be re-emitted after its response is processed"); + } + + /// + /// Tests the mixed-message scenario: resume contains both an external response + /// (FunctionResultContent matching a pending request) and regular non-response content + /// in the same message. + /// Verifies that regular content is still processed and that no duplicate + /// pending-request errors, redundant FunctionCallContent re-emissions, + /// or workflow errors occur. + /// + [Fact] + public async Task Test_AsAgent_MixedResponseAndRegularMessage_BothProcessedAsync() + { + // Arrange: Create an agent that emits a FunctionCallContent request + const string CallId = "mixed-call-id"; + const string FunctionName = "mixedTestFunction"; + FunctionCallContent requestContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the FunctionCallContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a FunctionCallContent + AgentResponseUpdate requestUpdate = firstCallUpdates.First(u => + u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent)); + FunctionCallContent emittedRequest = requestUpdate.Contents.OfType().Single(); + + firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent), + "the first call should emit a FunctionCallContent request"); + + // Act 2: Send a mixed message containing both the function result AND regular non-response content + FunctionResultContent responseContent = new(emittedRequest.CallId, "tool output"); + ChatMessage mixedMessage = new(ChatRole.Tool, [responseContent, new TextContent("additional context")]); + + List secondCallUpdates = await agent.RunStreamingAsync(mixedMessage, session).ToListAsync(); + + // Assert 2: The workflow should have processed both parts without errors + secondCallUpdates.Should().NotBeEmpty("the mixed message should produce follow-up updates"); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == emittedRequest.CallId, "the external FunctionCallContent should be cleared after the response is processed"); + secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Should() + .BeEmpty("no workflow errors should occur when processing a mixed response-and-regular message"); + } + + [Fact] + public async Task Test_AsAgent_ResponseThenRegularAcrossMessages_NoDuplicateFunctionCallAsync() + { + const string CallId = "mixed-separate-call-id"; + const string FunctionName = "mixedSeparateTestFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync(); + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + ChatMessage[] resumeMessages = + [ + new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + new(ChatRole.Tool, [new TextContent("extra context in separate message")]) + ]; + + List secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync(); + + secondCallUpdates.Should().NotBeEmpty(); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == emittedRequest.CallId, "response+regular content split across messages should not re-emit the handled external request"); + secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Should() + .BeEmpty(); + } + + [Fact] + public async Task Test_AsAgent_MatchingResponse_DoesNotCauseExtraTurnAsync() + { + const string CallId = "matching-response-call-id"; + const string FunctionName = "matchingResponseFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync(); + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + List secondCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + session).ToListAsync(); + + int functionCallCount = secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Count(c => c.CallId == emittedRequest.CallId); + + functionCallCount.Should().Be(1, "a matching external response should not trigger an extra TurnToken-driven turn"); + } + + [Fact] + public async Task Test_AsAgent_MixedResponseAndRegularMessage_CrossExecutorStartExecutorIsReawakenedAsync() + { + const string StartExecutorId = "start-executor"; + const string KickoffInputText = "Start"; + const string KickoffMessageText = "kickoff downstream"; + const string ResumeRegularText = "resume regular"; + const string ResumeProcessedText = "regular message processed"; + const string CallId = "cross-executor-call-id"; + const string FunctionName = "crossExecutorFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true); + ExecutorBinding requestBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + + KickoffOnStartExecutor startExecutor = new( + StartExecutorId, + requestBinding.Id, + KickoffInputText, + KickoffMessageText, + ResumeRegularText, + ResumeProcessedText); + ExecutorBinding startBinding = startExecutor.BindExecutor(); + + Workflow workflow = new WorkflowBuilder(startBinding) + .AddEdge>(startBinding, requestBinding, messages => + messages?.Any(message => message.Contents.OfType().Any(content => content.Text == KickoffMessageText)) == true) + .AddEdge(startBinding, requestBinding, _ => true) + .Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, KickoffInputText), + session).ToListAsync(); + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + ChatMessage[] resumeMessages = + [ + new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + new(ChatRole.User, ResumeRegularText) + ]; + + List secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync(); + List textContents = [.. secondCallUpdates.SelectMany(update => update.Contents.OfType()).Select(content => content.Text)]; + + textContents.Should().Contain(ResumeProcessedText, "the start executor should receive an explicit TurnToken when the matched response wakes a different executor"); + textContents.Should().Contain("Request processed", "the matched external response should still be delivered to the downstream request owner"); + secondCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Should() + .NotContain(c => c.CallId == emittedRequest.CallId, "the handled external request should not be re-emitted while waking the start executor"); + secondCallUpdates.SelectMany(u => u.Contents.OfType()).Should().BeEmpty(); + } + + [Fact] + public async Task Test_AsAgent_UnmatchedResponse_TriggersTurnAndKeepsProgressingAsync() + { + const string CallId = "unmatched-response-call-id"; + const string FunctionName = "unmatchedResponseFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync(); + firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent)); + + List secondCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("different-call-id", "tool output")]), + session).ToListAsync(); + + int functionCallCount = secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Count(c => c.CallId == CallId); + + functionCallCount.Should().Be(1, "an unmatched response should be treated as regular input and still drive a TurnToken continuation without workflow errors"); + secondCallUpdates.SelectMany(u => u.Contents.OfType()).Should().BeEmpty(); + } + + /// + /// Tests that when a resume contains only an external response directed at a non-start executor + /// (no regular messages), the start executor still receives a TurnToken and is activated. + /// This is a regression test for the case where the TurnToken was previously skipped because + /// HasRegularMessages was , leaving the start executor dormant. + /// + [Fact] + public async Task Test_AsAgent_ResponseOnlyToNonStartExecutor_StartExecutorIsStillActivatedAsync() + { + // Arrange + const string StartExecutorId = "start-executor"; + const string ActivatedMarker = "start-executor-activated"; + const string CallId = "response-only-call-id"; + const string FunctionName = "responseOnlyFunction"; + + RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true); + ExecutorBinding requestBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + + TurnTrackingStartExecutor startExecutor = new(StartExecutorId, requestBinding.Id, ActivatedMarker); + ExecutorBinding startBinding = startExecutor.BindExecutor(); + + Workflow workflow = new WorkflowBuilder(startBinding) + .AddEdge>(startBinding, requestBinding, messages => + messages?.Any(m => m.Contents.OfType().Any()) == true) + .AddEdge(startBinding, requestBinding, _ => true) + .Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call triggers the downstream FunctionCallContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + FunctionCallContent emittedRequest = firstCallUpdates + .Where(u => u.RawRepresentation is RequestInfoEvent) + .SelectMany(u => u.Contents.OfType()) + .Single(); + + // Act 2: Resume with ONLY the external response (no regular messages) + List secondCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]), + session).ToListAsync(); + + // Assert: Both the downstream and start executor should have been activated + List textContents = [.. secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Select(c => c.Text)]; + + textContents.Should().Contain("Request processed", + "the downstream executor should process the external response"); + textContents.Should().Contain(ActivatedMarker, + "the start executor should receive a TurnToken and be activated even when resume contains only an external response"); + secondCallUpdates + .SelectMany(u => u.Contents.OfType()) + .Should() + .BeEmpty(); + } + + private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync) + { + // Arrange + AIAgent workflowAgent = workflow.AsAIAgent(); + + // Act + AgentSession session = await workflowAgent.CreateSessionAsync(); + AgentResponse response; + if (runAsync) + { + List updates = []; + await foreach (AgentResponseUpdate update in workflowAgent.RunStreamingAsync(session)) + { + // Skip WorkflowEvent updates, which do not get persisted in ChatHistory; we cannot skip + // them after because of a deleterious interaction with .ToAgentResponse() due to the + // empty initial message (which is created without a MessageId). When running through the + // message merger, it does the right thing internally. + if (!string.IsNullOrEmpty(update.Text)) + { + updates.Add(update); + } + } + + response = updates.ToAgentResponse(); + } + else + { + response = await workflowAgent.RunAsync(session); + } + + // Assert + WorkflowSession workflowSession = session.Should().BeOfType().Subject; + + ChatMessage[] responseMessages = response.Messages.Where(message => message.Contents.Any()) + .ToArray(); + + ChatMessage[] sessionMessages = workflowSession.ChatHistoryProvider.GetAllMessages(workflowSession) + .ToArray(); + + // Since we never sent an incoming message, the expectation is that there should be nothing in the session + // except the response + responseMessages.Should().BeEquivalentTo(sessionMessages, options => options.WithStrictOrdering()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_SingleAgent_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow singleAgentWorkflow = new WorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(singleAgentWorkflow, runAsync); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_Handoffs_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync); + } } 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(); } diff --git a/python/.github/skills/python-code-quality/SKILL.md b/python/.github/skills/python-code-quality/SKILL.md index 9a1ba521b3..29ac63e4fe 100644 --- a/python/.github/skills/python-code-quality/SKILL.md +++ b/python/.github/skills/python-code-quality/SKILL.md @@ -13,26 +13,34 @@ description: > All commands run from the `python/` directory: ```bash -# Format code (ruff format, parallel across packages) -uv run poe fmt - -# Lint and auto-fix (ruff check, parallel across packages) -uv run poe lint +# Syntax formatting + checks (parallel across packages by default) +uv run poe syntax +uv run poe syntax -P core +uv run poe syntax -F # Format only +uv run poe syntax -C # Check only +uv run poe syntax -S # Samples only # Type checking -uv run poe pyright # Pyright (parallel across packages) -uv run poe mypy # MyPy (parallel across packages) +uv run poe pyright # Pyright fan-out across packages +uv run poe pyright -P core +uv run poe pyright -A +uv run poe mypy # MyPy fan-out across packages +uv run poe mypy -P core +uv run poe mypy -A uv run poe typing # Both pyright and mypy +uv run poe typing -P core +uv run poe typing -A -# All package-level checks in parallel (fmt + lint + pyright + mypy) +# All package-level checks in parallel (syntax + pyright) uv run poe check-packages # Full check (packages + samples + tests + markdown) uv run poe check +uv run poe check -P core # Samples only -uv run poe samples-lint # Ruff lint on samples/ -uv run poe samples-syntax # Pyright syntax check on samples/ +uv run poe check -S +uv run poe pyright -S # Markdown code blocks uv run poe markdown-code-lint @@ -40,8 +48,8 @@ uv run poe markdown-code-lint ## Pre-commit Hooks (prek) -Prek hooks run automatically on commit. They check only changed files and run -package-level checks in parallel for affected packages only. +Prek hooks run automatically on commit. They stay lightweight and only check +changed files. ```bash # Install hooks @@ -54,8 +62,10 @@ uv run prek run -a uv run prek run --last-commit ``` -When core package changes, type-checking (mypy, pyright) runs across all packages -since type changes propagate. Format and lint only run in changed packages. +They run changed-package syntax formatting/checking, markdown code lint only +when markdown files change, and sample syntax lint/pyright only when files +under `samples/` change. +They intentionally do not run workspace `pyright` or `mypy` by default. ## Ruff Configuration @@ -80,6 +90,6 @@ in-process with streaming output. CI splits into 4 parallel jobs: 1. **Pre-commit hooks** — lightweight hooks (SKIP=poe-check) -2. **Package checks** — fmt/lint/pyright via check-packages -3. **Samples & markdown** — samples-lint, samples-syntax, markdown-code-lint +2. **Package checks** — syntax/pyright via check-packages +3. **Samples & markdown** — `check -S` plus `markdown-code-lint` 4. **Mypy** — change-detected mypy checks diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index e954480e75..814410e73d 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -47,17 +47,17 @@ uv run poe upgrade-dev-dependencies # First, run workspace-wide lower/upper compatibility gates uv run poe validate-dependency-bounds-test -# Defaults to --project "*"; pass a package to scope test mode -uv run poe validate-dependency-bounds-test --project +# Defaults to --package "*"; pass a package to scope test mode +uv run poe validate-dependency-bounds-test --package core # Then expand bounds for one dependency in the target package -uv run poe validate-dependency-bounds-project --mode both --project --dependency "" +uv run poe validate-dependency-bounds-project --mode both --package core --dependency "" # Repo-wide automation can reuse the same task -uv run poe validate-dependency-bounds-project --mode upper --project "*" +uv run poe validate-dependency-bounds-project --mode upper --package "*" # Add a dependency to one project and run both validators for that project/dependency -uv run poe add-dependency-and-validate-bounds --project --dependency "" +uv run poe add-dependency-and-validate-bounds --package core --dependency "" ``` ### Dependency Bound Notes @@ -66,7 +66,7 @@ uv run poe add-dependency-and-validate-bounds --project - Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds with an explicit upper cap (avoid open-ended ranges). - For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may be a patch line, a minor line, or multiple minor lines when checks/tests show the broader lane is compatible. - Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths. -- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--project "*"` and omitting `--dependency`. +- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`. - Prefer targeted lock updates with `uv lock --upgrade-package ` to reduce `uv.lock` merge conflicts. - Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command. - Use `upgrade-dev-dependencies` for repo-wide dev tooling refreshes; it repins dev dependencies, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`. @@ -108,12 +108,12 @@ def __getattr__(name: str) -> Any: Recommended dependency workflow during connector implementation: 1. Add the dependency to the target package: - `uv run poe add-dependency-to-project --project --dependency ""` + `uv run poe add-dependency-to-project --package core --dependency ""` 2. Implement connector code and tests. 3. Validate dependency bounds for that package/dependency: - `uv run poe validate-dependency-bounds-project --mode both --project --dependency ""` + `uv run poe validate-dependency-bounds-project --mode both --package core --dependency ""` 4. If the package has meaningful tests/checks that validate dependency compatibility, you can use the add + validation flow in one command: - `uv run poe add-dependency-and-validate-bounds --project --dependency ""` + `uv run poe add-dependency-and-validate-bounds --package core --dependency ""` If compatibility checks are not in place yet, add the dependency first, then implement tests before running bound validation. ### Promotion to Stable diff --git a/python/.github/skills/python-samples/SKILL.md b/python/.github/skills/python-samples/SKILL.md index b70862eb8a..be992e0771 100644 --- a/python/.github/skills/python-samples/SKILL.md +++ b/python/.github/skills/python-samples/SKILL.md @@ -41,11 +41,14 @@ Do **not** add sample-only dependencies to the root `pyproject.toml` dev group. ## Syntax Checking ```bash -# Check samples for syntax errors and missing imports -uv run poe samples-syntax +# Format + lint samples +uv run poe syntax -S -# Lint samples -uv run poe samples-lint +# Check samples for syntax errors and missing imports +uv run poe pyright -S + +# Lint samples only +uv run poe syntax -S -C ``` ## Documentation diff --git a/python/.github/skills/python-testing/SKILL.md b/python/.github/skills/python-testing/SKILL.md index 4b61f27a55..b9c874a694 100644 --- a/python/.github/skills/python-testing/SKILL.md +++ b/python/.github/skills/python-testing/SKILL.md @@ -17,20 +17,27 @@ We run tests in two stages, for a PR each commit is tested with unit tests only # Run tests for all packages in parallel uv run poe test -# Run tests for a specific package -uv run --directory packages/core poe test +# Run tests for a specific workspace package +uv run poe test -P core -# Run all tests in a single pytest invocation (faster, uses pytest-xdist) -uv run poe all-tests +# Run all selected tests in a single pytest invocation +uv run poe test -A # With coverage -uv run poe all-tests-cov +uv run poe test -A -C +uv run poe test -P core -C # Run only unit tests (exclude integration tests) -uv run poe all-tests -m "not integration" +uv run poe test -A -m "not integration" # Run only integration tests -uv run poe all-tests -m integration +uv run poe test -A -m integration +``` + +Direct package execution still works when you need it: + +```bash +uv run --directory packages/core poe test ``` ## Test Configuration @@ -38,7 +45,7 @@ uv run poe all-tests -m integration - **Async mode**: `asyncio_mode = "auto"` is enabled — do NOT use `@pytest.mark.asyncio`, but do mark tests with `async def` and use `await` for async calls - **Timeout**: Default 60 seconds per test - **Import mode**: `importlib` for cross-package isolation -- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The `all-tests` task also uses xdist across all packages. +- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The aggregate `uv run poe test -A` sweep also uses xdist across the selected packages. ## Test Directory Structure diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml index dfdf60a61b..bbb2683c5c 100644 --- a/python/.pre-commit-config.yaml +++ b/python/.pre-commit-config.yaml @@ -52,10 +52,10 @@ repos: hooks: - id: poe-check name: Run checks through Poe - entry: uv run poe prek-check + entry: uv run python scripts/workspace_poe_tasks.py prek-check language: system - repo: https://github.com/PyCQA/bandit - rev: 1.9.3 + rev: 1.9.4 hooks: - id: bandit name: Bandit Security Checks @@ -63,7 +63,7 @@ repos: additional_dependencies: ["bandit[toml]"] - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.10.0 + rev: 0.10.10 hooks: # Update the uv lockfile - id: uv-lock diff --git a/python/.vscode/tasks.json b/python/.vscode/tasks.json index fc9ce278b3..ed5ac4997d 100644 --- a/python/.vscode/tasks.json +++ b/python/.vscode/tasks.json @@ -9,9 +9,8 @@ "command": "uv", "args": [ "run", - "prek", - "run", - "-a" + "poe", + "check" ], "problemMatcher": { "owner": "python", @@ -32,13 +31,13 @@ } }, { - "label": "Format", + "label": "Syntax", "type": "shell", "command": "uv", "args": [ "run", "poe", - "fmt", + "syntax", ], "problemMatcher": { "owner": "python", @@ -59,13 +58,42 @@ } }, { - "label": "Lint", + "label": "Syntax (format only)", "type": "shell", "command": "uv", "args": [ "run", "poe", - "lint", + "syntax", + "-F", + ], + "problemMatcher": { + "owner": "python", + "fileLocation": [ + "relative", + "${workspaceFolder}" + ], + "pattern": { + "regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + }, + "presentation": { + "panel": "shared" + } + }, + { + "label": "Syntax (check only)", + "type": "shell", + "command": "uv", + "args": [ + "run", + "poe", + "syntax", + "-C", ], "problemMatcher": { "owner": "python", @@ -169,7 +197,14 @@ { "label": "Create Venv", "type": "shell", - "command": "uv venv PYTHON=${input:py_version}", + "command": "uv", + "args": [ + "run", + "poe", + "venv", + "-P", + "${input:py_version}" + ], "presentation": { "reveal": "always", "panel": "new" @@ -184,7 +219,8 @@ "run", "poe", "setup", - "--python=${input:py_version}" + "-P", + "${input:py_version}" ], "presentation": { "reveal": "always", @@ -200,11 +236,12 @@ "3.10", "3.11", "3.12", - "3.13" + "3.13", + "3.14" ], "id": "py_version", "description": "Python version", - "default": "3.10" + "default": "3.13" } ] -} \ No newline at end of file +} 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/CODING_STANDARD.md b/python/CODING_STANDARD.md index be894dc545..d02b22e088 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -403,7 +403,7 @@ So we use bounded ranges for external package dependencies in `pyproject.toml`: - For `<1.0.0` dependencies, use a known-good bounded range with an explicit upper cap. Prefer the broadest validated range the package can actually support: that may be a patch line, a minor line, or multiple minor lines (for example: `a2a-sdk>=0.3.5,<0.4.0`, `fastapi>=0.115.0,<0.136.0`, `uvicorn>=0.30.0,<0.39.0`). - For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good bounded range with a hard upper cap and keep the range only as broad as the package's validation coverage justifies. - Prefer keeping support for multiple major versions when practical. This may mean that the upper bound spans multiple major versions when the dependency maintains backward compatibility; if APIs differ between supported majors, version-conditional imports/branches are acceptable to preserve compatibility. -- When adding or changing an external dependency, first run `uv run poe validate-dependency-bounds-test` to validate workspace-wide lower/upper compatibility, then run `uv run poe validate-dependency-bounds-project --mode both --project --dependency ""` to expand package-scoped bounds. +- When adding or changing an external dependency, first run `uv run poe validate-dependency-bounds-test` to validate workspace-wide lower/upper compatibility, then run `uv run poe validate-dependency-bounds-project --mode both --package --dependency ""` to expand package-scoped bounds. ### Installation Options diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index fa6619b899..d90e29226d 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -123,28 +123,39 @@ client = OpenAIChatClient(env_file_path="openai.env") All the tests are located in the `tests` folder of each package. Tests marked with `@pytest.mark.integration` and `@skip_if_..._integration_tests_disabled` are integration tests that require external services (e.g., OpenAI, Azure OpenAI). They are automatically skipped when the required API keys or service endpoints are not configured in your environment or `.env` file. -You can select or exclude integration tests using pytest markers: +The root `test` command now supports both project-scoped fan-out and a single aggregate sweep: ```bash -# Run only unit tests (exclude integration tests) -uv run poe all-tests -m "not integration" +# Run package-local tests across all workspace packages +uv run poe test -# Run only integration tests -uv run poe all-tests -m integration +# Run tests for one workspace package +uv run poe test -P core + +# Run an aggregate pytest sweep across the selected packages +uv run poe test -A + +# Run only unit tests in aggregate mode +uv run poe test -A -m "not integration" + +# Run only integration tests in aggregate mode +uv run poe test -A -m integration + +# Run tests with coverage for one package or an aggregate sweep +uv run poe test -P core -C +uv run poe test -A -C ``` Alternatively, you can run them using VSCode Tasks. Open the command palette (`Ctrl+Shift+P`) and type `Tasks: Run Task`. Select `Test` from the list. -If you want to run the tests for a single package, you can use the `uv run poe test` command with the package name as an argument. For example, to run the tests for the `agent_framework` package, you can use: +Direct package execution still works when you need it: ```bash uv run poe --directory packages/core test ``` -Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The `all-tests` task also uses xdist across all packages. - -These commands also output the coverage report. +Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The aggregate `test -A` sweep also uses `pytest-xdist` across the selected packages. ## Code quality checks @@ -158,10 +169,11 @@ Ideally you should run these checks before committing any changes, when you inst ## Code Coverage -We try to maintain a high code coverage for the project. To run the code coverage on the unit tests, you can use the following command: +We try to maintain a high code coverage for the project. To review coverage locally, use either a package-scoped run or the aggregate sweep: ```bash - uv run poe test +uv run poe test -P core -C +uv run poe test -A -C ``` This will show you which files are not covered by the tests, including the specific lines not covered. Make sure to consider the untested lines from the code you are working on, but feel free to add other tests as well, that is always welcome! @@ -213,7 +225,7 @@ Set up the development environment with a virtual environment, install dependenc ```bash uv run poe setup # or with specific Python version -uv run poe setup --python 3.12 +uv run poe setup -P 3.12 ``` #### `install` @@ -230,7 +242,7 @@ Create a virtual environment with specified Python version or switch python vers ```bash uv run poe venv # or with specific Python version -uv run poe venv --python 3.12 +uv run poe venv -P 3.12 ``` #### `prek-install` @@ -239,41 +251,89 @@ Install prek hooks: uv run poe prek-install ``` -### Code Quality and Formatting +### Project-scoped command families -Each of the following tasks run against both the main `agent-framework` package and the extension packages in parallel, ensuring consistent code quality across the project. +These commands default to `--package "*"`, so they run across all workspace packages unless you narrow them with `-P/--package`: -#### `fmt` (format) -Format code using ruff (runs in parallel across all packages): +#### `syntax` +Run Ruff formatting plus Ruff lint checks by default: ```bash -uv run poe fmt +uv run poe syntax +uv run poe syntax -P core +uv run poe syntax -F # format only +uv run poe syntax -C # lint/check only ``` -#### `lint` -Run linting checks and fix issues (runs in parallel across all packages): +#### `build` +Build workspace packages and the root meta package: ```bash -uv run poe lint +uv run poe build +uv run poe build -P core +``` + +#### `clean-dist` +Clean generated dist artifacts: +```bash +uv run poe clean-dist +uv run poe clean-dist -P core +``` + +### Dual-mode validation and test commands + +These command families share the same selector model: + +```bash +uv run poe # project fan-out over --package "*" +uv run poe -P core # one-project fan-out +uv run poe -A # aggregate sweep where supported ``` #### `pyright` -Run Pyright type checking (runs in parallel across all packages): +Run Pyright type checking: ```bash uv run poe pyright +uv run poe pyright -P core +uv run poe pyright -A ``` #### `mypy` -Run MyPy type checking (runs in parallel across all packages): +Run MyPy type checking: ```bash uv run poe mypy +uv run poe mypy -P core +uv run poe mypy -A ``` #### `typing` -Run both Pyright and MyPy type checking: +Run both Pyright and MyPy: ```bash uv run poe typing +uv run poe typing -P core +uv run poe typing -A ``` -### Code Validation +#### `test` +Run package-local tests in fan-out mode, or switch to one aggregate pytest sweep with `-A`: +```bash +uv run poe test +uv run poe test -P core +uv run poe test -P core -C +uv run poe test -A +uv run poe test -A -C +``` + +### Sample-target variants + +Use `-S/--samples` for sample-only validation instead of separate top-level commands: + +```bash +uv run poe syntax -S +uv run poe syntax -S -C +uv run poe pyright -S +uv run poe check -S +``` + +### Workspace validation and dependency commands #### `markdown-code-lint` Lint markdown code blocks: @@ -281,26 +341,41 @@ Lint markdown code blocks: uv run poe markdown-code-lint ``` +#### `check-packages` +Run the package-level syntax sweep (`syntax`) plus `pyright` across the selected projects: +```bash +uv run poe check-packages +uv run poe check-packages -P core +``` + +#### `check` +Run package syntax, pyright, and tests for the selected project set. Without `-P/--package`, it also includes sample checks and markdown lint: +```bash +uv run poe check +uv run poe check -P core +uv run poe check -S +``` + #### `validate-dependency-bounds-test` Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs test + pyright across all packages and stops on first failure: ```bash uv run poe validate-dependency-bounds-test -# Defaults to --project "*"; pass a package to scope test mode -uv run poe validate-dependency-bounds-test --project +# Defaults to --package "*"; pass a package to scope test mode +uv run poe validate-dependency-bounds-test -P core ``` #### `validate-dependency-bounds-project` Validate and extend dependency bounds for a single dependency in a single package. Use `--mode lower`, `--mode upper`, or the default `--mode both`: ```bash -uv run poe validate-dependency-bounds-project --mode both --project --dependency "" +uv run poe validate-dependency-bounds-project -M both -P core -D "" ``` -`--project` defaults to `*`, and `--dependency` is optional. Automation can use `--mode upper --project "*"` to run the upper-bound pass across the workspace. +`--package` defaults to `*`, and `--dependency` is optional. Automation can use `--mode upper --package "*"` to run the upper-bound pass across the workspace. For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may still be a single patch or minor line, but multi-minor ranges are fine when the package's checks/tests prove they work. #### `add-dependency-and-validate-bounds` Add an external dependency to a workspace project and run both validators for that same project/dependency: ```bash -uv run poe add-dependency-and-validate-bounds --project --dependency "" +uv run poe add-dependency-and-validate-bounds -P core -D "" ``` #### `upgrade-dev-dependencies` @@ -310,72 +385,40 @@ uv run poe upgrade-dev-dependencies ``` Use this for repo-wide dev tooling refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package ` plus the package-scoped bound validation tasks above. -### Comprehensive Checks - -#### `check-packages` -Run all package-level quality checks (format, lint, pyright, mypy) in parallel across all packages. This runs the full cross-product of (package × check) concurrently: -```bash -uv run poe check-packages -``` - -#### `check` -Run all quality checks including package checks, samples, tests and markdown lint: -```bash -uv run poe check -``` - -### Testing - -#### `test` -Run unit tests with coverage by invoking the `test` task in each package in parallel: -```bash -uv run poe test -``` - -To run tests for a specific package only, use the `--directory` flag: -```bash -# Run tests for the core package -uv run --directory packages/core poe test - -# Run tests for the azure-ai package -uv run --directory packages/azure-ai poe test -``` - -#### `all-tests` -Run all tests in a single pytest invocation across all packages in parallel (excluding lab and devui). This is faster than `test` as it uses pytest's parallel execution: -```bash -uv run poe all-tests -``` - -#### `all-tests-cov` -Same as `all-tests` but with coverage reporting enabled: -```bash -uv run poe all-tests-cov -``` - ### Building and Publishing -#### `build` -Build all packages: -```bash -uv run poe build -``` - -#### `clean-dist` -Clean the dist directories: -```bash -uv run poe clean-dist -``` - #### `publish` Publish packages to PyPI: ```bash uv run poe publish ``` +### Compatibility aliases + +These legacy commands still work during the transition, but prefer the newer forms above: + +```bash +uv run poe fmt # prefer: uv run poe syntax -F +uv run poe format # prefer: uv run poe syntax -F +uv run poe lint # prefer: uv run poe syntax -C +uv run poe all-tests # prefer: uv run poe test -A +uv run poe all-tests-cov # prefer: uv run poe test -A -C +uv run poe samples-lint # prefer: uv run poe syntax -S -C +uv run poe samples-syntax # prefer: uv run poe pyright -S +``` + ## Prek Hooks -Prek hooks run automatically on commit and execute a subset of the checks on changed files only. Package-level checks (fmt, lint, pyright) run in parallel but only for packages with changed files. Markdown and sample checks are skipped when no relevant files were changed. If the `core` package is changed, all packages are checked. You can also run all checks using prek directly: +Prek hooks run automatically on commit and stay intentionally lightweight: + +- changed-package syntax formatting +- changed-package syntax lint/check +- markdown code lint only when markdown files change +- sample lint + sample pyright only when files under `samples/` change + +They do **not** run workspace `pyright` or `mypy` by default. Use `uv run poe pyright`, `uv run poe mypy`, `uv run poe typing`, `uv run poe check-packages`, or `uv run poe check` when you want deeper validation. + +You can run the installed hooks directly with: ```bash uv run prek run -a diff --git a/python/README.md b/python/README.md index 6e7a2c50f2..f9350a08a4 100644 --- a/python/README.md +++ b/python/README.md @@ -24,14 +24,14 @@ If you only need specific integrations, you can install at a more granular level # also includes workflows and orchestrations pip install agent-framework-core --pre -# Core + Azure AI integration -pip install agent-framework-azure-ai --pre +# Core + Azure AI Foundry integration +pip install agent-framework-foundry --pre # Core + Microsoft Copilot Studio integration pip install agent-framework-copilotstudio --pre -# Core + both Microsoft Copilot Studio and Azure AI integration -pip install agent-framework-microsoft agent-framework-azure-ai --pre +# Core + both Microsoft Copilot Studio and Azure AI Foundry integration +pip install agent-framework-microsoft agent-framework-foundry --pre ``` This selective approach is useful when you know which integrations you need, and it is the recommended way to set up lightweight environments. @@ -53,8 +53,8 @@ AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=... ... -AZURE_AI_PROJECT_ENDPOINT=... -AZURE_AI_MODEL_DEPLOYMENT_NAME=... +FOUNDRY_PROJECT_ENDPOINT=... +FOUNDRY_MODEL=... ``` You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index e11aa668da..4b6d9cc19b 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -35,10 +35,12 @@ from agent_framework import ( AgentResponseUpdate, AgentSession, BaseAgent, + BaseHistoryProvider, Content, ContinuationToken, Message, ResponseStream, + SessionContext, normalize_messages, prepend_agent_framework_to_user_agent, ) @@ -284,17 +286,37 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): When stream=True: A ResponseStream of AgentResponseUpdate items. """ del function_invocation_kwargs, client_kwargs, kwargs + normalized_messages = normalize_messages(messages) + if continuation_token is not None: a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe( TaskIdParams(id=continuation_token["task_id"]) ) else: - normalized_messages = normalize_messages(messages) + if not normalized_messages: + raise ValueError("At least one message is required when starting a new task (no continuation_token).") a2a_message = self._prepare_message_for_a2a(normalized_messages[-1]) a2a_stream = self.client.send_message(a2a_message) + provider_session = session + if provider_session is None and self.context_providers: + provider_session = AgentSession() + + session_context = SessionContext( + session_id=provider_session.session_id if provider_session else None, + service_session_id=provider_session.service_session_id if provider_session else None, + input_messages=normalized_messages or [], + options={}, + ) + response = ResponseStream( - self._map_a2a_stream(a2a_stream, background=background), + self._map_a2a_stream( + a2a_stream, + background=background, + emit_intermediate=stream, + session=provider_session, + session_context=session_context, + ), finalizer=AgentResponse.from_updates, ) if stream: @@ -306,6 +328,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): a2a_stream: AsyncIterable[A2AStreamItem], *, background: bool = False, + emit_intermediate: bool = False, + session: AgentSession | None = None, + session_context: SessionContext | None = None, ) -> AsyncIterable[AgentResponseUpdate]: """Map raw A2A protocol items to AgentResponseUpdates. @@ -316,37 +341,84 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): background: When False, in-progress task updates are silently consumed (the stream keeps iterating until a terminal state). When True, they are yielded with a continuation token. + emit_intermediate: When True, in-progress status updates that + carry message content are yielded to the caller. Typically + set for streaming callers so non-streaming consumers only + receive terminal task outputs. + session: The agent session for context providers. + session_context: The session context for context providers. """ + if session_context is None: + session_context = SessionContext(input_messages=[], options={}) + + # Run before_run providers (forward order) + for provider in self.context_providers: + if isinstance(provider, BaseHistoryProvider) and not provider.load_messages: + continue + if session is None: + raise RuntimeError("Provider session must be available when context providers are configured.") + await provider.before_run( + agent=self, # type: ignore[arg-type] + session=session, + context=session_context, + state=session.state.setdefault(provider.source_id, {}), + ) + + all_updates: list[AgentResponseUpdate] = [] async for item in a2a_stream: if isinstance(item, A2AMessage): # Process A2A Message contents = self._parse_contents_from_a2a(item.parts) - yield AgentResponseUpdate( + update = AgentResponseUpdate( contents=contents, role="assistant" if item.role == A2ARole.agent else "user", response_id=str(getattr(item, "message_id", uuid.uuid4())), raw_representation=item, ) + all_updates.append(update) + yield update elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task): task, _update_event = item - for update in self._updates_from_task(task, background=background): + for update in self._updates_from_task( + task, + background=background, + emit_intermediate=emit_intermediate, + ): + all_updates.append(update) yield update else: raise NotImplementedError("Only Message and Task responses are supported") + # Set the response on the context for after_run providers + if all_updates: + session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment] + + await self._run_after_providers(session=session, context=session_context) + # ------------------------------------------------------------------ # Task helpers # ------------------------------------------------------------------ - def _updates_from_task(self, task: Task, *, background: bool = False) -> list[AgentResponseUpdate]: + def _updates_from_task( + self, + task: Task, + *, + background: bool = False, + emit_intermediate: bool = False, + ) -> list[AgentResponseUpdate]: """Convert an A2A Task into AgentResponseUpdate(s). Terminal tasks produce updates from their artifacts/history. - In-progress tasks produce a continuation token update only when - ``background=True``; otherwise they are silently skipped so the - caller keeps consuming the stream until completion. + In-progress tasks produce a continuation token update when + ``background=True``. When ``emit_intermediate=True`` (typically + set for streaming callers), any message content attached to an + in-progress status update is surfaced; otherwise the update is + silently skipped so the caller keeps consuming the stream until + completion. """ - if task.status.state in TERMINAL_TASK_STATES: + status = task.status + + if status.state in TERMINAL_TASK_STATES: task_messages = self._parse_messages_from_task(task) if task_messages: return [ @@ -361,7 +433,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ] return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)] - if background and task.status.state in IN_PROGRESS_TASK_STATES: + if background and status.state in IN_PROGRESS_TASK_STATES: token = self._build_continuation_token(task) return [ AgentResponseUpdate( @@ -373,6 +445,26 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ) ] + # Surface message content from in-progress status updates (e.g. working state) + # Only emitted when the caller opts in (streaming), so non-streaming + # consumers keep receiving only terminal task outputs. + if ( + emit_intermediate + and status.state in IN_PROGRESS_TASK_STATES + and status.message is not None + and status.message.parts + ): + contents = self._parse_contents_from_a2a(status.message.parts) + if contents: + return [ + AgentResponseUpdate( + contents=contents, + role="assistant" if status.message.role == A2ARole.agent else "user", + response_id=task.id, + raw_representation=task, + ) + ] + return [] @staticmethod diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index fece606606..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", ] @@ -85,9 +85,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a" -test = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index a426c27a7f..0d81179cd1 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -23,11 +23,14 @@ from a2a.types import Role as A2ARole from agent_framework import ( AgentResponse, AgentResponseUpdate, + AgentSession, + BaseContextProvider, Content, Message, + SessionContext, ) from agent_framework.a2a import A2AAgent -from pytest import fixture, raises +from pytest import fixture, mark, raises from agent_framework_a2a import A2AContinuationToken from agent_framework_a2a._agent import _get_uri_data # type: ignore @@ -88,9 +91,18 @@ class MockA2AClient: task_id: str, context_id: str = "test-context", state: TaskState = TaskState.working, + text: str | None = None, + role: A2ARole = A2ARole.agent, ) -> None: """Add a mock in-progress Task response (non-terminal).""" - status = TaskStatus(state=state, message=None) + message = None + if text is not None: + message = A2AMessage( + message_id=str(uuid4()), + role=role, + parts=[Part(root=TextPart(text=text))], + ) + status = TaskStatus(state=state, message=message) task = Task(id=task_id, context_id=context_id, status=status) client_event = (task, None) self.responses.append(client_event) @@ -99,9 +111,10 @@ class MockA2AClient: """Mock send_message method that yields responses.""" self.call_count += 1 - if self.responses: - response = self.responses.pop(0) + # All queued responses are delivered as a single streaming batch per call. + for response in self.responses: yield response + self.responses.clear() async def resubscribe(self, request: Any) -> AsyncIterator[Any]: """Mock resubscribe method that yields responses.""" @@ -851,3 +864,329 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A # endregion + + +# region Context Provider Tests + + +class TrackingContextProvider(BaseContextProvider): + """A context provider that records when before_run and after_run are called.""" + + def __init__(self) -> None: + super().__init__(source_id="tracking-provider") + self.before_run_called = False + self.after_run_called = False + self.before_run_context: SessionContext | None = None + self.after_run_context: SessionContext | None = None + + async def before_run( + self, + *, + agent: Any, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + self.before_run_called = True + self.before_run_context = context + + async def after_run( + self, + *, + agent: Any, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + self.after_run_called = True + self.after_run_context = context + + +async def test_run_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None: + """Test that context providers are invoked during non-streaming run.""" + provider = TrackingContextProvider() + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + context_providers=[provider], + http_client=None, + ) + mock_a2a_client.add_message_response("msg-1", "Hello from A2A") + session = agent.create_session() + + response = await agent.run("Hello", session=session) + + assert provider.before_run_called + assert provider.after_run_called + assert response.text == "Hello from A2A" + + +async def test_run_streaming_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None: + """Test that context providers are invoked during streaming run.""" + provider = TrackingContextProvider() + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + context_providers=[provider], + http_client=None, + ) + mock_a2a_client.add_message_response("msg-1", "Streamed response") + session = agent.create_session() + + stream = agent.run("Hello", stream=True, session=session) + updates = [] + async for update in stream: + updates.append(update) + + assert provider.before_run_called + assert provider.after_run_called + assert len(updates) == 1 + assert updates[0].text == "Streamed response" + + +async def test_context_providers_receive_response(mock_a2a_client: MockA2AClient) -> None: + """Test that after_run providers can access the response via session context.""" + provider = TrackingContextProvider() + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + context_providers=[provider], + http_client=None, + ) + mock_a2a_client.add_message_response("msg-1", "Response text") + session = agent.create_session() + + await agent.run("Hello", session=session) + + assert provider.after_run_context is not None + assert provider.after_run_context.response is not None + assert provider.after_run_context.response.text == "Response text" + + +async def test_context_providers_receive_input_messages(mock_a2a_client: MockA2AClient) -> None: + """Test that before_run providers can access input messages via session context.""" + provider = TrackingContextProvider() + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + context_providers=[provider], + http_client=None, + ) + mock_a2a_client.add_message_response("msg-1", "Reply") + session = agent.create_session() + + await agent.run("Hello world", session=session) + + assert provider.before_run_context is not None + assert len(provider.before_run_context.input_messages) > 0 + assert provider.before_run_context.input_messages[-1].text == "Hello world" + + +async def test_run_without_context_providers(mock_a2a_client: MockA2AClient) -> None: + """Test that run works normally when no context providers are configured.""" + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + http_client=None, + ) + mock_a2a_client.add_message_response("msg-1", "Hello") + + response = await agent.run("Hello") + + assert response.text == "Hello" + + +async def test_run_creates_session_for_providers_when_none_provided(mock_a2a_client: MockA2AClient) -> None: + """Test that a session is auto-created when context providers are configured but no session is passed.""" + provider = TrackingContextProvider() + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + context_providers=[provider], + http_client=None, + ) + mock_a2a_client.add_message_response("msg-1", "Hello") + + await agent.run("Hello") + + assert provider.before_run_called + assert provider.after_run_called + + +@mark.parametrize("messages", [None, []]) +async def test_run_raises_when_no_messages_and_no_continuation_token( + mock_a2a_client: MockA2AClient, messages: list[str] | None +) -> None: + """Test that run() raises ValueError when messages is None/empty and no continuation_token is provided.""" + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + http_client=None, + ) + + with raises(ValueError, match="At least one message is required"): + await agent.run(messages) + + +async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_client: MockA2AClient) -> None: + """Test that run() does not raise when messages is None but a continuation_token is provided.""" + task = Task( + id="task-cont", + context_id="ctx-cont", + status=TaskStatus(state=TaskState.completed, message=None), + ) + mock_a2a_client.resubscribe_responses.append((task, None)) + + agent = A2AAgent( + name="Test Agent", + client=mock_a2a_client, + http_client=None, + ) + + token = A2AContinuationToken(task_id="task-cont", context_id="ctx-cont") + response = await agent.run(None, continuation_token=token) + assert response is not None + + +# endregion + +# region Streaming with in-progress message content + + +async def test_streaming_working_updates_yield_message_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that streaming working updates with status.message yield content.""" + mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 1...") + mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 2...") + mock_a2a_client.add_task_response("task-w", [{"id": "art-w", "content": "Final result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 3 + assert updates[0].contents[0].text == "Processing step 1..." + assert updates[1].contents[0].text == "Processing step 2..." + assert updates[2].contents[0].text == "Final result" + + +async def test_streaming_single_working_update_with_message( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a single working update with message content is not dropped.""" + mock_a2a_client.add_in_progress_task_response("task-s", context_id="ctx-s", text="Thinking...") + mock_a2a_client.add_task_response("task-s", [{"id": "art-s", "content": "Done"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "Thinking..." + assert updates[0].role == "assistant" + assert updates[1].contents[0].text == "Done" + + +async def test_streaming_working_update_without_message_is_skipped( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that working updates without status.message are still silently skipped.""" + mock_a2a_client.add_in_progress_task_response("task-n", context_id="ctx-n") + mock_a2a_client.add_task_response("task-n", [{"id": "art-n", "content": "Result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].contents[0].text == "Result" + + +async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that A2ARole.user in status message maps to role='user'.""" + mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user) + mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "User echo" + assert updates[0].role == "user" + + +async def test_background_with_status_message_yields_continuation_token( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that background=True takes precedence over status message content.""" + mock_a2a_client.add_in_progress_task_response("task-bg", context_id="ctx-bg", text="Should be ignored") + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True, background=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].continuation_token is not None + assert updates[0].continuation_token["task_id"] == "task-bg" + assert updates[0].contents == [] + + +async def test_non_streaming_does_not_surface_intermediate_messages( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that run(stream=False) does not include intermediate status messages.""" + mock_a2a_client.add_in_progress_task_response("task-ns", context_id="ctx-ns", text="Intermediate") + mock_a2a_client.add_task_response("task-ns", [{"id": "art-ns", "content": "Final"}]) + + response = await a2a_agent.run("Hello") + + assert len(response.messages) == 1 + assert response.messages[0].text == "Final" + + +async def test_terminal_no_artifacts_after_working_with_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a terminal task with no artifacts after working-state messages does not re-emit the working content.""" + mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...") + # Terminal task with no artifacts and no history + status = TaskStatus(state=TaskState.completed, message=None) + task = Task(id="task-t", context_id="ctx-t", status=status) + mock_a2a_client.responses.append((task, None)) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "Working on it..." + # Terminal task with no artifacts yields an empty-contents update + assert updates[1].contents == [] + + +async def test_streaming_working_update_with_empty_parts_is_skipped( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a working update with status.message but empty parts list is skipped.""" + # Construct a message with an empty parts list (distinct from message=None) + message = A2AMessage( + message_id=str(uuid4()), + role=A2ARole.agent, + parts=[], + ) + status = TaskStatus(state=TaskState.working, message=message) + task = Task(id="task-ep", context_id="ctx-ep", status=status) + mock_a2a_client.responses.append((task, None)) + mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].contents[0].text == "Result" + + +# endregion 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..e9ce610b10 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. @@ -665,6 +684,10 @@ def _build_messages_snapshot( } ) + # Add reasoning messages so frontends that reconcile state from + # MESSAGES_SNAPSHOT retain reasoning content after streaming ends. + all_messages.extend(flow.reasoning_messages) + return MessagesSnapshotEvent(messages=all_messages) # type: ignore[arg-type] @@ -787,7 +810,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 +876,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 +933,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 @@ -1036,7 +1065,9 @@ async def run_agent_stream( # Emit MessagesSnapshotEvent if we have tool calls or results # Feature #5: Suppress intermediate snapshots for predictive tools without confirmation - should_emit_snapshot = flow.pending_tool_calls or flow.tool_results or flow.accumulated_text + should_emit_snapshot = ( + flow.pending_tool_calls or flow.tool_results or flow.accumulated_text or flow.reasoning_messages + ) if should_emit_snapshot: # Check if we should suppress for predictive tool last_tool_name = None 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/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 2e5294a6b6..5e4fced97c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -604,6 +604,10 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Mes # Handle standard tool result messages early (role="tool") to preserve provider invariants # This path maps AG‑UI tool messages to function_result content with the correct tool_call_id role_str = normalize_agui_role(msg.get("role", "user")) + if role_str == "reasoning": + # Reasoning messages are UI-only state carried in MESSAGES_SNAPSHOT. + # They should not be forwarded to the LLM provider. + continue if role_str == "tool": # Prefer explicit tool_call_id fields; fall back to backend fields only if necessary tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId") @@ -1020,6 +1024,11 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic elif "toolCallId" not in normalized_msg: normalized_msg["toolCallId"] = "" + # Normalize encrypted_value to encryptedValue for reasoning messages + if normalized_msg.get("role") == "reasoning" and "encrypted_value" in normalized_msg: + normalized_msg["encryptedValue"] = normalized_msg["encrypted_value"] + del normalized_msg["encrypted_value"] + result.append(normalized_msg) return result 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..155f559a94 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, @@ -120,6 +126,8 @@ class FlowState: tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType] interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] def get_tool_name(self, call_id: str | None) -> str | None: """Get tool name by call ID.""" @@ -224,27 +232,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 +263,7 @@ def _emit_tool_result( { "id": message_id, "role": "tool", - "toolCallId": content.call_id, + "toolCallId": call_id, "content": result_content, } ) @@ -268,7 +277,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 +285,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 +402,141 @@ 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, flow: FlowState | None = None) -> 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. + + When *flow* is provided the reasoning message is persisted into + ``flow.reasoning_messages`` so that ``_build_messages_snapshot`` can + include it in the final ``MESSAGES_SNAPSHOT``. + """ + 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)) + + # Persist reasoning into flow state for MESSAGES_SNAPSHOT. + # Accumulate reasoning text per message_id, similar to flow.accumulated_text, + # so that incremental deltas build the full reasoning string. + if flow is not None: + if text: + previous_text = flow.accumulated_reasoning.get(message_id, "") + flow.accumulated_reasoning[message_id] = previous_text + text + full_text = flow.accumulated_reasoning.get(message_id, text or "") + + # Update existing reasoning entry for this message_id if present; otherwise append a new one. + existing_entry: dict[str, Any] | None = None + for entry in flow.reasoning_messages: + if isinstance(entry, dict) and entry.get("id") == message_id: + existing_entry = entry + break + + if existing_entry is None: + reasoning_entry: dict[str, Any] = { + "id": message_id, + "role": "reasoning", + "content": full_text, + } + if content.protected_data is not None: + reasoning_entry["encryptedValue"] = content.protected_data + flow.reasoning_messages.append(reasoning_entry) + else: + existing_entry["content"] = full_text + if content.protected_data is not None: + existing_entry["encryptedValue"] = content.protected_data + + return events + + def _emit_content( content: Any, flow: FlowState, @@ -402,5 +558,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, flow) logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type) return [] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py index bfda3948ec..c68301f7d2 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -27,7 +27,7 @@ FRAMEWORK_TO_AGUI_ROLE: dict[str, str] = { "system": "system", } -ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"} +ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"} def generate_event_id() -> str: @@ -82,7 +82,7 @@ def normalize_agui_role(raw_role: Any) -> str: raw_role: Raw role value from AG-UI message Returns: - Normalized role string (user, assistant, system, or tool) + Normalized role string (user, assistant, system, tool, or reasoning) """ if not isinstance(raw_role, str): return "user" diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 74b04669b4..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" @@ -72,6 +72,10 @@ typeCheckingMode = "basic" executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui" -test = 'pytest -m "not integration" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui' 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/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() 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_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index cc4f1230df..9508b53085 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -1669,3 +1669,94 @@ def test_agui_fresh_approval_is_still_processed(): assert len(approval_contents) == 1, "Fresh approval should produce function_approval_response" assert approval_contents[0].approved is True assert approval_contents[0].function_call.name == "get_datetime" + + +class TestReasoningRoundTrip: + """Tests for reasoning message handling in inbound/outbound adapters.""" + + def test_reasoning_skipped_on_inbound(self): + """Reasoning messages from prior snapshot are not forwarded to the LLM.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "Hello"}, + {"id": "r1", "role": "reasoning", "content": "Thinking..."}, + {"id": "a1", "role": "assistant", "content": "Hi there"}, + ] + + result = agui_messages_to_agent_framework(messages_input) + + roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result] + assert "reasoning" not in roles + assert len(result) == 2 + + def test_reasoning_preserved_in_snapshot_format(self): + """Reasoning messages retain their role through snapshot normalization.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "Hello"}, + {"id": "r1", "role": "reasoning", "content": "Thinking about this..."}, + {"id": "a1", "role": "assistant", "content": "Answer"}, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + reasoning_msgs = [m for m in result if m.get("role") == "reasoning"] + assert len(reasoning_msgs) == 1 + assert reasoning_msgs[0]["content"] == "Thinking about this..." + + def test_reasoning_with_encrypted_value_in_snapshot_format(self): + """Reasoning with encryptedValue passes through snapshot normalization.""" + messages_input = [ + { + "id": "r1", + "role": "reasoning", + "content": "visible", + "encryptedValue": "secret-data", + }, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + assert len(result) == 1 + assert result[0]["role"] == "reasoning" + assert result[0]["encryptedValue"] == "secret-data" + + def test_reasoning_encrypted_value_snake_case_normalized(self): + """Snake-case encrypted_value is normalized to encryptedValue in snapshot format.""" + messages_input = [ + { + "id": "r1", + "role": "reasoning", + "content": "visible", + "encrypted_value": "snake-case-data", + }, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + assert len(result) == 1 + assert result[0]["encryptedValue"] == "snake-case-data" + assert "encrypted_value" not in result[0] + + def test_multi_turn_with_reasoning_in_prior_snapshot(self): + """Second turn with reasoning from prior snapshot does not corrupt messages.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "First question"}, + {"id": "r1", "role": "reasoning", "content": "Prior reasoning"}, + {"id": "a1", "role": "assistant", "content": "First answer"}, + {"id": "u2", "role": "user", "content": "Follow-up question"}, + ] + + result = agui_messages_to_agent_framework(messages_input) + + roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result] + # Reasoning is filtered out, other messages preserved in order + assert roles == ["user", "assistant", "user"] + # Content not corrupted + texts = [] + for m in result: + for c in m.contents or []: + if hasattr(c, "text") and c.text: + texts.append(c.text) + assert "First question" in texts + assert "First answer" in texts + assert "Follow-up question" in texts + assert "Prior reasoning" not in texts 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..0e5c329ce9 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,504 @@ 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) + + +class TestReasoningInSnapshot: + """Tests for reasoning message inclusion in MESSAGES_SNAPSHOT.""" + + def test_reasoning_persisted_to_flow_state(self): + """_emit_text_reasoning with flow persists reasoning into flow.reasoning_messages.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_persist", + text="Let me think step by step.", + ) + + _emit_text_reasoning(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["id"] == "reason_persist" + assert flow.reasoning_messages[0]["role"] == "reasoning" + assert flow.reasoning_messages[0]["content"] == "Let me think step by step." + assert "encryptedValue" not in flow.reasoning_messages[0] + + def test_reasoning_with_encrypted_value_persisted(self): + """Reasoning with protected_data preserves encryptedValue in flow state.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_enc", + text="visible reasoning", + protected_data="encrypted-data-123", + ) + + _emit_text_reasoning(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-data-123" + + def test_snapshot_includes_reasoning(self): + """_build_messages_snapshot includes reasoning messages from flow state.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + flow.accumulated_text = "Here is my answer." + flow.reasoning_messages = [ + {"id": "r1", "role": "reasoning", "content": "Thinking..."}, + ] + + snapshot = _build_messages_snapshot(flow, []) + + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages] + assert "reasoning" in roles + + def test_snapshot_preserves_reasoning_encrypted_value(self): + """Snapshot reasoning with encryptedValue is preserved end-to-end.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_e2e", + text="visible", + protected_data="secret-data", + ) + _emit_text_reasoning(content, flow) + + text_content = Content.from_text("Final answer.") + _emit_text(text_content, flow) + + snapshot = _build_messages_snapshot(flow, []) + + reasoning_msgs = [ + m + for m in snapshot.messages + if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "reasoning" + ] + assert len(reasoning_msgs) == 1 + msg = reasoning_msgs[0] + if isinstance(msg, dict): + assert msg["content"] == "visible" + assert msg["encryptedValue"] == "secret-data" + + def test_emit_content_routes_reasoning_with_flow(self): + """_emit_content passes flow to _emit_text_reasoning for persistence.""" + flow = FlowState() + content = Content.from_text_reasoning(text="routed reasoning") + + _emit_content(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "routed reasoning" + + def test_reasoning_without_flow_does_not_error(self): + """Calling _emit_text_reasoning without flow still works (backward compat).""" + content = Content.from_text_reasoning(text="no flow") + + events = _emit_text_reasoning(content) + + assert len(events) == 5 + assert isinstance(events[0], ReasoningStartEvent) + + def test_snapshot_reasoning_ordering(self): + """Reasoning messages appear after assistant text in snapshot.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + reasoning_content = Content.from_text_reasoning(id="r1", text="Thinking...") + _emit_text_reasoning(reasoning_content, flow) + + text_content = Content.from_text("Answer") + _emit_text(text_content, flow) + + snapshot = _build_messages_snapshot(flow, [{"id": "u1", "role": "user", "content": "Hi"}]) + + # user -> assistant text -> reasoning + assert len(snapshot.messages) == 3 + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages] + assert roles == ["user", "assistant", "reasoning"] + + def test_reasoning_accumulates_incremental_deltas(self): + """Multiple reasoning deltas with the same id accumulate into one entry.""" + flow = FlowState() + content1 = Content.from_text_reasoning(id="reason_inc", text="First ") + content2 = Content.from_text_reasoning(id="reason_inc", text="second ") + content3 = Content.from_text_reasoning(id="reason_inc", text="third.") + + _emit_text_reasoning(content1, flow) + _emit_text_reasoning(content2, flow) + _emit_text_reasoning(content3, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["id"] == "reason_inc" + assert flow.reasoning_messages[0]["content"] == "First second third." + + def test_reasoning_accumulates_distinct_message_ids(self): + """Reasoning entries with different ids are stored separately.""" + flow = FlowState() + content_a = Content.from_text_reasoning(id="a", text="alpha") + content_b = Content.from_text_reasoning(id="b", text="beta") + + _emit_text_reasoning(content_a, flow) + _emit_text_reasoning(content_b, flow) + + assert len(flow.reasoning_messages) == 2 + assert flow.reasoning_messages[0]["content"] == "alpha" + assert flow.reasoning_messages[1]["content"] == "beta" + + def test_reasoning_encrypted_value_updated_on_later_delta(self): + """encryptedValue is set even when it arrives with a later delta.""" + flow = FlowState() + content1 = Content.from_text_reasoning(id="enc_late", text="part1 ") + content2 = Content.from_text_reasoning(id="enc_late", text="part2", protected_data="encrypted-payload") + + _emit_text_reasoning(content1, flow) + _emit_text_reasoning(content2, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "part1 part2" + assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload" diff --git a/python/packages/ag-ui/tests/ag_ui/test_utils.py b/python/packages/ag-ui/tests/ag_ui/test_utils.py index 0f453132f7..f353d2f0a7 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_utils.py +++ b/python/packages/ag-ui/tests/ag_ui/test_utils.py @@ -450,6 +450,7 @@ def test_normalize_agui_role_valid(): assert normalize_agui_role("assistant") == "assistant" assert normalize_agui_role("system") == "system" assert normalize_agui_role("tool") == "tool" + assert normalize_agui_role("reasoning") == "reasoning" def test_normalize_agui_role_invalid(): 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/pyproject.toml b/python/packages/anthropic/pyproject.toml index 92522a9c50..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", ] @@ -85,9 +85,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic" -test = 'pytest -m "not integration" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] 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-search/README.md b/python/packages/azure-ai-search/README.md index c24677b3b0..fcd3161f94 100644 --- a/python/packages/azure-ai-search/README.md +++ b/python/packages/azure-ai-search/README.md @@ -15,7 +15,7 @@ The Azure AI Search integration provides context providers for RAG (Retrieval Au ### Basic Usage Example -See the [Azure AI Search context provider examples](../../samples/02-agents/providers/azure_ai/) which demonstrate: +See the [Azure AI Search context provider examples](../../samples/02-agents/context_providers/azure_ai_search/) which demonstrate: - Semantic search with hybrid (vector + keyword) queries - Agentic mode with Knowledge Bases for complex multi-hop reasoning diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index b2eb41e03f..0338b13416 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -16,8 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Annotation, Content, Message, SupportsGetEmbeddings from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext from agent_framework._settings import SecretString, load_settings -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes -from azure.core.credentials import AzureKeyCredential +from azure.core.credentials import AzureKeyCredential, TokenCredential from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ResourceNotFoundError from azure.search.documents.aio import SearchClient @@ -111,6 +110,8 @@ try: except ImportError: _agentic_retrieval_available = False +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + logger = logging.getLogger("agent_framework.azure_ai_search") _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10 diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index 66b5689acf..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", ] @@ -87,9 +87,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search" -test = 'pytest -m "not integration" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py index 46b1ed5b3b..401af22c51 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py @@ -2,23 +2,35 @@ import importlib.metadata -from ._agent_provider import AzureAIAgentsProvider -from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions -from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient +from ._agent_provider import AzureAIAgentsProvider # pyright: ignore[reportDeprecated] +from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated] +from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient # pyright: ignore[reportDeprecated] +from ._deprecated_azure_openai import ( + AzureOpenAIAssistantsClient, # pyright: ignore[reportDeprecated] + AzureOpenAIAssistantsOptions, + AzureOpenAIChatClient, # pyright: ignore[reportDeprecated] + AzureOpenAIChatOptions, + AzureOpenAIConfigMixin, + AzureOpenAIEmbeddingClient, # pyright: ignore[reportDeprecated] + AzureOpenAIResponsesClient, # pyright: ignore[reportDeprecated] + AzureOpenAIResponsesOptions, + AzureOpenAISettings, + AzureUserSecurityContext, +) from ._embedding_client import ( AzureAIInferenceEmbeddingClient, AzureAIInferenceEmbeddingOptions, AzureAIInferenceEmbeddingSettings, RawAzureAIInferenceEmbeddingClient, ) -from ._foundry_memory_provider import FoundryMemoryProvider -from ._project_provider import AzureAIProjectAgentProvider +from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider +from ._project_provider import AzureAIProjectAgentProvider # pyright: ignore[reportDeprecated] from ._shared import AzureAISettings try: __version__ = importlib.metadata.version(__name__) except importlib.metadata.PackageNotFoundError: - __version__ = "0.0.0" # Fallback for development mode + __version__ = "0.0.0" __all__ = [ "AzureAIAgentClient", @@ -31,7 +43,18 @@ __all__ = [ "AzureAIProjectAgentOptions", "AzureAIProjectAgentProvider", "AzureAISettings", - "FoundryMemoryProvider", + "AzureCredentialTypes", + "AzureOpenAIAssistantsClient", + "AzureOpenAIAssistantsOptions", + "AzureOpenAIChatClient", + "AzureOpenAIChatOptions", + "AzureOpenAIConfigMixin", + "AzureOpenAIEmbeddingClient", + "AzureOpenAIResponsesClient", + "AzureOpenAIResponsesOptions", + "AzureOpenAISettings", + "AzureTokenProvider", + "AzureUserSecurityContext", "RawAzureAIClient", "RawAzureAIInferenceEmbeddingClient", "__version__", 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..51589a0d84 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 @@ -17,19 +18,23 @@ from agent_framework import ( from agent_framework._mcp import MCPTool from agent_framework._settings import load_settings from agent_framework._tools import ToolTypes -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes from azure.ai.agents.aio import AgentsClient from azure.ai.agents.models import Agent as AzureAgent from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType from pydantic import BaseModel -from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions +from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated] +from ._entra_id_authentication import AzureCredentialTypes from ._shared import AzureAISettings, to_azure_ai_agent_tools if sys.version_info >= (3, 13): from typing import Self, TypeVar # type: ignore # pragma: no cover else: from typing_extensions import Self, TypeVar # type: ignore # pragma: no cover +if sys.version_info >= (3, 13): + from warnings import deprecated # type: ignore # pragma: no cover +else: + from typing_extensions import deprecated # type: ignore # pragma: no cover if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover else: @@ -46,9 +51,18 @@ OptionsCoT = TypeVar( ) +@deprecated( + "AzureAIAgentClient and the AzureAIAgentsProvider are deprecated. " + "They target the V1 Agents Service API and have no direct replacement; " + "for new Foundry projects, use FoundryAgent." +) 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 +128,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 +197,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 +233,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 +301,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 +333,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 +363,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 +398,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) @@ -385,7 +435,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): context_providers: Context providers to include during agent invocation. """ # Create the underlying client - client = AzureAIAgentClient( + client = AzureAIAgentClient( # pyright: ignore[reportDeprecated] agents_client=self._agents_client, agent_id=agent.id, agent_name=agent.name, 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..7faba8c47c 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 @@ -35,7 +36,6 @@ from agent_framework import ( ) from agent_framework._settings import load_settings from agent_framework._tools import ToolTypes -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidRequestException, @@ -91,12 +91,14 @@ from azure.ai.agents.models import ( ) from pydantic import BaseModel +from ._entra_id_authentication import AzureCredentialTypes from ._shared import AzureAISettings, resolve_file_ids, to_azure_ai_agent_tools if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover + from warnings import deprecated # type: ignore # pragma: no cover else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover + from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover if sys.version_info >= (3, 12): from typing import override # type: ignore # pragma: no cover else: @@ -118,6 +120,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. @@ -205,14 +211,25 @@ AzureAIAgentOptionsT = TypeVar( # endregion +@deprecated( + "AzureAIAgentClient is deprecated. " + "It targets the V1 Agents Service API and has no direct replacement; " + "for new Foundry projects, use FoundryAgent." +) class AzureAIAgentClient( - ChatMiddlewareLayer[AzureAIAgentOptionsT], FunctionInvocationLayer[AzureAIAgentOptionsT], + ChatMiddlewareLayer[AzureAIAgentOptionsT], ChatTelemetryLayer[AzureAIAgentOptionsT], 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. + It targets the V1 Agents Service API and has no direct replacement. + For new Foundry projects, use :class:`FoundryAgent`. + """ OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] @@ -227,6 +244,11 @@ 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. + For new Foundry projects, configure hosted tools on the Foundry agent definition + in the service 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 +278,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; " + "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", + DeprecationWarning, + stacklevel=2, + ) resolved = resolve_file_ids(file_ids) return CodeInterpreterTool(file_ids=resolved, data_sources=data_sources) @@ -266,6 +294,11 @@ 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. + For new Foundry projects, configure hosted tools on the Foundry agent definition + in the service instead. + Keyword Args: vector_store_ids: List of vector store IDs to search within. @@ -282,6 +315,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; " + "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", + DeprecationWarning, + stacklevel=2, + ) return FileSearchTool(vector_store_ids=vector_store_ids) @staticmethod @@ -293,6 +332,11 @@ 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. + For new Foundry projects, configure hosted tools on the Foundry agent definition + in the service 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 +377,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; " + "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service 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 +418,11 @@ 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. + For new Foundry projects, configure hosted tools on the Foundry agent definition + in the service 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 +455,12 @@ class AzureAIAgentClient( ) agent = ChatAgent(client, tools=[tool]) """ + warnings.warn( + "AzureAIAgentClient.get_mcp_tool() is deprecated and will be removed in a future release; " + "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", + DeprecationWarning, + stacklevel=2, + ) mcp_tool = McpTool( server_label=name.replace(" ", "_"), server_url=url or "", 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..9f96cf0c3a 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -30,10 +30,9 @@ from agent_framework import ( ) from agent_framework._settings import load_settings from agent_framework._tools import ToolTypes -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes from agent_framework.observability import ChatTelemetryLayer from agent_framework.openai import OpenAIResponsesOptions -from agent_framework.openai._responses_client import RawOpenAIResponsesClient +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( ApproximateLocation, @@ -50,12 +49,14 @@ from azure.ai.projects.models import ( from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool from azure.core.exceptions import ResourceNotFoundError +from ._entra_id_authentication import AzureCredentialTypes from ._shared import AzureAISettings, create_text_format_config, resolve_file_ids if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover + from warnings import deprecated # type: ignore # pragma: no cover else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover + from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover if sys.version_info >= (3, 12): from typing import override # type: ignore # pragma: no cover else: @@ -68,7 +69,7 @@ else: logger = logging.getLogger("agent_framework.azure") -class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False): +class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False): # type: ignore[misc, call-arg] """Azure AI Project Agent options.""" rai_config: RaiConfig @@ -88,8 +89,13 @@ AzureAIClientOptionsT = TypeVar( _DOC_INDEX_PATTERN = re.compile(r"doc_(\d+)") -class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]): - """Raw Azure AI client without middleware, telemetry, or function invocation layers. +@deprecated( + "RawAzureAIClient is deprecated. " + "Use RawFoundryAgentChatClient for low-level Foundry agent client customization, " + "or FoundryAgent for the recommended production API." +) +class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]): + """Deprecated raw Azure AI client without middleware, telemetry, or function invocation layers. Warning: **This class should not normally be used directly.** It does not include middleware, @@ -97,11 +103,12 @@ 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. + Use ``RawFoundryAgentChatClient`` for low-level Foundry agent customization, or + ``FoundryAgent`` for the recommended production API. """ OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] @@ -215,8 +222,10 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ project_client = AIProjectClient(**project_client_kwargs) should_close_client = True - # Initialize parent - super().__init__( + # Initialize parent with OpenAI client from project + super().__init__( # type: ignore + async_client=project_client.get_openai_client(), + model=azure_ai_settings.get("model"), # type: ignore[arg-type] additional_properties=additional_properties, ) @@ -680,10 +689,6 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ return result, instructions - async def _initialize_client(self) -> None: - """Initialize OpenAI client.""" - self.client = self.project_client.get_openai_client() # type: ignore - def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None: """Update the agent name in the chat client. @@ -842,7 +847,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ if not stream: async def _enrich_response() -> ChatResponse: - response = await super(RawAzureAIClient, self)._inner_get_response( + response = await super(RawAzureAIClient, self)._inner_get_response( # pyright: ignore[reportDeprecated] messages=messages, options=options, stream=False, **kwargs ) get_urls = self._extract_azure_search_urls(response.raw_representation.output) # type: ignore[union-attr] @@ -1182,8 +1187,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ It does NOT create an agent on the Azure AI service - the actual agent will be created on the server during the first invocation (run). - For creating and managing persistent agents on the server, use - :class:`~agent_framework_azure_ai.AzureAIProjectAgentProvider` instead. + For working with pre-configured persistent agents on the server, use + :class:`~agent_framework_azure_ai.FoundryAgent` instead. Keyword Args: id: The unique identifier for the agent. Will be created automatically if not provided. @@ -1213,21 +1218,23 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ ) +@deprecated("AzureAIClient is deprecated. Use FoundryAgent instead.") class AzureAIClient( - ChatMiddlewareLayer[AzureAIClientOptionsT], FunctionInvocationLayer[AzureAIClientOptionsT], + ChatMiddlewareLayer[AzureAIClientOptionsT], ChatTelemetryLayer[AzureAIClientOptionsT], - RawAzureAIClient[AzureAIClientOptionsT], + RawAzureAIClient[AzureAIClientOptionsT], # pyright: ignore[reportDeprecated] Generic[AzureAIClientOptionsT], ): - """Azure AI client with middleware, telemetry, and function invocation support. + """Deprecated Azure AI client with middleware, telemetry, and function invocation support. - This is the recommended client for most use cases. It includes: + This class is deprecated. Use ``FoundryAgent`` instead for connecting to + pre-configured agents in Foundry. It includes: - Chat middleware support for request/response interception - OpenTelemetry-based telemetry for observability - Automatic function/tool invocation handling - For a minimal implementation without these features, use :class:`RawAzureAIClient`. + For a minimal implementation without these features, use :class:`RawFoundryAgentChatClient`. """ def __init__( diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py new file mode 100644 index 0000000000..8370412394 --- /dev/null +++ b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py @@ -0,0 +1,897 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Deprecated Azure OpenAI client classes. + +All classes in this module are deprecated and will be removed in a future release. +Migrate to the ``agent_framework_openai`` package equivalents with an ``AsyncAzureOpenAI`` client, +or use ``FoundryChatClient`` for Azure AI Foundry projects. +""" + +from __future__ import annotations + +import json +import logging +import sys +from collections.abc import Mapping, Sequence +from copy import copy +from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast +from urllib.parse import urljoin, urlparse + +from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, prepend_agent_framework_to_user_agent +from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer +from agent_framework._types import Annotation, Content +from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer +from agent_framework_openai._assistants_client import OpenAIAssistantsClient, OpenAIAssistantsOptions +from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient +from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient +from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient +from agent_framework_openai._shared import OpenAIBase +from azure.ai.projects.aio import AIProjectClient +from openai import AsyncOpenAI +from openai.lib.azure import AsyncAzureOpenAI +from pydantic import BaseModel + +from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover + from warnings import deprecated # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore # pragma: no cover +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +if TYPE_CHECKING: + from agent_framework._middleware import MiddlewareTypes + from openai.types.chat.chat_completion import Choice + from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice + +logger: logging.Logger = logging.getLogger(__name__) + + +# region Constants and Settings + +DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21" +DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105 + + +class AzureOpenAISettings(TypedDict, total=False): + """AzureOpenAI model settings. + + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'AZURE_OPENAI_'. If settings are missing after resolution, validation will fail. + + Keyword Args: + endpoint: The endpoint of the Azure deployment. + chat_deployment_name: The name of the Azure Chat deployment. + responses_deployment_name: The name of the Azure Responses deployment. + embedding_deployment_name: The name of the Azure Embedding deployment. + api_key: The API key for the Azure deployment. + api_version: The API version to use. + base_url: The url of the Azure deployment. + token_endpoint: The token endpoint to use to retrieve the authentication token. + """ + + chat_deployment_name: str | None + responses_deployment_name: str | None + embedding_deployment_name: str | None + endpoint: str | None + base_url: str | None + api_key: SecretString | None + api_version: str | None + token_endpoint: str | None + + +def _apply_azure_defaults( + settings: AzureOpenAISettings, + default_api_version: str = DEFAULT_AZURE_API_VERSION, + default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT, +) -> None: + """Apply default values for api_version and token_endpoint after loading settings. + + Args: + settings: The loaded Azure OpenAI settings dict. + default_api_version: The default API version to use if not set. + default_token_endpoint: The default token endpoint to use if not set. + """ + if not settings.get("api_version"): + settings["api_version"] = default_api_version + if not settings.get("token_endpoint"): + settings["token_endpoint"] = default_token_endpoint + + +# endregion + + +# region AzureOpenAIConfigMixin + + +class AzureOpenAIConfigMixin(OpenAIBase): + """Internal class for configuring a connection to an Azure OpenAI service.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" + + def __init__( + self, + deployment_name: str, + endpoint: str | None = None, + base_url: str | None = None, + api_version: str = DEFAULT_AZURE_API_VERSION, + api_key: str | None = None, + token_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + default_headers: Mapping[str, str] | None = None, + client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + **kwargs: Any, + ) -> None: + """Configure a connection to an Azure OpenAI service. + + Args: + deployment_name: Name of the deployment. + endpoint: The specific endpoint URL for the deployment. + base_url: The base URL for Azure services. + api_version: Azure API version. + api_key: API key for Azure services. + token_endpoint: Azure AD token scope. + credential: Azure credential or token provider for authentication. + default_headers: Default headers for HTTP requests. + client: An existing client to use. + instruction_role: The role to use for 'instruction' messages. + kwargs: Additional keyword arguments. + """ + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + if not client: + ad_token_provider = None + if not api_key and credential: + ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint) + + if not api_key and not ad_token_provider: + raise ValueError("Please provide either api_key, credential, or a client.") + + if not endpoint and not base_url: + raise ValueError("Please provide an endpoint or a base_url") + + args: dict[str, Any] = { + "default_headers": merged_headers, + } + if api_version: + args["api_version"] = api_version + if ad_token_provider: + args["azure_ad_token_provider"] = ad_token_provider + if api_key: + args["api_key"] = api_key + if base_url: + args["base_url"] = str(base_url) + if endpoint and not base_url: + args["azure_endpoint"] = str(endpoint) + if deployment_name: + args["azure_deployment"] = deployment_name + if "websocket_base_url" in kwargs: + args["websocket_base_url"] = kwargs.pop("websocket_base_url") + + client = AsyncAzureOpenAI(**args) + + self.endpoint = str(endpoint) + self.base_url = str(base_url) + self.api_version = api_version + self.deployment_name = deployment_name + self.instruction_role = instruction_role + if default_headers: + from agent_framework._telemetry import USER_AGENT_KEY + + def_headers = {k: v for k, v in default_headers.items() if k != USER_AGENT_KEY} + else: + def_headers = None + self.default_headers = def_headers + + super().__init__(model_id=deployment_name, client=client, **kwargs) + + +# endregion + + +# region AzureOpenAIResponsesClient + + +AzureOpenAIResponsesOptionsT = TypeVar( + "AzureOpenAIResponsesOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OpenAIChatOptions", + covariant=True, +) + +AzureOpenAIResponsesOptions = OpenAIChatOptions + + +@deprecated( + "AzureOpenAIResponsesClient is deprecated. " + "Use OpenAIChatClient with an AsyncAzureOpenAI client, or FoundryChatClient for Foundry projects." +) +class AzureOpenAIResponsesClient( # type: ignore[misc] + FunctionInvocationLayer[AzureOpenAIResponsesOptionsT], + ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], + ChatTelemetryLayer[AzureOpenAIResponsesOptionsT], + RawOpenAIChatClient[AzureOpenAIResponsesOptionsT], + Generic[AzureOpenAIResponsesOptionsT], +): + """Deprecated Azure Responses client. Use OpenAIChatClient with an AsyncAzureOpenAI client instead.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" + + def __init__( + self, + *, + api_key: str | None = None, + deployment_name: str | None = None, + endpoint: str | None = None, + base_url: str | None = None, + api_version: str | None = None, + token_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + project_client: Any | None = None, + project_endpoint: str | None = None, + allow_preview: bool | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + instruction_role: str | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + **kwargs: Any, + ) -> None: + """Initialize an Azure OpenAI Responses client. + + Keyword Args: + api_key: The API key. + deployment_name: The deployment name. + endpoint: The deployment endpoint. + base_url: The deployment base URL. + api_version: The deployment API version. + token_endpoint: The token endpoint to request an Azure token. + credential: Azure credential or token provider for authentication. + default_headers: Default headers for HTTP requests. + async_client: An existing client to use. + project_client: An existing AIProjectClient to use. + project_endpoint: The Azure AI Foundry project endpoint URL. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + instruction_role: The role to use for 'instruction' messages. + middleware: Optional sequence of middleware. + function_invocation_configuration: Optional function invocation configuration. + kwargs: Additional keyword arguments. + """ + if (model_id := kwargs.pop("model_id", None)) and not deployment_name: + deployment_name = str(model_id) + + if async_client is None and (project_client is not None or project_endpoint is not None): + async_client = self._create_client_from_project( + project_client=project_client, + project_endpoint=project_endpoint, + credential=credential, + allow_preview=allow_preview, + ) + + azure_openai_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + api_key=api_key, + base_url=base_url, + endpoint=endpoint, + responses_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + _apply_azure_defaults(azure_openai_settings, default_api_version="preview") + endpoint_value = azure_openai_settings.get("endpoint") + if ( + not azure_openai_settings.get("base_url") + and endpoint_value + and (hostname := urlparse(str(endpoint_value)).hostname) + and hostname.endswith(".openai.azure.com") + ): + azure_openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") + + responses_deployment_name = azure_openai_settings.get("responses_deployment_name") + if not responses_deployment_name: + raise ValueError( + "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " + "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." + ) + + if not async_client: + # Create the Azure OpenAI client directly + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + + api_key_secret = azure_openai_settings.get("api_key") + ad_token_provider = None + if not api_key_secret and credential: + ad_token_provider = resolve_credential_to_token_provider( + credential, azure_openai_settings.get("token_endpoint") + ) + + if not api_key_secret and not ad_token_provider: + raise ValueError("Please provide either api_key, credential, or a client.") + + client_endpoint = azure_openai_settings.get("endpoint") + client_base_url = azure_openai_settings.get("base_url") + if not client_endpoint and not client_base_url: + raise ValueError("Please provide an endpoint or a base_url") + + client_args: dict[str, Any] = {"default_headers": merged_headers} + if resolved_api_version := azure_openai_settings.get("api_version"): + client_args["api_version"] = resolved_api_version + if ad_token_provider: + client_args["azure_ad_token_provider"] = ad_token_provider + if api_key_secret: + client_args["api_key"] = api_key_secret.get_secret_value() + if client_base_url: + client_args["base_url"] = str(client_base_url) + if client_endpoint and not client_base_url: + client_args["azure_endpoint"] = str(client_endpoint) + if responses_deployment_name: + client_args["azure_deployment"] = responses_deployment_name + if "websocket_base_url" in kwargs: + client_args["websocket_base_url"] = kwargs.pop("websocket_base_url") + + async_client = AsyncAzureOpenAI(**client_args) + + # Store Azure-specific attributes for serialization + self.endpoint = str(endpoint_value) if endpoint_value else None + self.api_version = azure_openai_settings.get("api_version") or "" + self.deployment_name = responses_deployment_name + + super().__init__( + async_client=async_client, + model=responses_deployment_name, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + **kwargs, + ) + + @staticmethod + def _create_client_from_project( + *, + project_client: AIProjectClient | None, + project_endpoint: str | None, + credential: AzureCredentialTypes | AzureTokenProvider | None, + allow_preview: bool | None = None, + ) -> AsyncOpenAI: + """Create an AsyncOpenAI client from an Azure AI Foundry project.""" + if project_client is not None: + return project_client.get_openai_client() + + if not project_endpoint: + raise ValueError("Azure AI project endpoint is required when project_client is not provided.") + if not credential: + raise ValueError("Azure credential is required when using project_endpoint without a project_client.") + project_client_kwargs: dict[str, Any] = { + "endpoint": project_endpoint, + "credential": credential, # type: ignore[arg-type] + "user_agent": AGENT_FRAMEWORK_USER_AGENT, + } + if allow_preview is not None: + project_client_kwargs["allow_preview"] = allow_preview + project_client = AIProjectClient(**project_client_kwargs) + return project_client.get_openai_client() + + @override + def _check_model_presence(self, options: dict[str, Any]) -> None: + if not options.get("model"): + if not self.model: + raise ValueError("deployment_name must be a non-empty string") + options["model"] = self.model + + +# endregion + + +# region AzureOpenAIChatClient + + +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) + + +class AzureUserSecurityContext(TypedDict, total=False): + """User security context for Azure AI applications. + + These fields help security operations teams investigate and mitigate security + incidents by providing context about the application and end user. + """ + + application_name: str + """Name of the application making the request.""" + + end_user_id: str + """Unique identifier for the end user (recommend hashing username/email).""" + + end_user_tenant_id: str + """Microsoft 365 tenant ID the end user belongs to. Required for multi-tenant apps.""" + + source_ip: str + """The original client's IP address.""" + + +class AzureOpenAIChatOptions(OpenAIChatCompletionOptions[ResponseModelT], Generic[ResponseModelT], total=False): + """Azure OpenAI-specific chat options dict. + + Extends OpenAIChatCompletionOptions with Azure-specific options including + the "On Your Data" feature and enhanced security context. + """ + + data_sources: list[dict[str, Any]] + """Azure "On Your Data" data sources for retrieval-augmented generation.""" + + user_security_context: AzureUserSecurityContext + """Enhanced security context for Azure Defender integration.""" + + n: int + """Number of chat completion choices to generate for each input message.""" + + +AzureOpenAIChatOptionsT = TypeVar( + "AzureOpenAIChatOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="AzureOpenAIChatOptions", + covariant=True, +) + + +@deprecated("AzureOpenAIChatClient is deprecated. Use OpenAIChatCompletionClient with an AsyncAzureOpenAI client.") +class AzureOpenAIChatClient( # type: ignore[misc] + FunctionInvocationLayer[AzureOpenAIChatOptionsT], + ChatMiddlewareLayer[AzureOpenAIChatOptionsT], + ChatTelemetryLayer[AzureOpenAIChatOptionsT], + RawOpenAIChatCompletionClient[AzureOpenAIChatOptionsT], + Generic[AzureOpenAIChatOptionsT], +): + """Deprecated Azure OpenAI Chat client. Use OpenAIChatCompletionClient with AsyncAzureOpenAI instead.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" + + def __init__( + self, + *, + api_key: str | None = None, + deployment_name: str | None = None, + endpoint: str | None = None, + base_url: str | None = None, + api_version: str | None = None, + token_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + instruction_role: str | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Initialize an Azure OpenAI Chat completion client. + + Keyword Args: + api_key: The API key. + deployment_name: The deployment name. + endpoint: The deployment endpoint. + base_url: The deployment base URL. + api_version: The deployment API version. + token_endpoint: The token endpoint to request an Azure token. + credential: Azure credential or token provider for authentication. + default_headers: Default headers for HTTP requests. + async_client: An existing client to use. + additional_properties: Additional properties stored on the client instance. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + instruction_role: The role to use for 'instruction' messages. + middleware: Optional sequence of middleware. + function_invocation_configuration: Optional function invocation configuration. + """ + azure_openai_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + api_key=api_key, + base_url=base_url, + endpoint=endpoint, + chat_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + _apply_azure_defaults(azure_openai_settings) + + chat_deployment_name = azure_openai_settings.get("chat_deployment_name") + if not chat_deployment_name: + raise ValueError( + "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " + "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." + ) + + if not async_client: + # Create the Azure OpenAI client directly + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + + api_key_secret = azure_openai_settings.get("api_key") + ad_token_provider = None + if not api_key_secret and credential: + ad_token_provider = resolve_credential_to_token_provider( + credential, azure_openai_settings.get("token_endpoint") + ) + + if not api_key_secret and not ad_token_provider: + raise ValueError("Please provide either api_key, credential, or a client.") + + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") + if not endpoint_value and not base_url_value: + raise ValueError("Please provide an endpoint or a base_url") + + client_args: dict[str, Any] = {"default_headers": merged_headers} + if resolved_api_version := azure_openai_settings.get("api_version"): + client_args["api_version"] = resolved_api_version + if ad_token_provider: + client_args["azure_ad_token_provider"] = ad_token_provider + if api_key_secret: + client_args["api_key"] = api_key_secret.get_secret_value() + if base_url_value: + client_args["base_url"] = str(base_url_value) + if endpoint_value and not base_url_value: + client_args["azure_endpoint"] = str(endpoint_value) + if chat_deployment_name: + client_args["azure_deployment"] = chat_deployment_name + + async_client = AsyncAzureOpenAI(**client_args) + + # Store Azure-specific attributes for serialization + self.endpoint = str(azure_openai_settings.get("endpoint") or "") + self.api_version = azure_openai_settings.get("api_version") or "" + self.deployment_name = chat_deployment_name + + super().__init__( + async_client=async_client, + model=chat_deployment_name, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + additional_properties=additional_properties, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + ) + + @override + def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: + """Parse the choice into a Content object with type='text'. + + Overwritten from RawOpenAIChatCompletionClient to deal with Azure On Your Data function. + """ + message = getattr(choice, "message", None) + if message is None: + message = getattr(choice, "delta", None) + if message is None: # type: ignore + return None + if hasattr(message, "refusal") and message.refusal: + return Content.from_text(text=message.refusal, raw_representation=choice) + if not message.content: + return None + text_content = Content.from_text(text=message.content, raw_representation=choice) + if not message.model_extra or "context" not in message.model_extra: + return text_content + + context_raw: object = cast(object, message.context) # type: ignore[union-attr] + if isinstance(context_raw, str): + try: + context_raw = json.loads(context_raw) + except json.JSONDecodeError: + logger.warning("Context is not a valid JSON string, ignoring context.") + return text_content + if not isinstance(context_raw, dict): + logger.warning("Context is not a valid dictionary, ignoring context.") + return text_content + context = cast(dict[str, Any], context_raw) + if intent := context.get("intent"): + text_content.additional_properties = {"intent": intent} + citations = context.get("citations") + if isinstance(citations, list) and citations: + annotations: list[Annotation] = [] + for citation_raw in cast(list[object], citations): + if not isinstance(citation_raw, dict): + continue + citation = cast(dict[str, Any], citation_raw) + annotations.append( + Annotation( + type="citation", + title=citation.get("title", ""), + url=citation.get("url", ""), + snippet=citation.get("content", ""), + file_id=citation.get("filepath", ""), + tool_name="Azure-on-your-Data", + additional_properties={"chunk_id": citation.get("chunk_id", "")}, + raw_representation=citation, + ) + ) + text_content.annotations = annotations + return text_content + + +# endregion + + +# region AzureOpenAIAssistantsClient + + +AzureOpenAIAssistantsOptionsT = TypeVar( + "AzureOpenAIAssistantsOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OpenAIAssistantsOptions", + covariant=True, +) + +AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions + + +@deprecated( + "AzureOpenAIAssistantsClient is deprecated. " + "Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient." +) +class AzureOpenAIAssistantsClient( + OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT] +): + """Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient.""" + + DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview" + + def __init__( + self, + *, + deployment_name: str | None = None, + assistant_id: str | None = None, + assistant_name: str | None = None, + assistant_description: str | None = None, + thread_id: str | None = None, + api_key: str | None = None, + endpoint: str | None = None, + base_url: str | None = None, + api_version: str | None = None, + token_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Azure OpenAI Assistants client. + + Keyword Args: + deployment_name: The Azure OpenAI deployment name. + assistant_id: The ID of an Azure OpenAI assistant to use. + assistant_name: The name to use when creating new assistants. + assistant_description: The description to use when creating new assistants. + thread_id: Default thread ID to use for conversations. + api_key: The API key to use. + endpoint: The deployment endpoint. + base_url: The deployment base URL. + api_version: The deployment API version. + token_endpoint: The token endpoint to request an Azure token. + credential: Azure credential or token provider for authentication. + default_headers: Default headers for HTTP requests. + async_client: An existing client to use. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + """ + azure_openai_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + api_key=api_key, + base_url=base_url, + endpoint=endpoint, + chat_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + _apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION) + + chat_deployment_name = azure_openai_settings.get("chat_deployment_name") + if not chat_deployment_name: + raise ValueError( + "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " + "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." + ) + + api_key_secret = azure_openai_settings.get("api_key") + token_scope = azure_openai_settings.get("token_endpoint") + + ad_token_provider = None + if not async_client and not api_key_secret and credential: + ad_token_provider = resolve_credential_to_token_provider(credential, token_scope) + + if not async_client and not api_key_secret and not ad_token_provider: + raise ValueError("Please provide either api_key, credential, or a client.") + + if not async_client: + client_params: dict[str, Any] = { + "default_headers": default_headers, + } + if resolved_api_version := azure_openai_settings.get("api_version"): + client_params["api_version"] = resolved_api_version + + if api_key_secret: + client_params["api_key"] = api_key_secret.get_secret_value() + elif ad_token_provider: + client_params["azure_ad_token_provider"] = ad_token_provider + + if resolved_base_url := azure_openai_settings.get("base_url"): + client_params["base_url"] = str(resolved_base_url) + elif resolved_endpoint := azure_openai_settings.get("endpoint"): + client_params["azure_endpoint"] = str(resolved_endpoint) + + async_client = AsyncAzureOpenAI(**client_params) + + super().__init__( + model_id=chat_deployment_name, + assistant_id=assistant_id, + assistant_name=assistant_name, + assistant_description=assistant_description, + thread_id=thread_id, + async_client=async_client, # type: ignore[reportArgumentType] + default_headers=default_headers, + ) + + +# endregion + + +# region AzureOpenAIEmbeddingClient + + +AzureOpenAIEmbeddingOptionsT = TypeVar( + "AzureOpenAIEmbeddingOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OpenAIEmbeddingOptions", + covariant=True, +) + + +@deprecated("AzureOpenAIEmbeddingClient is deprecated. Use OpenAIEmbeddingClient with an AsyncAzureOpenAI client.") +class AzureOpenAIEmbeddingClient( + EmbeddingTelemetryLayer[str, list[float], AzureOpenAIEmbeddingOptionsT], + RawOpenAIEmbeddingClient[AzureOpenAIEmbeddingOptionsT], + Generic[AzureOpenAIEmbeddingOptionsT], +): + """Deprecated Azure OpenAI embedding client. Use OpenAIEmbeddingClient with AsyncAzureOpenAI instead.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" + + def __init__( + self, + *, + api_key: str | None = None, + deployment_name: str | None = None, + endpoint: str | None = None, + base_url: str | None = None, + api_version: str | None = None, + token_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Azure OpenAI embedding client. + + Keyword Args: + api_key: The API key. + deployment_name: The deployment name. + endpoint: The deployment endpoint. + base_url: The deployment base URL. + api_version: The deployment API version. + token_endpoint: The token endpoint to request an Azure token. + credential: Azure credential or token provider for authentication. + default_headers: Default headers for HTTP requests. + async_client: An existing client to use. + otel_provider_name: Override the OpenTelemetry provider name. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + """ + azure_openai_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + api_key=api_key, + base_url=base_url, + endpoint=endpoint, + embedding_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + _apply_azure_defaults(azure_openai_settings) + + embedding_deployment_name = azure_openai_settings.get("embedding_deployment_name") + if not embedding_deployment_name: + raise ValueError( + "Azure OpenAI embedding deployment name is required. Set via 'deployment_name' parameter " + "or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable." + ) + + if not async_client: + # Create the Azure OpenAI client directly + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + + api_key_secret = azure_openai_settings.get("api_key") + ad_token_provider = None + if not api_key_secret and credential: + ad_token_provider = resolve_credential_to_token_provider( + credential, azure_openai_settings.get("token_endpoint") + ) + + if not api_key_secret and not ad_token_provider: + raise ValueError("Please provide either api_key, credential, or a client.") + + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") + if not endpoint_value and not base_url_value: + raise ValueError("Please provide an endpoint or a base_url") + + client_args: dict[str, Any] = {"default_headers": merged_headers} + if resolved_api_version := azure_openai_settings.get("api_version"): + client_args["api_version"] = resolved_api_version + if ad_token_provider: + client_args["azure_ad_token_provider"] = ad_token_provider + if api_key_secret: + client_args["api_key"] = api_key_secret.get_secret_value() + if base_url_value: + client_args["base_url"] = str(base_url_value) + if endpoint_value and not base_url_value: + client_args["azure_endpoint"] = str(endpoint_value) + if embedding_deployment_name: + client_args["azure_deployment"] = embedding_deployment_name + + async_client = AsyncAzureOpenAI(**client_args) + + # Store Azure-specific attributes for serialization + self.endpoint = str(azure_openai_settings.get("endpoint") or "") + self.api_version = azure_openai_settings.get("api_version") or "" + self.deployment_name = embedding_deployment_name + + super().__init__( + async_client=async_client, + model=embedding_deployment_name, + default_headers=default_headers, + ) + if otel_provider_name is not None: + self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] + + +# endregion diff --git a/python/packages/core/agent_framework/azure/_entra_id_authentication.py b/python/packages/azure-ai/agent_framework_azure_ai/_entra_id_authentication.py similarity index 97% rename from python/packages/core/agent_framework/azure/_entra_id_authentication.py rename to python/packages/azure-ai/agent_framework_azure_ai/_entra_id_authentication.py index 8f68a40331..b1ae8a4739 100644 --- a/python/packages/core/agent_framework/azure/_entra_id_authentication.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_entra_id_authentication.py @@ -6,11 +6,10 @@ import logging from collections.abc import Awaitable, Callable from typing import Union +from agent_framework.exceptions import ChatClientInvalidAuthException from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from ..exceptions import ChatClientInvalidAuthException - logger: logging.Logger = logging.getLogger(__name__) AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py index 82e6a1d5b7..b4c948efa3 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py @@ -18,7 +18,6 @@ from agent_framework import ( from agent_framework._mcp import MCPTool from agent_framework._settings import load_settings from agent_framework._tools import ToolTypes -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( AgentVersionDetails, @@ -29,13 +28,15 @@ from azure.ai.projects.models import ( FunctionTool as AzureFunctionTool, ) -from ._client import AzureAIClient, AzureAIProjectAgentOptions +from ._client import AzureAIClient, AzureAIProjectAgentOptions # pyright: ignore[reportDeprecated] +from ._entra_id_authentication import AzureCredentialTypes from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover + from warnings import deprecated # type: ignore # pragma: no cover else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover + from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover if sys.version_info >= (3, 11): from typing import Self, TypedDict # type: ignore # pragma: no cover else: @@ -55,11 +56,12 @@ OptionsCoT = TypeVar( ) +@deprecated("AzureAIProjectAgentProvider is deprecated. Use FoundryAgent instead.") class AzureAIProjectAgentProvider(Generic[OptionsCoT]): - """Provider for Azure AI Agent Service (Responses API). + """Deprecated provider for Azure AI Agent Service (Responses API). - This provider allows you to create, retrieve, and manage Azure AI agents - using the AIProjectClient from the Azure AI Projects SDK. + This provider is deprecated. Use ``FoundryAgent`` instead to connect to + pre-configured agents in Foundry. Examples: Using with explicit AIProjectClient: @@ -200,7 +202,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): ) # Extract options from default_options if present - opts = dict(default_options) if default_options else {} + opts: dict[str, Any] = dict(default_options) if default_options else {} response_format = opts.get("response_format") rai_config = opts.get("rai_config") reasoning = opts.get("reasoning") @@ -384,7 +386,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): if not isinstance(details.definition, PromptAgentDefinition): raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.") - client = AzureAIClient( + client = AzureAIClient( # pyright: ignore[reportDeprecated] project_client=self._project_client, agent_name=details.name, agent_version=details.version, 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 [] diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 76c37fdbea..5f0f3a31e1 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,9 +23,12 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", + "agent-framework-openai>=1.0.0rc5", + "azure-ai-projects>=2.0.0,<3.0", "azure-ai-agents>=1.2.0b5,<1.2.0b6", "azure-ai-inference>=1.0.0b9,<1.0.0b10", + "azure-identity>=1,<2", "aiohttp>=3.7.0,<4", ] @@ -85,11 +88,16 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai" -test = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests' [tool.poe.tasks.integration-tests] +help = "Run the package integration test suite." cmd = """ pytest --import-mode=importlib -n logical --dist worksteal diff --git a/python/packages/azure-ai/tests/assets/sample_image.jpg b/python/packages/azure-ai/tests/assets/sample_image.jpg new file mode 100644 index 0000000000..ea6486656f Binary files /dev/null and b/python/packages/azure-ai/tests/assets/sample_image.jpg differ diff --git a/python/packages/core/tests/azure/conftest.py b/python/packages/azure-ai/tests/azure_openai/conftest.py similarity index 99% rename from python/packages/core/tests/azure/conftest.py rename to python/packages/azure-ai/tests/azure_openai/conftest.py index 9d8ce0cebb..8e32c53608 100644 --- a/python/packages/core/tests/azure/conftest.py +++ b/python/packages/azure-ai/tests/azure_openai/conftest.py @@ -1,9 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. from typing import Any -from pytest import fixture - from agent_framework import Message +from pytest import fixture # region: Connector Settings fixtures diff --git a/python/packages/core/tests/azure/test_azure_assistants_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py similarity index 52% rename from python/packages/core/tests/azure/test_azure_assistants_client.py rename to python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py index 3c51881279..dd0e2f2d38 100644 --- a/python/packages/core/tests/azure/test_azure_assistants_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py @@ -1,31 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. -import os from typing import Annotated from unittest.mock import AsyncMock, MagicMock, patch import pytest -from azure.identity import AzureCliCredential -from pydantic import Field - from agent_framework import ( - Agent, - AgentResponse, - AgentResponseUpdate, - AgentSession, - ChatResponse, - ChatResponseUpdate, - Message, SupportsChatGetResponse, tool, ) from agent_framework._settings import SecretString from agent_framework.azure import AzureOpenAIAssistantsClient - -skip_if_azure_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), - reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", -) +from pydantic import Field def create_test_azure_assistants_client( @@ -87,7 +72,7 @@ def test_azure_assistants_client_init_with_client(mock_async_azure_openai: Magic ) assert client.client is mock_async_azure_openai - assert client.model_id == "test_chat_deployment" + assert client.model == "test_chat_deployment" assert client.assistant_id == "existing-assistant-id" assert client.thread_id == "test-thread-id" assert not client._should_delete_assistant # type: ignore @@ -108,7 +93,7 @@ def test_azure_assistants_client_init_auto_create_client( ) assert client.client is mock_async_azure_openai - assert client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert client.assistant_id is None assert client.assistant_name == "TestAssistant" assert not client._should_delete_assistant # type: ignore @@ -139,7 +124,7 @@ def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_tes default_headers=default_headers, ) - assert client.model_id == "test_chat_deployment" + assert client.model == "test_chat_deployment" assert isinstance(client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers @@ -235,7 +220,7 @@ def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str, dumped_settings = client.to_dict() - assert dumped_settings["model_id"] == "test_chat_deployment" + assert dumped_settings["model"] == "test_chat_deployment" assert dumped_settings["assistant_id"] == "test-assistant-id" assert dumped_settings["assistant_name"] == "TestAssistant" assert dumped_settings["thread_id"] == "test-thread-id" @@ -256,319 +241,18 @@ def get_weather( return f"The weather in {location} is sunny with a high of 25°C." -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_client_get_response() -> None: - """Test Azure Assistants Client response.""" - async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="The weather in Seattle is currently sunny with a high of 25°C. " - "It's a beautiful day for outdoor activities.", - ) - ) - messages.append(Message(role="user", text="What's the weather like today?")) - - # Test that the client can be used to get a response - response = await azure_assistants_client.get_response(messages=messages) - - assert response is not None - assert isinstance(response, ChatResponse) - assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_client_get_response_tools() -> None: - """Test Azure Assistants Client response with tools.""" - async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like in Seattle?")) - - # Test that the client can be used to get a response - response = await azure_assistants_client.get_response( - messages=messages, - options={"tools": [get_weather], "tool_choice": "auto"}, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert any(word in response.text.lower() for word in ["sunny", "25", "weather"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_client_streaming() -> None: - """Test Azure Assistants Client streaming response.""" - async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="The weather in Seattle is currently sunny with a high of 25°C. " - "It's a beautiful day for outdoor activities.", - ) - ) - messages.append(Message(role="user", text="What's the weather like today?")) - - # Test that the client can be used to get a response - response = azure_assistants_client.get_response(messages=messages, stream=True) - - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_client_streaming_tools() -> None: - """Test Azure Assistants Client streaming response with tools.""" - async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client: - assert isinstance(azure_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like in Seattle?")) - - # Test that the client can be used to get a response - response = azure_assistants_client.get_response( - messages=messages, - options={"tools": [get_weather], "tool_choice": "auto"}, - stream=True, - ) - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert any(word in full_message.lower() for word in ["sunny", "25", "weather"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_client_with_existing_assistant() -> None: - """Test Azure Assistants Client with existing assistant ID.""" - # First create an assistant to use in the test - async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as temp_client: - # Get the assistant ID by triggering assistant creation - messages = [Message(role="user", text="Hello")] - await temp_client.get_response(messages=messages) - assistant_id = temp_client.assistant_id - - # Now test using the existing assistant - async with AzureOpenAIAssistantsClient( - assistant_id=assistant_id, credential=AzureCliCredential() - ) as azure_assistants_client: - assert isinstance(azure_assistants_client, SupportsChatGetResponse) - assert azure_assistants_client.assistant_id == assistant_id - - messages = [Message(role="user", text="What can you do?")] - - # Test that the client can be used to get a response - response = await azure_assistants_client.get_response(messages=messages) - - assert response is not None - assert isinstance(response, ChatResponse) - assert len(response.text) > 0 - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_agent_basic_run(): - """Test Agent basic run functionality with AzureOpenAIAssistantsClient.""" - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - ) as agent: - # Run a simple query - response = await agent.run("Hello! Please respond with 'Hello World' exactly.") - - # Validate response - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - assert "Hello World" in response.text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_agent_basic_run_streaming(): - """Test Agent basic streaming functionality with AzureOpenAIAssistantsClient.""" - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - ) as agent: - # Run streaming query - full_message: str = "" - async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True): - assert chunk is not None - assert isinstance(chunk, AgentResponseUpdate) - if chunk.text: - full_message += chunk.text - - # Validate streaming response - assert len(full_message) > 0 - assert "streaming response test" in full_message.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_agent_session_persistence(): - """Test Agent session persistence across runs with AzureOpenAIAssistantsClient.""" - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as agent: - # Create a new session that will be reused - session = agent.create_session() - - # First message - establish context - first_response = await agent.run( - "Remember this number: 42. What number did I just tell you to remember?", session=session - ) - assert isinstance(first_response, AgentResponse) - assert "42" in first_response.text - - # Second message - test conversation memory - second_response = await agent.run( - "What number did I tell you to remember in my previous message?", session=session - ) - assert isinstance(second_response, AgentResponse) - assert "42" in second_response.text - - # Verify session has been populated with conversation ID - assert session.service_session_id is not None - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_agent_existing_session_id(): - """Test Agent with existing session ID to continue conversations across agent instances.""" - # First, create a conversation and capture the session ID - existing_session_id = None - - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) as agent: - # Start a conversation and get the session ID - session = agent.create_session() - response1 = await agent.run("What's the weather in Paris?", session=session) - - # Validate first response - assert isinstance(response1, AgentResponse) - assert response1.text is not None - assert any(word in response1.text.lower() for word in ["weather", "paris"]) - - # The session ID is set after the first response - existing_session_id = session.service_session_id - assert existing_session_id is not None - - # Now continue with the same session ID in a new agent instance - - async with Agent( - client=AzureOpenAIAssistantsClient(thread_id=existing_session_id, credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) as agent: - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) - - # Ask about the previous conversation - response2 = await agent.run("What was the last city I asked about?", session=session) - - # Validate that the agent remembers the previous conversation - assert isinstance(response2, AgentResponse) - assert response2.text is not None - # Should reference Paris from the previous conversation - assert "paris" in response2.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_agent_code_interpreter(): - """Test Agent with code interpreter through AzureOpenAIAssistantsClient.""" - - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that can write and execute Python code.", - tools=[AzureOpenAIAssistantsClient.get_code_interpreter_tool()], - ) as agent: - # Request code execution - response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.") - - # Validate response - assert isinstance(response, AgentResponse) - assert response.text is not None - # Factorial of 5 is 120 - assert "120" in response.text or "factorial" in response.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -async def test_azure_assistants_client_agent_level_tool_persistence(): - """Test that agent-level tools persist across multiple runs with Azure Assistants Client.""" - - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that uses available tools.", - tools=[get_weather], # Agent-level tool - ) as agent: - # First run - agent-level tool should be available - first_response = await agent.run("What's the weather like in Chicago?") - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - # Should use the agent-level weather tool - assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) - - # Second run - agent-level tool should still be available (persistence test) - second_response = await agent.run("What's the weather in Miami?") - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - # Should use the agent-level weather tool again - assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"]) - - def test_azure_assistants_client_entra_id_authentication() -> None: """Test credential authentication path with sync credential.""" mock_credential = MagicMock() mock_provider = MagicMock(return_value="token-string") with ( - patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, + patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, patch( - "agent_framework.azure._assistants_client.resolve_credential_to_token_provider", + "agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider", return_value=mock_provider, ) as mock_resolve, - patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, + patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): mock_load_settings.return_value = { @@ -602,7 +286,7 @@ def test_azure_assistants_client_entra_id_authentication() -> None: def test_azure_assistants_client_no_authentication_error() -> None: """Test authentication validation error when no auth provided.""" - with patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings: + with patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings: mock_load_settings.return_value = { "chat_deployment_name": "test-deployment", "responses_deployment_name": None, @@ -627,12 +311,12 @@ def test_azure_assistants_client_callable_credential() -> None: mock_provider = MagicMock(return_value="my-token") with ( - patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, + patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, patch( - "agent_framework.azure._assistants_client.resolve_credential_to_token_provider", + "agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider", return_value=mock_provider, ), - patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, + patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): mock_load_settings.return_value = { @@ -664,8 +348,8 @@ def test_azure_assistants_client_callable_credential() -> None: def test_azure_assistants_client_base_url_configuration() -> None: """Test base_url client parameter path.""" with ( - patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, - patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, + patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, + patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): mock_load_settings.return_value = { @@ -695,8 +379,8 @@ def test_azure_assistants_client_base_url_configuration() -> None: def test_azure_assistants_client_azure_endpoint_configuration() -> None: """Test azure_endpoint client parameter path.""" with ( - patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, - patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, + patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, + patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): mock_load_settings.return_value = { diff --git a/python/packages/core/tests/azure/test_azure_chat_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py similarity index 91% rename from python/packages/core/tests/azure/test_azure_chat_client.py rename to python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py index dd50c48db4..22e0a20d96 100644 --- a/python/packages/core/tests/azure/test_azure_chat_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py @@ -6,16 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import openai import pytest -from azure.identity import AzureCliCredential -from httpx import Request, Response -from openai import AsyncAzureOpenAI, AsyncStream -from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions -from openai.types.chat import ChatCompletion, ChatCompletionChunk -from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice -from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta -from openai.types.chat.chat_completion_message import ChatCompletionMessage - from agent_framework import ( Agent, AgentResponse, @@ -29,10 +19,19 @@ from agent_framework import ( from agent_framework._telemetry import USER_AGENT_KEY from agent_framework.azure import AzureOpenAIChatClient from agent_framework.exceptions import ChatClientException -from agent_framework.openai import ( +from agent_framework_openai import ( ContentFilterResultSeverity, OpenAIContentFilterException, ) +from azure.identity import AzureCliCredential +from httpx import Request, Response +from openai import AsyncAzureOpenAI, AsyncStream +from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions +from openai.types.chat import ChatCompletion, ChatCompletionChunk +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice +from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta +from openai.types.chat.chat_completion_message import ChatCompletionMessage # region Service Setup @@ -48,7 +47,7 @@ def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client is not None assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(azure_chat_client, SupportsChatGetResponse) @@ -71,7 +70,7 @@ def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client is not None assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(azure_chat_client, SupportsChatGetResponse) for key, value in default_headers.items(): assert key in azure_chat_client.client.default_headers @@ -84,7 +83,7 @@ def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client is not None assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(azure_chat_client, SupportsChatGetResponse) @@ -131,7 +130,7 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: azure_chat_client = AzureOpenAIChatClient.from_dict(settings) dumped_settings = azure_chat_client.to_dict() - assert dumped_settings["model_id"] == settings["deployment_name"] + assert dumped_settings["model"] == settings["deployment_name"] assert str(settings["endpoint"]) in str(dumped_settings["endpoint"]) assert str(settings["deployment_name"]) == str(dumped_settings["deployment_name"]) assert settings["api_version"] == dumped_settings["api_version"] @@ -652,6 +651,88 @@ async def test_streaming_with_none_delta( assert any(msg.contents for msg in results) +# region _parse_text_from_openai direct unit tests + + +def test_parse_text_from_openai_with_choice_message(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test _parse_text_from_openai correctly reads message from a Choice.""" + client = AzureOpenAIChatClient() + choice = Choice( + index=0, + message=ChatCompletionMessage(content="hello", role="assistant"), + finish_reason="stop", + ) + result = client._parse_text_from_openai(choice) + assert result is not None + assert result.type == "text" + assert result.text == "hello" + + +def test_parse_text_from_openai_with_chunk_choice_delta(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test _parse_text_from_openai correctly reads delta from a ChunkChoice.""" + client = AzureOpenAIChatClient() + choice = ChunkChoice( + index=0, + delta=ChunkChoiceDelta(content="streamed", role="assistant"), + finish_reason=None, + ) + result = client._parse_text_from_openai(choice) + assert result is not None + assert result.type == "text" + assert result.text == "streamed" + + +def test_parse_text_from_openai_refusal_choice(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test _parse_text_from_openai returns refusal text from a Choice.""" + client = AzureOpenAIChatClient() + choice = Choice( + index=0, + message=ChatCompletionMessage(content=None, role="assistant", refusal="I cannot help with that"), + finish_reason="stop", + ) + result = client._parse_text_from_openai(choice) + assert result is not None + assert result.type == "text" + assert result.text == "I cannot help with that" + + +def test_parse_text_from_openai_refusal_chunk_choice(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test _parse_text_from_openai returns refusal text from a ChunkChoice.""" + client = AzureOpenAIChatClient() + choice = ChunkChoice( + index=0, + delta=ChunkChoiceDelta(content=None, role="assistant", refusal="I cannot help with that"), + finish_reason=None, + ) + result = client._parse_text_from_openai(choice) + assert result is not None + assert result.type == "text" + assert result.text == "I cannot help with that" + + +def test_parse_text_from_openai_no_content_no_refusal(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test _parse_text_from_openai returns None when no content or refusal.""" + client = AzureOpenAIChatClient() + choice = Choice( + index=0, + message=ChatCompletionMessage(content=None, role="assistant"), + finish_reason="stop", + ) + result = client._parse_text_from_openai(choice) + assert result is None + + +def test_parse_text_from_openai_none_delta(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test _parse_text_from_openai returns None when delta is None (async content filtering).""" + client = AzureOpenAIChatClient() + choice = ChunkChoice.model_construct(index=0, delta=None, finish_reason=None) + result = client._parse_text_from_openai(choice) + assert result is None + + +# endregion + + @patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) async def test_cmc_with_conversation_id( mock_create: AsyncMock, diff --git a/python/packages/core/tests/azure/test_azure_embedding_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py similarity index 97% rename from python/packages/core/tests/azure/test_azure_embedding_client.py rename to python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py index 97e477549c..de78178df1 100644 --- a/python/packages/core/tests/azure/test_azure_embedding_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py @@ -6,13 +6,12 @@ import os from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework.azure import AzureOpenAIEmbeddingClient +from agent_framework_openai import OpenAIEmbeddingOptions from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage -from agent_framework.azure import AzureOpenAIEmbeddingClient -from agent_framework.openai import OpenAIEmbeddingOptions - def _make_openai_response( embeddings: list[list[float]], @@ -49,7 +48,7 @@ def test_azure_construction_with_deployment_name(azure_embedding_unit_test_env: api_key="test-key", endpoint="https://test.openai.azure.com/", ) - assert client.model_id == "text-embedding-3-small" + assert client.model == "text-embedding-3-small" def test_azure_construction_with_existing_client(azure_embedding_unit_test_env: None) -> None: @@ -58,7 +57,7 @@ def test_azure_construction_with_existing_client(azure_embedding_unit_test_env: deployment_name="my-deployment", async_client=mock_client, ) - assert client.model_id == "my-deployment" + assert client.model == "my-deployment" assert client.client is mock_client diff --git a/python/packages/core/tests/azure/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py similarity index 95% rename from python/packages/core/tests/azure/test_azure_responses_client.py rename to python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py index 35eaa2b407..65e9629b96 100644 --- a/python/packages/core/tests/azure/test_azure_responses_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py @@ -8,10 +8,6 @@ from typing import Annotated, Any from unittest.mock import MagicMock import pytest -from azure.identity import AzureCliCredential -from pydantic import BaseModel -from pytest import param - from agent_framework import ( Agent, AgentResponse, @@ -22,6 +18,9 @@ from agent_framework import ( tool, ) from agent_framework.azure import AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential +from pydantic import BaseModel +from pytest import param skip_if_azure_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), @@ -75,7 +74,7 @@ def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(azure_responses_client, SupportsChatGetResponse) @@ -90,7 +89,7 @@ def test_init_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) - model_id = "test_model_id" azure_responses_client = AzureOpenAIResponsesClient(deployment_name=model_id) - assert azure_responses_client.model_id == model_id + assert azure_responses_client.model == model_id assert isinstance(azure_responses_client, SupportsChatGetResponse) @@ -98,7 +97,7 @@ def test_init_model_id_kwarg(azure_openai_unit_test_env: dict[str, str]) -> None """Test that model_id kwarg correctly sets the deployment name (issue #4299).""" azure_responses_client = AzureOpenAIResponsesClient(model_id="gpt-4o") - assert azure_responses_client.model_id == "gpt-4o" + assert azure_responses_client.model == "gpt-4o" assert isinstance(azure_responses_client, SupportsChatGetResponse) @@ -108,7 +107,7 @@ def test_init_model_id_kwarg_does_not_override_deployment_name( """Test that deployment_name takes precedence over model_id kwarg (issue #4299).""" azure_responses_client = AzureOpenAIResponsesClient(deployment_name="my-deployment", model_id="gpt-4o") - assert azure_responses_client.model_id == "my-deployment" + assert azure_responses_client.model == "my-deployment" assert isinstance(azure_responses_client, SupportsChatGetResponse) @@ -116,7 +115,7 @@ def test_init_model_id_kwarg_none(azure_openai_unit_test_env: dict[str, str]) -> """Test that model_id=None does not override the env-var deployment name.""" azure_responses_client = AzureOpenAIResponsesClient(model_id=None) - assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None: @@ -127,7 +126,7 @@ def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> default_headers=default_headers, ) - assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(azure_responses_client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers @@ -156,7 +155,7 @@ def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> mock_project_client.get_openai_client.return_value = mock_openai_client with patch( - "agent_framework.azure._responses_client.AzureOpenAIResponsesClient._create_client_from_project", + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", return_value=mock_openai_client, ): azure_responses_client = AzureOpenAIResponsesClient( @@ -164,7 +163,7 @@ def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> deployment_name="gpt-4o", ) - assert azure_responses_client.model_id == "gpt-4o" + assert azure_responses_client.model == "gpt-4o" assert azure_responses_client.client is mock_openai_client assert isinstance(azure_responses_client, SupportsChatGetResponse) @@ -179,7 +178,7 @@ def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) mock_openai_client.default_headers = {} with patch( - "agent_framework.azure._responses_client.AzureOpenAIResponsesClient._create_client_from_project", + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", return_value=mock_openai_client, ): azure_responses_client = AzureOpenAIResponsesClient( @@ -188,7 +187,7 @@ def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) credential=AzureCliCredential(), ) - assert azure_responses_client.model_id == "gpt-4o" + assert azure_responses_client.model == "gpt-4o" assert azure_responses_client.client is mock_openai_client assert isinstance(azure_responses_client, SupportsChatGetResponse) @@ -220,7 +219,7 @@ def test_create_client_from_project_with_endpoint() -> None: mock_openai_client = MagicMock(spec=AsyncOpenAI) mock_credential = MagicMock() - with patch("agent_framework.azure._responses_client.AIProjectClient") as MockAIProjectClient: + with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: mock_instance = MockAIProjectClient.return_value mock_instance.get_openai_client.return_value = mock_openai_client @@ -668,8 +667,8 @@ async def test_azure_openai_responses_client_tool_rich_content_image() -> None: skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") - or os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") == "", - reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL_DEPLOYMENT_NAME provided; skipping integration tests.", + or os.getenv("AZURE_AI_MODEL", "") == "", + reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL provided; skipping integration tests.", ) @@ -695,7 +694,7 @@ async def test_integration_function_call_roundtrip_preserves_fidelity(): client = AzureOpenAIResponsesClient( project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + deployment_name=os.environ["AZURE_AI_MODEL"], credential=AzureCliCredential(), ) diff --git a/python/packages/core/tests/azure/test_entra_id_authentication.py b/python/packages/azure-ai/tests/azure_openai/test_entra_id_authentication.py similarity index 97% rename from python/packages/core/tests/azure/test_entra_id_authentication.py rename to python/packages/azure-ai/tests/azure_openai/test_entra_id_authentication.py index cfdde2e8df..a741459fd6 100644 --- a/python/packages/core/tests/azure/test_entra_id_authentication.py +++ b/python/packages/azure-ai/tests/azure_openai/test_entra_id_authentication.py @@ -3,13 +3,13 @@ from unittest.mock import MagicMock, patch import pytest +from agent_framework.exceptions import ChatClientInvalidAuthException from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from agent_framework.azure._entra_id_authentication import ( +from agent_framework_azure_ai._entra_id_authentication import ( resolve_credential_to_token_provider, ) -from agent_framework.exceptions import ChatClientInvalidAuthException TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default" diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py index b394c15b4a..025f882eb6 100644 --- a/python/packages/azure-ai/tests/test_agent_provider.py +++ b/python/packages/azure-ai/tests/test_agent_provider.py @@ -15,7 +15,6 @@ from azure.ai.agents.models import ( from azure.ai.agents.models import ( CodeInterpreterToolDefinition, ) -from azure.identity.aio import AzureCliCredential from pydantic import BaseModel from agent_framework_azure_ai import ( @@ -772,82 +771,3 @@ def test_from_azure_ai_agent_tools_unknown_dict() -> None: # endregion - -# region Integration Tests - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_create_agent() -> None: - """Integration test: Create an agent using the provider.""" - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="IntegrationTestAgent", - instructions="You are a helpful assistant for testing.", - ) - - try: - assert isinstance(agent, Agent) - assert agent.name == "IntegrationTestAgent" - assert agent.id is not None - finally: - # Cleanup: delete the agent - if agent.id: - await provider._agents_client.delete_agent(agent.id) # type: ignore - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_get_agent() -> None: - """Integration test: Get an existing agent using the provider.""" - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # First create an agent - created = await provider._agents_client.create_agent( # type: ignore - model=os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o"), - name="GetAgentTest", - instructions="Test agent", - ) - - try: - # Then get it using the provider - agent = await provider.get_agent(created.id) - - assert isinstance(agent, Agent) - assert agent.id == created.id - finally: - await provider._agents_client.delete_agent(created.id) # type: ignore - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_create_and_run() -> None: - """Integration test: Create an agent and run a conversation.""" - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="RunTestAgent", - instructions="You are a helpful assistant. Always respond with 'Hello!' to any greeting.", - ) - - try: - result = await agent.run("Hi there!") - - assert result is not None - assert len(result.messages) > 0 - finally: - if agent.id: - await provider._agents_client.delete_agent(agent.id) # type: ignore - - -# endregion 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..f7560a6443 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 @@ -1,17 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. import json -import os -from pathlib import Path from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ( - Agent, - AgentResponse, - AgentResponseUpdate, - AgentSession, ChatOptions, ChatResponse, ChatResponseUpdate, @@ -28,7 +22,6 @@ from azure.ai.agents.models import ( AgentsNamedToolChoiceType, AgentsToolChoiceOptionMode, CodeInterpreterToolDefinition, - FileInfo, MessageDeltaChunk, MessageDeltaTextContent, MessageDeltaTextFileCitationAnnotation, @@ -41,19 +34,12 @@ from azure.ai.agents.models import ( SubmitToolApprovalAction, SubmitToolOutputsAction, ThreadRun, - VectorStore, ) from azure.core.credentials_async import AsyncTokenCredential -from azure.identity.aio import AzureCliCredential from pydantic import BaseModel, Field from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/"), - reason="No real AZURE_AI_PROJECT_ENDPOINT provided; skipping integration tests.", -) - def create_test_azure_ai_chat_client( mock_agents_client: MagicMock, @@ -87,6 +73,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, @@ -100,6 +88,15 @@ def create_test_azure_ai_chat_client( return client +def test_init_emits_updated_deprecation_warning(mock_agents_client: MagicMock) -> None: + """Test that construction emits the updated class deprecation warning.""" + with pytest.deprecated_call(match="V1 Agents Service API and has no direct replacement"): + AzureAIAgentClient( + agents_client=mock_agents_client, + agent_id="test-agent", + ) + + def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: """Test AzureAISettings initialization.""" settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") @@ -151,6 +148,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 @@ -1521,401 +1522,6 @@ def get_weather( return f"The weather in {location} is sunny with a high of 25°C." -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_get_response() -> None: - """Test Azure AI Chat Client response.""" - async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="The weather in Seattle is currently sunny with a high of 25°C. " - "It's a beautiful day for outdoor activities.", - ) - ) - messages.append(Message(role="user", text="What's the weather like today?")) - - # Test that the agents_client can be used to get a response - response = await azure_ai_chat_client.get_response(messages=messages) - - assert response is not None - assert isinstance(response, ChatResponse) - assert any(word in response.text.lower() for word in ["sunny", "25"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_get_response_tools() -> None: - """Test Azure AI Chat Client response with tools.""" - async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like in Seattle?")) - - # Test that the agents_client can be used to get a response - response = await azure_ai_chat_client.get_response( - messages=messages, - options={"tools": [get_weather], "tool_choice": "auto"}, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert any(word in response.text.lower() for word in ["sunny", "25"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_streaming() -> None: - """Test Azure AI Chat Client streaming response.""" - async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="The weather in Seattle is currently sunny with a high of 25°C. " - "It's a beautiful day for outdoor activities.", - ) - ) - messages.append(Message(role="user", text="What's the weather like today?")) - - # Test that the agents_client can be used to get a response - response = azure_ai_chat_client.get_response(messages=messages, stream=True) - - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert any(word in full_message.lower() for word in ["sunny", "25"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_streaming_tools() -> None: - """Test Azure AI Chat Client streaming response with tools.""" - async with AzureAIAgentClient(credential=AzureCliCredential()) as azure_ai_chat_client: - assert isinstance(azure_ai_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like in Seattle?")) - - # Test that the agents_client can be used to get a response - response = azure_ai_chat_client.get_response( - messages=messages, - stream=True, - options={"tools": [get_weather], "tool_choice": "auto"}, - ) - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert any(word in full_message.lower() for word in ["sunny", "25"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_basic_run() -> None: - """Test Agent basic run functionality with AzureAIAgentClient.""" - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - ) as agent: - # Run a simple query - response = await agent.run("Hello! Please respond with 'Hello World' exactly.") - - # Validate response - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - assert "Hello World" in response.text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None: - """Test Agent basic streaming functionality with AzureAIAgentClient.""" - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - ) as agent: - # Run streaming query - full_message: str = "" - async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True): - assert chunk is not None - assert isinstance(chunk, AgentResponseUpdate) - if chunk.text: - full_message += chunk.text - - # Validate streaming response - assert len(full_message) > 0 - assert "streaming response test" in full_message.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_thread_persistence() -> None: - """Test Agent session persistence across runs with AzureAIAgentClient.""" - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as agent: - # Create a new session that will be reused - session = agent.create_session() - - # First message - establish context - first_response = await agent.run( - "Remember this number: 42. What number did I just tell you to remember?", session=session - ) - assert isinstance(first_response, AgentResponse) - assert "42" in first_response.text - - # Second message - test conversation memory - second_response = await agent.run( - "What number did I tell you to remember in my previous message?", session=session - ) - assert isinstance(second_response, AgentResponse) - assert "42" in second_response.text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_existing_thread_id() -> None: - """Test Agent existing thread ID functionality with AzureAIAgentClient.""" - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as first_agent: - # Start a conversation and get the session ID - session = first_agent.create_session() - first_response = await first_agent.run("My name is Alice. Remember this.", session=session) - - # Validate first response - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - - # The thread ID is set after the first response - existing_thread_id = session.service_session_id - assert existing_thread_id is not None - - # Now continue with the same thread ID in a new agent instance - async with Agent( - client=AzureAIAgentClient(thread_id=existing_thread_id, credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as second_agent: - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_thread_id) - - # Ask about the previous conversation - response2 = await second_agent.run("What is my name?", session=session) - - # Validate that the agent remembers the previous conversation - assert isinstance(response2, AgentResponse) - assert response2.text is not None - # Should reference Alice from the previous conversation - assert "alice" in response2.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_code_interpreter(): - """Test Agent with code interpreter through AzureAIAgentClient.""" - - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that can write and execute Python code.", - tools=[AzureAIAgentClient.get_code_interpreter_tool()], - ) as agent: - # Request code execution - response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.") - - # Validate response - assert isinstance(response, AgentResponse) - assert response.text is not None - # Factorial of 5 is 120 - assert "120" in response.text or "factorial" in response.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_file_search(): - """Test Agent with file search through AzureAIAgentClient.""" - - client = AzureAIAgentClient(credential=AzureCliCredential()) - file: FileInfo | None = None - vector_store: VectorStore | None = None - - try: - # 1. Read and upload the test file to the Azure AI agent service - test_file_path = Path(__file__).parent / "resources" / "employees.pdf" - file = await client.agents_client.files.upload_and_poll(file_path=str(test_file_path), purpose="assistants") - vector_store = await client.agents_client.vector_stores.create_and_poll( - file_ids=[file.id], name="test_employees_vectorstore" - ) - - # 2. Create file search tool with uploaded resources - file_search_tool = AzureAIAgentClient.get_file_search_tool(vector_store_ids=[vector_store.id]) - - async with Agent( - client=client, - instructions="You are a helpful assistant that can search through uploaded employee files.", - tools=[file_search_tool], - ) as agent: - # 3. Test file search functionality - response = await agent.run("Who is the youngest employee in the files?") - - # Validate response - assert isinstance(response, AgentResponse) - assert response.text is not None - # Should find information about Alice Johnson (age 24) being the youngest - assert any(term in response.text.lower() for term in ["alice", "johnson", "24"]) - - finally: - # 4. Cleanup: Delete the vector store and file - try: - if vector_store: - await client.agents_client.vector_stores.delete(vector_store.id) - if file: - await client.agents_client.files.delete(file.id) - except Exception: - # Ignore cleanup errors to avoid masking the actual test failure - pass - finally: - await client.close() - - -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_hosted_mcp_tool() -> None: - """Integration test for MCP tool with Azure AI Agent using Microsoft Learn MCP.""" - - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - description="A Microsoft Learn MCP server for documentation questions", - approval_mode="never_require", - ) - - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=[mcp_tool], - ) as agent: - response = await agent.run( - "How to create an Azure storage account using az cli?", - options={"max_tokens": 200}, - ) - - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - - # With never_require approval mode, there should be no approval requests - assert len(response.user_input_requests) == 0, ( - f"Expected no approval requests with never_require mode, but got {len(response.user_input_requests)}" - ) - - # Should contain Azure-related content since it's asking about Azure CLI - assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_level_tool_persistence(): - """Test that agent-level tools persist across multiple runs with AzureAIAgentClient.""" - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that uses available tools.", - tools=[get_weather], - ) as agent: - # First run - agent-level tool should be available - first_response = await agent.run("What's the weather like in Chicago?") - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - # Should use the agent-level weather tool - assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "25"]) - - # Second run - agent-level tool should still be available (persistence test) - second_response = await agent.run("What's the weather in Miami?") - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - # Should use the agent-level weather tool again - assert any(term in second_response.text.lower() for term in ["miami", "sunny", "25"]) - - -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_chat_options_run_level() -> None: - """Test ChatOptions parameter coverage at run level.""" - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant.", - ) as agent: - response = await agent.run( - "Provide a brief, helpful response.", - tools=[get_weather], - options={ - "max_tokens": 100, - "temperature": 0.7, - "top_p": 0.9, - "tool_choice": "auto", - "metadata": {"test": "value"}, - }, - ) - - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - - -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_azure_ai_chat_client_agent_chat_options_agent_level() -> None: - """Test ChatOptions parameter coverage agent level.""" - async with Agent( - client=AzureAIAgentClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant.", - tools=[get_weather], - default_options={ - "max_tokens": 100, - "temperature": 0.7, - "top_p": 0.9, - "tool_choice": "auto", - "metadata": {"test": "value"}, - }, - ) as agent: - response = await agent.run( - "Provide a brief, helpful response.", - ) - - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - - async def test_azure_ai_chat_client_cleanup_agent_when_enabled_and_created( mock_agents_client: MagicMock, ) -> None: diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index f0246f40b2..f0d8cbc430 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -11,8 +11,6 @@ from uuid import uuid4 import pytest from agent_framework import ( - Agent, - AgentResponse, Annotation, ChatOptions, ChatResponse, @@ -24,7 +22,7 @@ from agent_framework import ( tool, ) from agent_framework._settings import load_settings -from agent_framework.openai._responses_client import RawOpenAIResponsesClient +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( ApproximateLocation, @@ -41,17 +39,11 @@ from azure.identity.aio import AzureCliCredential from openai.types.responses.parsed_response import ParsedResponse from openai.types.responses.response import Response as OpenAIResponse from pydantic import BaseModel, ConfigDict, Field -from pytest import fixture, param +from pytest import fixture from agent_framework_azure_ai import AzureAIClient, AzureAISettings from agent_framework_azure_ai._shared import from_azure_ai_tools -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") - or os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") == "", - reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL_DEPLOYMENT_NAME provided; skipping integration tests.", -) - @pytest.fixture def mock_project_client() -> MagicMock: @@ -415,7 +407,7 @@ async def test_prepare_options_basic(mock_project_client: MagicMock) -> None: with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model"}, ), patch.object( @@ -452,7 +444,7 @@ async def test_prepare_options_with_application_endpoint( with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model"}, ), patch.object( @@ -494,7 +486,7 @@ async def test_prepare_options_with_application_project_client( with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model"}, ), patch.object( @@ -512,19 +504,6 @@ async def test_prepare_options_with_application_project_client( assert "extra_body" not in run_options -async def test_initialize_client(mock_project_client: MagicMock) -> None: - """Test _initialize_client method.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_openai_client = MagicMock() - mock_project_client.get_openai_client = MagicMock(return_value=mock_openai_client) - - await client._initialize_client() - - assert client.client is mock_openai_client - mock_project_client.get_openai_client.assert_called_once() - - def test_update_agent_name_and_description(mock_project_client: MagicMock) -> None: """Test _update_agent_name_and_description method.""" client = create_test_azure_ai_client(mock_project_client) @@ -827,14 +806,14 @@ async def test_runtime_tools_override_logs_warning( messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] with patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, ): await client._prepare_options(messages, {}) with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_two"}]}, ), patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, @@ -853,7 +832,7 @@ async def test_prepare_options_logs_warning_for_tools_with_existing_agent_versio with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, ), patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, @@ -875,7 +854,7 @@ async def test_prepare_options_logs_warning_for_tools_on_application_endpoint( with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, ), patch.object(client, "_get_agent_reference_or_create", new_callable=AsyncMock) as mock_get_agent_reference, @@ -1101,14 +1080,14 @@ async def test_runtime_structured_output_override_logs_warning( messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] with patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model"}, ): await client._prepare_options(messages, {"response_format": ResponseFormatModel}) with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={"model": "test-model"}, ), patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, @@ -1129,7 +1108,7 @@ async def test_prepare_options_excludes_response_format( with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={ "model": "test-model", "response_format": ResponseFormatModel, @@ -1164,7 +1143,7 @@ async def test_prepare_options_keeps_values_for_unsupported_option_keys( with ( patch( - "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", return_value={ "model": "test-model", "tools": [{"type": "function", "name": "weather"}], @@ -1365,352 +1344,6 @@ async def client() -> AsyncGenerator[AzureAIClient, None]: await project_client.agents.delete(agent_name=agent_name) -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -@pytest.mark.parametrize( - "option_name,option_value,needs_validation", - [ - # Simple ChatOptions - just verify they don't fail - param("top_p", 0.9, False, id="top_p"), - param("max_tokens", 500, False, id="max_tokens"), - param("seed", 123, False, id="seed"), - param("user", "test-user-id", False, id="user"), - param("metadata", {"test_key": "test_value"}, False, id="metadata"), - param("frequency_penalty", 0.5, False, id="frequency_penalty"), - param("presence_penalty", 0.3, False, id="presence_penalty"), - param("stop", ["END"], False, id="stop"), - param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"), - param("tool_choice", "none", True, id="tool_choice_none"), - param("tool_choice", "auto", True, id="tool_choice_auto"), - param("tool_choice", "required", True, id="tool_choice_required_any"), - param( - "tool_choice", - {"mode": "required", "required_function_name": "get_weather"}, - True, - id="tool_choice_required", - ), - # OpenAIResponsesOptions - just verify they don't fail - param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), - param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), - param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), - param("max_tool_calls", 3, False, id="max_tool_calls"), - ], -) -async def test_integration_options( - option_name: str, - option_value: Any, - needs_validation: bool, - client: AzureAIClient, -) -> None: - """Parametrized test covering options that can be set at runtime for a Foundry Agent. - - Tests both streaming and non-streaming modes for each option to ensure - they don't cause failures. Options marked with needs_validation also - check that the feature actually works correctly. - - This test reuses a single agent. - """ - # Prepare test message - if option_name.startswith("tool_choice"): - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value, "tools": [get_weather]} - - for streaming in [False, True]: - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - - # For tool_choice="required", we return after tool execution without a model text response - is_required_tool_choice = option_name == "tool_choice" and ( - option_value == "required" or (isinstance(option_value, dict) and option_value.get("mode") == "required") - ) - - if is_required_tool_choice: - # Response should have function call and function result, but no text from model - assert len(response.messages) >= 2, f"Expected function call + result for {option_name}" - has_function_call = any(c.type == "function_call" for msg in response.messages for c in msg.contents) - has_function_result = any(c.type == "function_result" for msg in response.messages for c in msg.contents) - assert has_function_call, f"No function call in response for {option_name}" - assert has_function_result, f"No function result in response for {option_name}" - else: - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name.startswith("tool_choice") and not is_required_tool_choice: - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name == "response_format": - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -@pytest.mark.parametrize( - "option_name,option_value,needs_validation", - [ - param("temperature", 0.7, False, id="temperature"), - # Complex options requiring output validation - param("response_format", OutputStruct, True, id="response_format_pydantic"), - param( - "response_format", - { - "type": "json_schema", - "json_schema": { - "name": "WeatherDigest", - "strict": True, - "schema": { - "title": "WeatherDigest", - "type": "object", - "properties": { - "location": {"type": "string"}, - "conditions": {"type": "string"}, - "temperature_c": {"type": "number"}, - "advisory": {"type": "string"}, - }, - "required": ["location", "conditions", "temperature_c", "advisory"], - "additionalProperties": False, - }, - }, - }, - True, - id="response_format_runtime_json_schema", - ), - ], -) -async def test_integration_agent_options( - option_name: str, - option_value: Any, - needs_validation: bool, -) -> None: - """Test Foundry agent level options in both streaming and non-streaming modes. - - Tests both streaming and non-streaming modes for each option to ensure - they don't cause failures. Options marked with needs_validation also - check that the feature actually works correctly. - - This test create a new client and uses it for both streaming and non-streaming tests. - """ - async with temporary_chat_client(agent_name=f"test-agent-{option_name.replace('_', '-')}-{uuid4()}") as client: - for streaming in [False, True]: - # Prepare test message - if option_name.startswith("response_format"): - # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options = {option_name: option_value} - - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation and option_name.startswith("response_format"): - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_web_search() -> None: - async with temporary_chat_client(agent_name="af-int-test-web-search") as client: - for streaming in [False, True]: - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [client.get_web_search_tool()], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - content = { - "messages": [ - Message(role="user", text="What is the current weather? Do not ask for my current location.") - ], - "options": { - "tool_choice": "auto", - "tools": [client.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_agent_hosted_mcp_tool() -> None: - """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" - async with temporary_chat_client(agent_name="af-int-test-mcp") as client: - response = await client.get_response( - messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], - options={ - # this needs to be high enough to handle the full MCP tool response. - "max_tokens": 5000, - "tools": client.get_mcp_tool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - description="A Microsoft Learn MCP server for documentation questions", - approval_mode="never_require", - ), - }, - ) - assert isinstance(response, ChatResponse) - assert response.text - # Should contain Azure-related content since it's asking about Azure CLI - assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_agent_hosted_code_interpreter_tool(): - """Test Azure Responses Client agent with code interpreter tool through AzureAIClient.""" - async with temporary_chat_client(agent_name="af-int-test-code-interpreter") as client: - response = await client.get_response( - messages=[Message(role="user", text="Calculate the sum of numbers from 1 to 10 using Python code.")], - options={ - "tools": [client.get_code_interpreter_tool()], - }, - ) - # Should contain calculation result (sum of 1-10 = 55) or code execution content - contains_relevant_content = any( - term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"] - ) - assert contains_relevant_content or len(response.text.strip()) > 10 - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_agent_existing_session(): - """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" - # First conversation - capture the session - preserved_session = None - - async with ( - temporary_chat_client(agent_name="af-int-test-existing-session") as client, - Agent( - client=client, - instructions="You are a helpful assistant with good memory.", - ) as first_agent, - ): - # Start a conversation and capture the session - session = first_agent.create_session() - first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True) - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - - # Preserve the session for reuse - preserved_session = session - - # Second conversation - reuse the session in a new agent instance - if preserved_session: - async with ( - temporary_chat_client(agent_name="af-int-test-existing-session-2") as client, - Agent( - client=client, - instructions="You are a helpful assistant with good memory.", - ) as second_agent, - ): - # Reuse the preserved session - second_response = await second_agent.run("What is my hobby?", session=preserved_session) - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - assert "photography" in second_response.text.lower() - - # region Factory Method Tests @@ -2031,7 +1664,7 @@ async def test_inner_get_response_enriches_non_streaming(mock_project_client: Ma async def _fake_awaitable() -> ChatResponse: return base_response - with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=_fake_awaitable()): + with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()): result_awaitable = client._inner_get_response(messages=[], options={}, stream=False) result = await result_awaitable # type: ignore[misc] @@ -2054,7 +1687,7 @@ async def test_inner_get_response_no_search_output_non_streaming(mock_project_cl async def _fake_awaitable() -> ChatResponse: return base_response - with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=_fake_awaitable()): + with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()): result_awaitable = client._inner_get_response(messages=[], options={}, stream=False) result = await result_awaitable # type: ignore[misc] @@ -2075,7 +1708,7 @@ def test_inner_get_response_streaming_registers_hook(mock_project_client: MagicM mock_stream = _create_mock_stream() - with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=mock_stream): + with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): result = client._inner_get_response(messages=[], options={}, stream=True) assert result is mock_stream @@ -2088,7 +1721,7 @@ def test_streaming_hook_captures_search_urls(mock_project_client: MagicMock) -> mock_stream = _create_mock_stream() - with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=mock_stream): + with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): client._inner_get_response(messages=[], options={}, stream=True) hook = mock_stream._transform_hooks[0] @@ -2116,7 +1749,7 @@ def test_streaming_hook_enriches_url_citation(mock_project_client: MagicMock) -> mock_stream = _create_mock_stream() - with patch.object(RawOpenAIResponsesClient, "_inner_get_response", return_value=mock_stream): + with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): client._inner_get_response(messages=[], options={}, stream=True) hook = mock_stream._transform_hooks[0] diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py index cb312983d4..bc85948fca 100644 --- a/python/packages/azure-ai/tests/test_provider.py +++ b/python/packages/azure-ai/tests/test_provider.py @@ -1,12 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. -import os from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import Agent, FunctionTool from agent_framework._mcp import MCPTool -from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( AgentVersionDetails, PromptAgentDefinition, @@ -14,16 +12,9 @@ from azure.ai.projects.models import ( from azure.ai.projects.models import ( FunctionTool as AzureFunctionTool, ) -from azure.identity.aio import AzureCliCredential from agent_framework_azure_ai import AzureAIProjectAgentProvider -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") - or os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") == "", - reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL_DEPLOYMENT_NAME provided; skipping integration tests.", -) - @pytest.fixture def mock_project_client() -> MagicMock: @@ -689,42 +680,3 @@ async def test_provider_create_agent_with_mcp_and_regular_tools( assert "regular_function" in tool_names assert "mcp_function_1" in tool_names assert "mcp_function_2" in tool_names - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_provider_create_and_get_agent_integration() -> None: - """Integration test for provider create_agent and get_agent.""" - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential) as project_client, - ): - provider = AzureAIProjectAgentProvider(project_client=project_client) - - try: - # Create agent - agent = await provider.create_agent( - name="ProviderTestAgent", - model=model, - instructions="You are a helpful assistant. Always respond with 'Hello from provider!'", - ) - - assert isinstance(agent, Agent) - assert agent.name == "ProviderTestAgent" - - # Run the agent - response = await agent.run("Hi!") - assert response.text is not None - assert len(response.text) > 0 - - # Get the same agent - retrieved_agent = await provider.get_agent(name="ProviderTestAgent") - assert retrieved_agent.name == "ProviderTestAgent" - - finally: - # Cleanup - await project_client.agents.delete(agent_name="ProviderTestAgent") diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index 6d205fa378..6a61350a9c 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -13,10 +13,13 @@ from typing import Any, ClassVar, TypedDict from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message from agent_framework._sessions import BaseHistoryProvider from agent_framework._settings import SecretString, load_settings -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from azure.cosmos import PartitionKey from azure.cosmos.aio import ContainerProxy, CosmosClient, DatabaseProxy +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + logger = logging.getLogger(__name__) diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 9566c53c09..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", ] @@ -84,10 +84,17 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos" -test = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests" -integration-tests = "pytest tests/test_cosmos_history_provider.py -m integration" +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests" + +[tool.poe.tasks.integration-tests] +help = "Run the package integration test suite." +cmd = "pytest tests/test_cosmos_history_provider.py -m integration" [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 78b38541dd..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", @@ -91,9 +91,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions" -test = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/azurefunctions/tests/integration_tests/README.md b/python/packages/azurefunctions/tests/integration_tests/README.md index a7f9fadc44..8b1f05f6b0 100644 --- a/python/packages/azurefunctions/tests/integration_tests/README.md +++ b/python/packages/azurefunctions/tests/integration_tests/README.md @@ -14,7 +14,7 @@ cp .env.example .env Required variables: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` +- `AZURE_OPENAI_DEPLOYMENT_NAME` - `AZURE_OPENAI_API_KEY` - `AzureWebJobsStorage` - `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` diff --git a/python/packages/azurefunctions/tests/integration_tests/conftest.py b/python/packages/azurefunctions/tests/integration_tests/conftest.py index 3f6060d93d..98ce922f7e 100644 --- a/python/packages/azurefunctions/tests/integration_tests/conftest.py +++ b/python/packages/azurefunctions/tests/integration_tests/conftest.py @@ -111,13 +111,17 @@ def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]: f"Durable Task Scheduler emulator not running on port {_DTS_EMULATOR_PORT}. Start with: docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest", # noqa: E501 ) - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "").strip() - if not endpoint or endpoint == "https://your-resource.openai.azure.com/": - return True, "No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests." - - deployment_name = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "").strip() - if not deployment_name or deployment_name == "your-deployment-name": - return True, "No real AZURE_OPENAI_CHAT_DEPLOYMENT_NAME provided; skipping integration tests." + has_foundry_config = bool(os.getenv("FOUNDRY_PROJECT_ENDPOINT", "").strip()) and bool( + os.getenv("FOUNDRY_MODEL", "").strip() + ) + has_azure_openai_config = bool(os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()) and bool( + os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "").strip() + ) + if not has_foundry_config and not has_azure_openai_config: + return ( + True, + "No real FOUNDRY_* or AZURE_OPENAI_* configuration provided; skipping integration tests.", + ) return False, "Integration tests enabled." @@ -322,22 +326,22 @@ def _is_port_in_use(port: int, host: str = _DEFAULT_HOST) -> bool: return sock.connect_ex((host, port)) == 0 -def _load_and_validate_env() -> None: +def _load_and_validate_env(sample_path: Path) -> None: """Load .env file from current directory if it exists, then validate required environment variables. Raises pytest.fail if required environment variables are missing. """ _load_env_file_if_present() - # Required environment variables for Azure Functions samples - # These match the variables defined in .env.example required_env_vars = [ - "AZURE_OPENAI_ENDPOINT", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "AzureWebJobsStorage", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING", "FUNCTIONS_WORKER_RUNTIME", ] + if sample_path.name == "11_workflow_parallel": + required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"]) + else: + required_env_vars.extend(["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"]) # Check if required env vars are set missing_vars = [var for var in required_env_vars if not os.environ.get(var)] @@ -526,7 +530,7 @@ def function_app_for_test(request: pytest.FixtureRequest) -> Iterator[dict[str, assert sample_path is not None, "Sample path must be resolved before starting the function app" # Load .env file if it exists and validate required env vars - _load_and_validate_env() + _load_and_validate_env(sample_path) max_attempts = 3 last_error: Exception | None = None diff --git a/python/packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py b/python/packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py index 683ab7e0be..bc9fa59bca 100644 --- a/python/packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py +++ b/python/packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py @@ -42,6 +42,7 @@ class TestWorkflowParallel: self.base_url = base_url self.helper = sample_helper + @pytest.mark.skip(reason="Causes timeouts.") def test_parallel_workflow_document_analysis(self) -> None: """Test parallel workflow with a standard document.""" payload = { @@ -70,6 +71,7 @@ class TestWorkflowParallel: assert status["runtimeStatus"] == "Completed" assert "output" in status + @pytest.mark.skip(reason="Causes timeouts.") def test_parallel_workflow_short_document(self) -> None: """Test parallel workflow with a short document.""" payload = { @@ -89,6 +91,7 @@ class TestWorkflowParallel: assert status["runtimeStatus"] == "Completed" assert "output" in status + @pytest.mark.skip(reason="Causes timeouts.") def test_parallel_workflow_technical_document(self) -> None: """Test parallel workflow with a technical document.""" payload = { @@ -112,6 +115,7 @@ class TestWorkflowParallel: status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300) assert status["runtimeStatus"] == "Completed" + @pytest.mark.skip(reason="Causes timeouts.") def test_workflow_status_endpoint(self) -> None: """Test that the workflow status endpoint works correctly.""" payload = { 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/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/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index 201ae0e80f..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", ] @@ -84,9 +84,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock" -test = 'pytest -m "not integration" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests' [build-system] requires = ["hatchling"] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 9bb2bdbce3..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", ] @@ -86,9 +86,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit" -test = 'pytest -m "not integration" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 1ee7e0cd50..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", ] @@ -86,9 +86,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude" -test = 'pytest -m "not integration" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 8756ec40bd..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", ] @@ -85,9 +85,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio" -test = 'pytest -m "not integration" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/core/README.md b/python/packages/core/README.md index ff194ad859..9d88cc4556 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -14,8 +14,8 @@ Highlights ```bash pip install agent-framework-core --pre -# Optional: Add Azure AI integration -pip install agent-framework-azure-ai --pre +# Optional: Add Azure AI Foundry integration +pip install agent-framework-foundry --pre ``` Supported Platforms: @@ -36,8 +36,8 @@ AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=... ... -AZURE_AI_PROJECT_ENDPOINT=... -AZURE_AI_MODEL_DEPLOYMENT_NAME=... +FOUNDRY_PROJECT_ENDPOINT=... +FOUNDRY_MODEL=... ``` You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index c2c6e874f1..27a6a45747 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -24,9 +24,6 @@ from typing import ( ) from uuid import uuid4 -from mcp import types -from mcp.server.lowlevel import Server -from mcp.shared.exceptions import McpError from pydantic import BaseModel from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage] @@ -71,6 +68,9 @@ else: from typing_extensions import Self, TypedDict # pragma: no cover if TYPE_CHECKING: + from mcp import types + from mcp.server.lowlevel import Server + from ._compaction import CompactionStrategy, TokenizerProtocol from ._types import ChatOptions @@ -1369,6 +1369,15 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] Returns: The MCP server instance. """ + try: + from mcp import types + from mcp.server.lowlevel import Server + from mcp.shared.exceptions import McpError + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "`mcp` is required to use `Agent.as_mcp_server()`. Please install `mcp`." + ) from exc + server_args: dict[str, Any] = { "name": server_name, "version": version, @@ -1469,8 +1478,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] class Agent( - AgentTelemetryLayer, AgentMiddlewareLayer, + AgentTelemetryLayer, RawAgent[OptionsCoT], Generic[OptionsCoT], ): 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/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 5901e34dd9..267e176ee8 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -13,22 +13,13 @@ from collections.abc import Callable, Collection, Sequence from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore from datetime import timedelta from functools import partial -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast -import httpx -from anyio import ClosedResourceError -from mcp import types -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.client.streamable_http import streamable_http_client -from mcp.client.websocket import websocket_client -from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError -from mcp.shared.session import RequestResponder from opentelemetry import propagate from ._tools import FunctionTool from ._types import ( + ChatOptions, Content, Message, ) @@ -40,9 +31,18 @@ else: from typing_extensions import Self # pragma: no cover if TYPE_CHECKING: + from httpx import AsyncClient + from mcp import types + from mcp.client.session import ClientSession + from mcp.shared.context import RequestContext + from mcp.shared.session import RequestResponder + from ._clients import SupportsChatGetResponse +logger = logging.getLogger(__name__) + + class MCPSpecificApproval(TypedDict, total=False): """Represents the specific approval mode for an MCP tool. @@ -57,13 +57,12 @@ class MCPSpecificApproval(TypedDict, total=False): never_require_approval: Collection[str] | None -logger = logging.getLogger(__name__) _MCP_REMOTE_NAME_KEY = "_mcp_remote_name" _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name" # region: Helpers -LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = { +LOG_LEVEL_MAPPING: dict[str, int] = { "debug": logging.DEBUG, "info": logging.INFO, "notice": logging.INFO, @@ -75,269 +74,6 @@ LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = { } -def _parse_prompt_result_from_mcp( - mcp_type: types.GetPromptResult, -) -> str: - """Parse an MCP GetPromptResult directly into a string representation. - - Converts each message in the prompt result to its string form and combines them. - - Args: - mcp_type: The MCP GetPromptResult object to convert. - - Returns: - A string representation of the prompt result. - """ - parts: list[str] = [] - for message in mcp_type.messages: - content = message.content - if isinstance(content, types.TextContent): - parts.append(content.text) - elif isinstance(content, (types.ImageContent, types.AudioContent)): - parts.append( - json.dumps( - { - "type": "image" if isinstance(content, types.ImageContent) else "audio", - "data": content.data, - "mimeType": content.mimeType, - }, - default=str, - ) - ) - elif isinstance(content, types.EmbeddedResource): - match content.resource: - case types.TextResourceContents(): - parts.append(content.resource.text) - case types.BlobResourceContents(): - parts.append( - json.dumps( - { - "type": "blob", - "data": content.resource.blob, - "mimeType": content.resource.mimeType, - }, - default=str, - ) - ) - else: - parts.append(str(content)) - if not parts: - return "" - if len(parts) == 1: - return parts[0] - return json.dumps(parts, default=str) - - -def _parse_message_from_mcp( - mcp_type: types.PromptMessage | types.SamplingMessage, -) -> Message: - """Parse an MCP container type into an Agent Framework type.""" - return Message( - role=mcp_type.role, - contents=_parse_content_from_mcp(mcp_type.content), - raw_representation=mcp_type, - ) - - -def _parse_tool_result_from_mcp( - mcp_type: types.CallToolResult, -) -> list[Content]: - """Parse an MCP CallToolResult into a list of Content items. - - Converts each content item in the MCP result to its appropriate - Content form. Text items become ``Content(type="text")`` and media - items (images, audio) are preserved as rich Content. - - Args: - mcp_type: The MCP CallToolResult object to convert. - - Returns: - A list of Content items representing the tool result. - """ - result: list[Content] = [] - for item in mcp_type.content: - match item: - case types.TextContent(): - result.append(Content.from_text(item.text)) - case types.ImageContent() | types.AudioContent(): - decoded = base64.b64decode(item.data) - result.append( - Content.from_data( - data=decoded, - media_type=item.mimeType, - ) - ) - case types.ResourceLink(): - result.append( - Content.from_uri( - uri=str(item.uri), - media_type=item.mimeType, - ) - ) - case types.EmbeddedResource(): - match item.resource: - case types.TextResourceContents(): - result.append(Content.from_text(item.resource.text)) - case types.BlobResourceContents(): - blob = item.resource.blob - mime = item.resource.mimeType or "application/octet-stream" - if not blob.startswith("data:"): - blob = f"data:{mime};base64,{blob}" - result.append( - Content.from_uri( - uri=blob, - media_type=mime, - ) - ) - case _: - result.append(Content.from_text(str(item))) - - if not result: - result.append(Content.from_text("null")) - return result - - -def _parse_content_from_mcp( - mcp_type: types.ImageContent - | types.TextContent - | types.AudioContent - | types.EmbeddedResource - | types.ResourceLink - | types.ToolUseContent - | types.ToolResultContent - | Sequence[ - types.ImageContent - | types.TextContent - | types.AudioContent - | types.EmbeddedResource - | types.ResourceLink - | types.ToolUseContent - | types.ToolResultContent - ], -) -> list[Content]: - """Parse an MCP type into an Agent Framework type.""" - mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type] - return_types: list[Content] = [] - for mcp_type in mcp_types: - match mcp_type: - case types.TextContent(): - return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type)) - case types.ImageContent() | types.AudioContent(): - # MCP protocol uses base64-encoded strings, convert to bytes - data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data - return_types.append( - Content.from_data( - data=data_bytes, - media_type=mcp_type.mimeType, - raw_representation=mcp_type, - ) - ) - case types.ResourceLink(): - return_types.append( - Content.from_uri( - uri=str(mcp_type.uri), - media_type=mcp_type.mimeType or "application/json", - raw_representation=mcp_type, - ) - ) - case types.ToolUseContent(): - return_types.append( - Content.from_function_call( - call_id=mcp_type.id, - name=mcp_type.name, - arguments=mcp_type.input, - raw_representation=mcp_type, - ) - ) - case types.ToolResultContent(): - return_types.append( - Content.from_function_result( - call_id=mcp_type.toolUseId, - result=_parse_content_from_mcp(mcp_type.content) - if mcp_type.content - else mcp_type.structuredContent, - exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type] - raw_representation=mcp_type, - ) - ) - case types.EmbeddedResource(): - match mcp_type.resource: - case types.TextResourceContents(): - return_types.append( - Content.from_text( - text=mcp_type.resource.text, - raw_representation=mcp_type, - additional_properties=( - mcp_type.annotations.model_dump() if mcp_type.annotations else None - ), - ) - ) - case types.BlobResourceContents(): - return_types.append( - Content.from_uri( - uri=mcp_type.resource.blob, - media_type=mcp_type.resource.mimeType, - raw_representation=mcp_type, - additional_properties=( - mcp_type.annotations.model_dump() if mcp_type.annotations else None - ), - ) - ) - return return_types - - -def _prepare_content_for_mcp( - content: Content, -) -> types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None: - """Prepare an Agent Framework content type for MCP.""" - if content.type == "text": - return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined] - if content.type == "data": - if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined] - return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] - if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined] - return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] - if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined] - return types.EmbeddedResource( - type="resource", - resource=types.BlobResourceContents( - blob=content.uri, # type: ignore[attr-defined] - mimeType=content.media_type, # type: ignore[attr-defined] - # uri's are not limited in MCP but they have to be set. - # the uri of data content, contains the data uri, which - # is not the uri meant here, UriContent would match this. - uri=( - content.additional_properties.get("uri", "af://binary") - if content.additional_properties - else "af://binary" - ), # type: ignore[reportArgumentType] - ), - ) - return None - if content.type == "uri": - return types.ResourceLink( - type="resource_link", - uri=content.uri, # type: ignore[reportArgumentType,attr-defined] - mimeType=content.media_type, # type: ignore[attr-defined] - name=(content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"), - ) - return None - - -def _prepare_message_for_mcp( - content: Message, -) -> list[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink]: - """Prepare a Message for MCP format.""" - messages: list[ - types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink - ] = [] - for item in content.contents: - mcp_content = _prepare_content_for_mcp(item) - if mcp_content: - messages.append(mcp_content) - return messages - - def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> dict[str, Any]: """Get the input model from an MCP prompt. @@ -462,8 +198,8 @@ class MCPTool: ``Callable[[types.GetPromptResult], str]`` that overrides the default prompt result parsing. When ``None`` (the default), the built-in parser converts MCP prompt results to a string. If you need per-function result parsing, - access the ``.functions`` list after connecting and set ``result_parser`` on - individual ``FunctionTool`` instances. + access the ``.functions`` list after connecting and set ``result_parser`` on + individual ``FunctionTool`` instances. session: An existing MCP client session to use. request_timeout: Timeout in seconds for MCP requests. client: A chat client for sampling callbacks. @@ -495,6 +231,264 @@ class MCPTool: def __str__(self) -> str: return f"MCPTool(name={self.name}, description={self.description})" + def _parse_prompt_result_from_mcp( + self, + mcp_type: types.GetPromptResult, + ) -> str: + """Parse an MCP GetPromptResult directly into a string representation.""" + from mcp import types + + parts: list[str] = [] + for message in mcp_type.messages: + content = message.content + if isinstance(content, types.TextContent): + parts.append(content.text) + elif isinstance(content, (types.ImageContent, types.AudioContent)): + parts.append( + json.dumps( + { + "type": "image" if isinstance(content, types.ImageContent) else "audio", + "data": content.data, + "mimeType": content.mimeType, + }, + default=str, + ) + ) + elif isinstance(content, types.EmbeddedResource): + match content.resource: + case types.TextResourceContents(): + parts.append(content.resource.text) + case types.BlobResourceContents(): + parts.append( + json.dumps( + { + "type": "blob", + "data": content.resource.blob, + "mimeType": content.resource.mimeType, + }, + default=str, + ) + ) + else: + parts.append(str(content)) + if not parts: + return "" + if len(parts) == 1: + return parts[0] + return json.dumps(parts, default=str) + + def _parse_message_from_mcp( + self, + mcp_type: types.PromptMessage | types.SamplingMessage, + ) -> Message: + """Parse an MCP container type into an Agent Framework type.""" + return Message( + role=mcp_type.role, + contents=self._parse_content_from_mcp(mcp_type.content), + raw_representation=mcp_type, + ) + + def _parse_tool_result_from_mcp( + self, + mcp_type: types.CallToolResult, + ) -> list[Content]: + """Parse an MCP CallToolResult into a list of Content items.""" + from mcp import types + + result: list[Content] = [] + for item in mcp_type.content: + match item: + case types.TextContent(): + result.append(Content.from_text(item.text)) + case types.ImageContent() | types.AudioContent(): + decoded = base64.b64decode(item.data) + result.append( + Content.from_data( + data=decoded, + media_type=item.mimeType, + ) + ) + case types.ResourceLink(): + result.append( + Content.from_uri( + uri=str(item.uri), + media_type=item.mimeType, + ) + ) + case types.EmbeddedResource(): + match item.resource: + case types.TextResourceContents(): + result.append(Content.from_text(item.resource.text)) + case types.BlobResourceContents(): + blob = item.resource.blob + mime = item.resource.mimeType or "application/octet-stream" + if not blob.startswith("data:"): + blob = f"data:{mime};base64,{blob}" + result.append( + Content.from_uri( + uri=blob, + media_type=mime, + ) + ) + case _: + result.append(Content.from_text(str(item))) + + if not result: + result.append(Content.from_text("null")) + return result + + def _parse_content_from_mcp( + self, + mcp_type: types.ImageContent + | types.TextContent + | types.AudioContent + | types.EmbeddedResource + | types.ResourceLink + | types.ToolUseContent + | types.ToolResultContent + | Sequence[ + types.ImageContent + | types.TextContent + | types.AudioContent + | types.EmbeddedResource + | types.ResourceLink + | types.ToolUseContent + | types.ToolResultContent + ], + ) -> list[Content]: + """Parse an MCP type into an Agent Framework type.""" + from mcp import types + + mcp_content_types: Sequence[Any] = ( + cast(Sequence[Any], mcp_type) if isinstance(mcp_type, Sequence) else [mcp_type] + ) # type: ignore[redundant-cast] + return_types: list[Content] = [] + for mcp_type in mcp_content_types: + match mcp_type: + case types.TextContent(): + return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type)) + case types.ImageContent() | types.AudioContent(): + data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data + return_types.append( + Content.from_data( + data=data_bytes, + media_type=mcp_type.mimeType, + raw_representation=mcp_type, + ) + ) + case types.ResourceLink(): + return_types.append( + Content.from_uri( + uri=str(mcp_type.uri), + media_type=mcp_type.mimeType or "application/json", + raw_representation=mcp_type, + ) + ) + case types.ToolUseContent(): + return_types.append( + Content.from_function_call( + call_id=mcp_type.id, + name=mcp_type.name, + arguments=mcp_type.input, + raw_representation=mcp_type, + ) + ) + case types.ToolResultContent(): + return_types.append( + Content.from_function_result( + call_id=mcp_type.toolUseId, + result=self._parse_content_from_mcp(mcp_type.content) + if mcp_type.content + else mcp_type.structuredContent, + exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type] + raw_representation=mcp_type, + ) + ) + case types.EmbeddedResource(): + match mcp_type.resource: + case types.TextResourceContents(): + return_types.append( + Content.from_text( + text=mcp_type.resource.text, + raw_representation=mcp_type, + additional_properties=( + mcp_type.annotations.model_dump() if mcp_type.annotations else None + ), + ) + ) + case types.BlobResourceContents(): + return_types.append( + Content.from_uri( + uri=mcp_type.resource.blob, + media_type=mcp_type.resource.mimeType, + raw_representation=mcp_type, + additional_properties=( + mcp_type.annotations.model_dump() if mcp_type.annotations else None + ), + ) + ) + case _: + pass + return return_types + + def _prepare_content_for_mcp( + self, + content: Content, + ) -> ( + types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None + ): + """Prepare an Agent Framework content type for MCP.""" + from mcp import types + + if content.type == "text": + return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined] + if content.type == "data": + if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined] + return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] + if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined] + return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined] + if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined] + return types.EmbeddedResource( + type="resource", + resource=types.BlobResourceContents( + blob=content.uri, # type: ignore[attr-defined] + mimeType=content.media_type, # type: ignore[attr-defined] + uri=( + content.additional_properties.get("uri", "af://binary") + if content.additional_properties + else "af://binary" + ), # type: ignore[arg-type] + ), + ) + return None + if content.type == "uri": + resource_name = ( + content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown" + ) + return types.ResourceLink( + type="resource_link", + uri=content.uri, # type: ignore[arg-type,attr-defined] + mimeType=content.media_type, # type: ignore[attr-defined] + name=resource_name, + ) + return None + + def _prepare_message_for_mcp( + self, + content: Message, + ) -> list[ + types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink + ]: + """Prepare a Message for MCP format.""" + messages: list[ + types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink + ] = [] + for item in content.contents: + mcp_content = self._prepare_content_for_mcp(item) + if mcp_content: + messages.append(mcp_content) + return messages + @property def functions(self) -> list[FunctionTool]: """Get the list of functions that are allowed.""" @@ -649,8 +643,23 @@ class MCPTool: error_msg = f"Failed to connect to MCP server: {ex}" raise ToolException(error_msg, inner_exception=ex) from ex try: + try: + from mcp import types + from mcp.client.session import ClientSession as runtime_client_session + except ModuleNotFoundError as ex: + await self._safe_close_exit_stack() + raise ToolException( + "MCP support requires `mcp`. Please install `mcp`.", + inner_exception=ex, + ) from ex + + sampling_capabilities = None + if self.client is not None: + sampling_capabilities = types.SamplingCapability( + tools=types.SamplingToolsCapability(), + ) session = await self._exit_stack.enter_async_context( - ClientSession( + runtime_client_session( read_stream=transport[0], write_stream=transport[1], read_timeout_seconds=( @@ -659,6 +668,7 @@ class MCPTool: message_handler=self.message_handler, logging_callback=self.logging_callback, sampling_callback=self.sampling_callback, + sampling_capabilities=sampling_capabilities, ) ) except Exception as ex: @@ -681,7 +691,7 @@ class MCPTool: error_msg = f"MCP server failed to initialize: {ex}" raise ToolException(error_msg, inner_exception=ex) from ex self.session = session - elif self.session._request_id == 0: # type: ignore[reportPrivateUsage] + elif self.session._request_id == 0: # type: ignore[attr-defined] # If the session is not initialized, we need to reinitialize it await self.session.initialize() logger.debug("Connected to MCP server: %s", self.session) @@ -695,9 +705,10 @@ class MCPTool: if logger.level != logging.NOTSET: try: - await self.session.set_logging_level( - next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level) + level_name = cast( + Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level) ) + await self.session.set_logging_level(level_name) except Exception as exc: logger.warning("Failed to set log level to %s", logger.level, exc_info=exc) @@ -723,6 +734,8 @@ class MCPTool: Returns: Either a CreateMessageResult with the generated message or ErrorData if generation fails. """ + from mcp import types + if not self.client: return types.ErrorData( code=types.INTERNAL_ERROR, @@ -731,15 +744,36 @@ class MCPTool: logger.debug("Sampling callback called with params: %s", params) messages: list[Message] = [] for msg in params.messages: - messages.append(_parse_message_from_mcp(msg)) + messages.append(self._parse_message_from_mcp(msg)) + + options: ChatOptions[None] = {} + if params.systemPrompt is not None: + options["instructions"] = params.systemPrompt + if params.tools is not None: + options["tools"] = [ + FunctionTool( + name=tool.name, + description=tool.description or "", + input_model=tool.inputSchema, + ) + for tool in params.tools + ] + if params.toolChoice is not None and params.toolChoice.mode is not None: + options["tool_choice"] = params.toolChoice.mode + + if params.temperature is not None: + options["temperature"] = params.temperature + options["max_tokens"] = params.maxTokens + if params.stopSequences is not None: + options["stop"] = params.stopSequences + try: response = await self.client.get_response( messages, - temperature=params.temperature, - max_tokens=params.maxTokens, - stop=params.stopSequences, + options=options or None, ) except Exception as ex: + logger.debug("Sampling callback error: %s", ex, exc_info=True) return types.ErrorData( code=types.INTERNAL_ERROR, message=f"Failed to get chat message content: {ex}", @@ -749,7 +783,7 @@ class MCPTool: code=types.INTERNAL_ERROR, message="Failed to get chat message content.", ) - mcp_contents = _prepare_message_for_mcp(response.messages[0]) + mcp_contents = self._prepare_message_for_mcp(response.messages[0]) # grab the first content that is of type TextContent or ImageContent mcp_content = next( (content for content in mcp_contents if isinstance(content, (types.TextContent, types.ImageContent))), @@ -798,6 +832,8 @@ class MCPTool: Args: message: The message from the MCP server (request responder, notification, or exception). """ + from mcp import types + if isinstance(message, Exception): logger.error("Error from MCP server: %s", message, exc_info=message) return @@ -824,7 +860,7 @@ class MCPTool: ): return "never_require" return None - return self.approval_mode # type: ignore[reportReturnType] + return self.approval_mode # type: ignore[return-value] async def load_prompts(self) -> None: """Load prompts from the MCP server. @@ -835,6 +871,8 @@ class MCPTool: Raises: ToolExecutionException: If the MCP server is not connected. """ + from mcp import types + # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} @@ -883,6 +921,8 @@ class MCPTool: Raises: ToolExecutionException: If the MCP server is not connected. """ + from mcp import types + # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} @@ -902,13 +942,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, @@ -988,6 +1036,9 @@ class MCPTool: ToolExecutionException: If the MCP server is not connected, tools are not loaded, or the tool call fails. """ + from anyio import ClosedResourceError + from mcp.shared.exceptions import McpError + if not self.load_tools_flag: raise ToolExecutionException( "Tools are not loaded for this server, please set load_tools=True in the constructor." @@ -1017,8 +1068,7 @@ class MCPTool: # Inject OpenTelemetry trace context into MCP _meta for distributed tracing. otel_meta = _inject_otel_into_mcp_meta() - parser = self.parse_tool_results or _parse_tool_result_from_mcp - + parser = self.parse_tool_results or self._parse_tool_result_from_mcp # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: @@ -1054,7 +1104,8 @@ class MCPTool: inner_exception=cl_ex, ) from cl_ex except McpError as mcp_exc: - raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc + error_message = mcp_exc.error.message + raise ToolExecutionException(error_message, inner_exception=mcp_exc) from mcp_exc except Exception as ex: raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.") @@ -1075,13 +1126,15 @@ class MCPTool: ToolExecutionException: If the MCP server is not connected, prompts are not loaded, or the prompt call fails. """ + from anyio import ClosedResourceError + from mcp.shared.exceptions import McpError + if not self.load_prompts_flag: raise ToolExecutionException( "Prompts are not loaded for this server, please set load_prompts=True in the constructor." ) - parser = self.parse_prompt_results or _parse_prompt_result_from_mcp - + parser = self.parse_prompt_results or self._parse_prompt_result_from_mcp # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: @@ -1107,7 +1160,8 @@ class MCPTool: inner_exception=cl_ex, ) from cl_ex except McpError as mcp_exc: - raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc + error_message = mcp_exc.error.message + raise ToolExecutionException(error_message, inner_exception=mcp_exc) from mcp_exc except Exception as ex: raise ToolExecutionException(f"Failed to call prompt '{prompt_name}'.", inner_exception=ex) from ex raise ToolExecutionException(f"Failed to get prompt '{prompt_name}' after retries.") @@ -1281,6 +1335,11 @@ class MCPStdioTool(MCPTool): args["encoding"] = self.encoding if self._client_kwargs: args.update(self._client_kwargs) + try: + from mcp.client.stdio import StdioServerParameters, stdio_client + except ModuleNotFoundError as ex: + raise ModuleNotFoundError("`mcp` is required to use `MCPStdioTool`. Please install `mcp`.") from ex + return stdio_client(server=StdioServerParameters(**args)) @@ -1325,7 +1384,7 @@ class MCPStreamableHTTPTool(MCPTool): terminate_on_close: bool | None = None, client: SupportsChatGetResponse | None = None, additional_properties: dict[str, Any] | None = None, - http_client: httpx.AsyncClient | None = None, + http_client: AsyncClient | None = None, **kwargs: Any, ) -> None: """Initialize the MCP streamable HTTP tool. @@ -1333,7 +1392,7 @@ class MCPStreamableHTTPTool(MCPTool): Note: The arguments are used to create a streamable HTTP client using the new ``mcp.client.streamable_http.streamable_http_client`` API. - If an httpx.AsyncClient is provided via ``http_client``, it will be used directly. + If an asyncClient is provided via ``http_client``, it will be used directly. Otherwise, the ``streamable_http_client`` API will create and manage a default client. Args: @@ -1369,10 +1428,10 @@ class MCPStreamableHTTPTool(MCPTool): additional_properties: Additional properties. terminate_on_close: Close the transport when the MCP client is terminated. client: The chat client to use for sampling. - http_client: Optional httpx.AsyncClient to use. If not provided, the + http_client: Optional asyncClient to use. If not provided, the ``streamable_http_client`` API will create and manage a default client. To configure headers, timeouts, or other HTTP client settings, create - and pass your own ``httpx.AsyncClient`` instance. + and pass your own ``asyncClient`` instance. kwargs: Additional keyword arguments (accepted for backward compatibility but not used). """ super().__init__( @@ -1392,7 +1451,7 @@ class MCPStreamableHTTPTool(MCPTool): ) self.url = url self.terminate_on_close = terminate_on_close - self._httpx_client: httpx.AsyncClient | None = http_client + self._httpx_client: AsyncClient | None = http_client def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP streamable HTTP client. @@ -1400,6 +1459,11 @@ class MCPStreamableHTTPTool(MCPTool): Returns: An async context manager for the streamable HTTP client transport. """ + try: + from mcp.client.streamable_http import streamable_http_client + except ModuleNotFoundError as ex: + raise ModuleNotFoundError("`mcp` is required to use `MCPStreamableHTTPTool`. Please install `mcp`.") from ex + # Pass the http_client (which may be None) to streamable_http_client return streamable_http_client( url=self.url, @@ -1514,6 +1578,21 @@ class MCPWebsocketTool(MCPTool): Returns: An async context manager for the WebSocket client transport. """ + try: + from mcp.client.websocket import websocket_client + except ModuleNotFoundError as ex: + missing_name = ex.name or "mcp/websocket dependencies" + if missing_name == "mcp" or missing_name.startswith("mcp."): + reason = "The `mcp` package is not installed." + elif missing_name == "websockets" or missing_name.startswith("websockets."): + reason = "WebSocket transport support is not installed." + else: + reason = f"The optional dependency `{missing_name}` is not installed." + raise ModuleNotFoundError( + f"`MCPWebsocketTool` requires websocket transport support. {reason} " + "Please install `mcp[ws]` and update your dependencies." + ) from ex + args: dict[str, Any] = { "url": self.url, } 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 4119afec05..f7bc3f0e15 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, @@ -72,6 +77,7 @@ if TYPE_CHECKING: Content, Message, ResponseStream, + UsageDetails, ) else: @@ -460,9 +466,15 @@ class FunctionTool(SerializationMixin): if func is None: return create_model(f"{self.name}_input") sig = inspect.signature(func) + try: + type_hints = typing.get_type_hints(func, include_extras=True) + except Exception: + type_hints = {} fields: dict[str, Any] = { pname: ( - _parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str, + _parse_annotation(type_hints.get(pname, param.annotation)) + if type_hints.get(pname, param.annotation) is not inspect.Parameter.empty + else str, param.default if param.default is not inspect.Parameter.empty else ..., ) for pname, param in sig.parameters.items() @@ -2023,18 +2035,37 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): def __init__( self, *, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: - self.function_middleware: list[FunctionMiddlewareTypes] = ( - list(function_middleware) if function_middleware else [] - ) + from ._middleware import categorize_middleware + + middleware_list = categorize_middleware(middleware) + self.function_middleware: list[FunctionMiddlewareTypes] = list(middleware_list["function"]) + self._cached_function_middleware_pipeline: FunctionMiddlewarePipeline | None = None self.function_invocation_configuration = normalize_function_invocation_configuration( function_invocation_configuration ) + if (chat_middleware := (middleware_list["chat"] or None)) is not None: + kwargs["middleware"] = chat_middleware super().__init__(**kwargs) + def _get_function_middleware_pipeline( + self, + middleware: Sequence[FunctionMiddlewareTypes], + ) -> FunctionMiddlewarePipeline: + from ._middleware import FunctionMiddlewarePipeline + + effective_middleware = [*self.function_middleware, *middleware] + if self._cached_function_middleware_pipeline is not None and self._cached_function_middleware_pipeline.matches( + effective_middleware + ): + return self._cached_function_middleware_pipeline + + self._cached_function_middleware_pipeline = FunctionMiddlewarePipeline(*effective_middleware) + return self._cached_function_middleware_pipeline + @overload def get_response( self, @@ -2042,6 +2073,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: Literal[False] = ..., options: ChatOptions[ResponseModelBoundT], + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -2056,6 +2088,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: Literal[False] = ..., options: OptionsCoT | ChatOptions[None] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -2070,6 +2103,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: Literal[True], options: OptionsCoT | ChatOptions[Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -2083,18 +2117,19 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: bool = False, options: OptionsCoT | ChatOptions[Any] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - from ._middleware import FunctionMiddlewarePipeline + from ._middleware import categorize_middleware from ._types import ( ChatResponse, ChatResponseUpdate, ResponseStream, + add_usage_details, ) super_get_response = super().get_response # type: ignore[misc] @@ -2107,16 +2142,21 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} - effective_function_middleware = function_middleware - if effective_function_middleware is None: - middleware_from_client_kwargs = effective_client_kwargs.pop("function_middleware", None) - if middleware_from_client_kwargs is not None: - effective_function_middleware = cast(Sequence[Any], middleware_from_client_kwargs) + if middleware is not None: + existing = effective_client_kwargs.get("middleware", []) + effective_client_kwargs["middleware"] = [ + *( + existing + if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes)) + else [existing] + ), + *middleware, + ] + runtime_middleware = categorize_middleware(effective_client_kwargs.pop("middleware", [])) - # ChatMiddleware adds this kwarg - function_middleware_pipeline = FunctionMiddlewarePipeline( - *(self.function_middleware), *(effective_function_middleware or []) - ) + function_middleware_pipeline = self._get_function_middleware_pipeline(runtime_middleware["function"]) + if runtime_middleware["chat"]: + effective_client_kwargs["middleware"] = runtime_middleware["chat"] max_errors = self.function_invocation_configuration.get( "max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST ) @@ -2160,6 +2200,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): prepped_messages = list(messages) fcc_messages: list[Message] = [] response: ChatResponse[Any] | None = None + aggregated_usage: UsageDetails | None = None loop_enabled = self.function_invocation_configuration.get("enabled", True) max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS) @@ -2191,6 +2232,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): client_kwargs=filtered_kwargs, ), ) + aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) if response.conversation_id is not None: _update_conversation_id(kwargs, response.conversation_id, mutable_options) @@ -2207,6 +2249,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): execute_function_calls=execute_function_calls, ) if result.get("action") == "return": + response.usage_details = aggregated_usage return response total_function_calls += result.get("function_call_count", 0) if result.get("action") == "stop": @@ -2262,6 +2305,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): client_kwargs=filtered_kwargs, ), ) + aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) + response.usage_details = aggregated_usage if fcc_messages: for msg in reversed(fcc_messages): response.messages.insert(0, msg) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index a4e3a57330..14c3050952 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1995,6 +1995,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): messages: Message | Sequence[Message] | None = None, response_id: str | None = None, conversation_id: str | None = None, + model: str | None = None, model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, @@ -2011,8 +2012,9 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): messages: A single Message or sequence of Message objects to include in the response. response_id: Optional ID of the chat response. conversation_id: Optional identifier for the state of the conversation. - model_id: Optional model ID used in the creation of the chat response. - created_at: Optional timestamp for the chat response. + model: Optional model used in the creation of the chat response. + model_id: Deprecated alias for ``model``. + created_at: Optional timestamp for when the response was created. finish_reason: Optional reason for the chat response (e.g., "stop", "length", "tool_calls"). usage_details: Optional usage details for the chat response. value: Optional value of the structured output. @@ -2022,6 +2024,8 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): additional_properties: Optional additional properties associated with the chat response. raw_representation: Optional raw representation of the chat response from an underlying implementation. """ + if model_id is not None and model is None: + model = model_id if messages is None: self.messages: list[Message] = [] elif isinstance(messages, Message): @@ -2039,7 +2043,7 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): self.messages = processed_messages self.response_id = response_id self.conversation_id = conversation_id - self.model_id = model_id + self.model = model self.created_at = created_at self.finish_reason = finish_reason self.usage_details = usage_details @@ -2052,6 +2056,15 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]): self.continuation_token = continuation_token self.raw_representation: Any | list[Any] | None = raw_representation + @property + def model_id(self) -> str | None: + """Deprecated alias for :attr:`model`.""" + return self.model + + @model_id.setter + def model_id(self, value: str | None) -> None: + self.model = value + @overload @classmethod def from_updates( @@ -2249,6 +2262,7 @@ class ChatResponseUpdate(SerializationMixin): response_id: str | None = None, message_id: str | None = None, conversation_id: str | None = None, + model: str | None = None, model_id: str | None = None, created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, @@ -2265,7 +2279,8 @@ class ChatResponseUpdate(SerializationMixin): response_id: Optional ID of the response of which this update is a part. message_id: Optional ID of the message of which this update is a part. conversation_id: Optional identifier for the state of the conversation of which this update is a part - model_id: Optional model ID associated with this response update. + model: Optional model associated with this response update. + model_id: Deprecated alias for ``model``. created_at: Optional timestamp for the chat response update. finish_reason: Optional finish reason for the operation. continuation_token: Optional token for resuming a long-running background operation. @@ -2275,6 +2290,8 @@ class ChatResponseUpdate(SerializationMixin): from an underlying implementation. """ + if model_id is not None and model is None: + model = model_id # Handle contents - support dict conversion for from_dict if contents is None: self.contents: list[Content] = [] @@ -2294,7 +2311,7 @@ class ChatResponseUpdate(SerializationMixin): self.response_id = response_id self.message_id = message_id self.conversation_id = conversation_id - self.model_id = model_id + self.model = model self.created_at = created_at self.finish_reason = finish_reason self.continuation_token = continuation_token @@ -2304,6 +2321,15 @@ class ChatResponseUpdate(SerializationMixin): ) self.raw_representation = raw_representation + @property + def model_id(self) -> str | None: + """Deprecated alias for :attr:`model`.""" + return self.model + + @model_id.setter + def model_id(self, value: str | None) -> None: + self.model = value + @property def text(self) -> str: """Returns the concatenated text of all contents in the update.""" @@ -3418,7 +3444,7 @@ class Embedding(Generic[EmbeddingT]): Args: vector: The embedding vector data. - model_id: The model used to generate this embedding. + model: The model used to generate this embedding. dimensions: Explicit dimension count (computed from vector length if omitted). created_at: Timestamp of when the embedding was generated. additional_properties: Additional metadata. @@ -3430,7 +3456,7 @@ class Embedding(Generic[EmbeddingT]): embedding = Embedding( vector=[0.1, 0.2, 0.3], - model_id="text-embedding-3-small", + model="text-embedding-3-small", ) assert embedding.dimensions == 3 """ @@ -3439,19 +3465,31 @@ class Embedding(Generic[EmbeddingT]): self, vector: EmbeddingT, *, + model: str | None = None, model_id: str | None = None, dimensions: int | None = None, created_at: datetime | None = None, additional_properties: dict[str, Any] | None = None, ) -> None: + if model_id is not None and model is None: + model = model_id self.vector = vector self._dimensions = dimensions - self.model_id = model_id + self.model = model self.created_at = created_at self.additional_properties = ( _restore_compaction_annotation_in_additional_properties(additional_properties) or {} ) + @property + def model_id(self) -> str | None: + """Deprecated alias for :attr:`model`.""" + return self.model + + @model_id.setter + def model_id(self, value: str | None) -> None: + self.model = value + @property def dimensions(self) -> int | None: """Return the number of dimensions in the embedding vector. 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/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index c4694bf31b..d26952d8e5 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -121,7 +121,7 @@ WorkflowEventType = Literal[ "executor_completed", # Executor handler completed (use .executor_id, .data) "executor_failed", # Executor handler raised error (use .executor_id, .details) # Orchestration event types (use .data for typed payload) - "group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501 + "group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501 "handoff_sent", # Handoff routing events (use .data as HandoffSentEvent) "magentic_orchestrator", # Magentic orchestrator events (use .data as MagenticOrchestratorEvent) ] 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/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index dcf9fc321e..1748ada1a3 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -2,16 +2,7 @@ """Azure integration namespace for optional Agent Framework connectors. -This module lazily re-exports objects from optional Azure connector packages and -built-in core Azure OpenAI modules. - -Supported classes include: -- AzureAIClient -- AzureAIAgentClient -- AzureOpenAIChatClient -- AzureOpenAIResponsesClient -- AzureAISearchContextProvider -- DurableAIAgent +This module lazily re-exports objects from optional Azure connector packages. """ import importlib @@ -30,18 +21,17 @@ _IMPORTS: dict[str, tuple[str, str]] = { "AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "AzureAIAgentsProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureCredentialTypes": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"), - "AzureTokenProvider": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"), - "FoundryMemoryProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "agent-framework-core"), - "AzureOpenAIAssistantsOptions": ("agent_framework.azure._assistants_client", "agent-framework-core"), - "AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "agent-framework-core"), - "AzureOpenAIChatOptions": ("agent_framework.azure._chat_client", "agent-framework-core"), - "AzureOpenAIEmbeddingClient": ("agent_framework.azure._embedding_client", "agent-framework-core"), - "AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", "agent-framework-core"), - "AzureOpenAIResponsesOptions": ("agent_framework.azure._responses_client", "agent-framework-core"), - "AzureOpenAISettings": ("agent_framework.azure._shared", "agent-framework-core"), - "AzureUserSecurityContext": ("agent_framework.azure._chat_client", "agent-framework-core"), + "AzureCredentialTypes": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureTokenProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIAssistantsClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIAssistantsOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIChatClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIChatOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIEmbeddingClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIResponsesClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAIResponsesOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureOpenAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), + "AzureUserSecurityContext": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"), diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index 9f435efe40..cdd3b66046 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -1,5 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. +# Type stubs for the agent_framework.azure lazy-loading namespace. +# Install the relevant packages for full type support. + from agent_framework_azure_ai import ( AzureAIAgentClient, AzureAIAgentsProvider, @@ -7,9 +10,23 @@ from agent_framework_azure_ai import ( AzureAIProjectAgentOptions, AzureAIProjectAgentProvider, AzureAISettings, - FoundryMemoryProvider, + AzureCredentialTypes, + AzureOpenAIAssistantsClient, + AzureOpenAIAssistantsOptions, + AzureOpenAIChatClient, + AzureOpenAIChatOptions, + AzureOpenAIEmbeddingClient, + AzureOpenAIResponsesClient, + AzureOpenAIResponsesOptions, + AzureOpenAISettings, + AzureTokenProvider, + AzureUserSecurityContext, + RawAzureAIClient, +) +from agent_framework_azure_ai_search import ( + AzureAISearchContextProvider, + AzureAISearchSettings, ) -from agent_framework_azure_ai_search import AzureAISearchContextProvider, AzureAISearchSettings from agent_framework_azurefunctions import AgentFunctionApp from agent_framework_durabletask import ( AgentCallbackContext, @@ -20,13 +37,6 @@ from agent_framework_durabletask import ( DurableAIAgentWorker, ) -from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient -from agent_framework.azure._chat_client import AzureOpenAIChatClient -from agent_framework.azure._embedding_client import AzureOpenAIEmbeddingClient -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from agent_framework.azure._responses_client import AzureOpenAIResponsesClient -from agent_framework.azure._shared import AzureOpenAISettings - __all__ = [ "AgentCallbackContext", "AgentFunctionApp", @@ -41,14 +51,18 @@ __all__ = [ "AzureAISettings", "AzureCredentialTypes", "AzureOpenAIAssistantsClient", + "AzureOpenAIAssistantsOptions", "AzureOpenAIChatClient", + "AzureOpenAIChatOptions", "AzureOpenAIEmbeddingClient", "AzureOpenAIResponsesClient", + "AzureOpenAIResponsesOptions", "AzureOpenAISettings", "AzureTokenProvider", + "AzureUserSecurityContext", "DurableAIAgent", "DurableAIAgentClient", "DurableAIAgentOrchestrationContext", "DurableAIAgentWorker", - "FoundryMemoryProvider", + "RawAzureAIClient", ] diff --git a/python/packages/core/agent_framework/azure/_assistants_client.py b/python/packages/core/agent_framework/azure/_assistants_client.py deleted file mode 100644 index aae89d562d..0000000000 --- a/python/packages/core/agent_framework/azure/_assistants_client.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -from collections.abc import Mapping -from typing import Any, ClassVar, Generic - -from openai.lib.azure import AsyncAzureOpenAI - -from .._settings import load_settings -from ..openai import OpenAIAssistantsClient -from ..openai._assistants_client import OpenAIAssistantsOptions -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider -from ._shared import AzureOpenAISettings, _apply_azure_defaults # pyright: ignore[reportPrivateUsage] - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - - -# region Azure OpenAI Assistants Options TypedDict - - -AzureOpenAIAssistantsOptionsT = TypeVar( - "AzureOpenAIAssistantsOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIAssistantsOptions", - covariant=True, -) - - -# endregion - - -class AzureOpenAIAssistantsClient( - OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT] -): - """Azure OpenAI Assistants client.""" - - DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview" - - def __init__( - self, - *, - deployment_name: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - assistant_description: str | None = None, - thread_id: str | None = None, - api_key: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure OpenAI Assistants client. - - Keyword Args: - deployment_name: The Azure OpenAI deployment name for the model to use. - Can also be set via environment variable AZURE_OPENAI_CHAT_DEPLOYMENT_NAME. - assistant_id: The ID of an Azure OpenAI assistant to use. - If not provided, a new assistant will be created (and deleted after the request). - assistant_name: The name to use when creating new assistants. - assistant_description: The description to use when creating new assistants. - thread_id: Default thread ID to use for conversations. Can be overridden by - conversation_id property when making a request. - If not provided, a new thread will be created (and deleted after the request). - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable AZURE_OPENAI_API_KEY. - endpoint: The deployment endpoint. If provided will override the value - in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_ENDPOINT. - base_url: The deployment base URL. If provided will override the value - in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_BASE_URL. - api_version: The deployment API version. If provided will override the value - in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_API_VERSION. - token_endpoint: The token endpoint to request an Azure token. - Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT. - credential: Azure credential or token provider for authentication. Accepts a - ``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a - bearer token string (sync or async), for example from - ``azure.identity.get_bearer_token_provider()``. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureOpenAIAssistantsClient - - # Using environment variables - # Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com - # Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4 - # Set AZURE_OPENAI_API_KEY=your-key - client = AzureOpenAIAssistantsClient() - - # Or passing parameters directly - client = AzureOpenAIAssistantsClient( - endpoint="https://your-endpoint.openai.azure.com", deployment_name="gpt-4", api_key="your-key" - ) - - # Or loading from a .env file - client = AzureOpenAIAssistantsClient(env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework.azure import AzureOpenAIAssistantsOptions - - - class MyOptions(AzureOpenAIAssistantsOptions, total=False): - my_custom_option: str - - - client: AzureOpenAIAssistantsClient[MyOptions] = AzureOpenAIAssistantsClient() - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION) - - chat_deployment_name = azure_openai_settings.get("chat_deployment_name") - if not chat_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." - ) - - api_key_secret = azure_openai_settings.get("api_key") - token_scope = azure_openai_settings.get("token_endpoint") - - # Resolve credential to token provider - ad_token_provider = None - if not async_client and not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider(credential, token_scope) - - if not async_client and not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - # Create Azure client if not provided - if not async_client: - client_params: dict[str, Any] = { - "default_headers": default_headers, - } - if resolved_api_version := azure_openai_settings.get("api_version"): - client_params["api_version"] = resolved_api_version - - if api_key_secret: - client_params["api_key"] = api_key_secret.get_secret_value() - elif ad_token_provider: - client_params["azure_ad_token_provider"] = ad_token_provider - - if resolved_base_url := azure_openai_settings.get("base_url"): - client_params["base_url"] = str(resolved_base_url) - elif resolved_endpoint := azure_openai_settings.get("endpoint"): - client_params["azure_endpoint"] = str(resolved_endpoint) - - async_client = AsyncAzureOpenAI(**client_params) - - super().__init__( - model_id=chat_deployment_name, - assistant_id=assistant_id, - assistant_name=assistant_name, - assistant_description=assistant_description, - thread_id=thread_id, - async_client=async_client, # type: ignore[reportArgumentType] - default_headers=default_headers, - ) diff --git a/python/packages/core/agent_framework/azure/_chat_client.py b/python/packages/core/agent_framework/azure/_chat_client.py deleted file mode 100644 index 21c38f6b57..0000000000 --- a/python/packages/core/agent_framework/azure/_chat_client.py +++ /dev/null @@ -1,347 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import json -import logging -import sys -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, Generic, cast - -from openai.lib.azure import AsyncAzureOpenAI -from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice -from pydantic import BaseModel - -from agent_framework import ( - Annotation, - ChatMiddlewareLayer, - ChatResponse, - ChatResponseUpdate, - Content, - FunctionInvocationConfiguration, - FunctionInvocationLayer, -) -from agent_framework.observability import ChatTelemetryLayer -from agent_framework.openai import OpenAIChatOptions -from agent_framework.openai._chat_client import RawOpenAIChatClient - -from .._settings import load_settings -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._shared import ( - AzureOpenAIConfigMixin, - AzureOpenAISettings, - _apply_azure_defaults, # pyright: ignore[reportPrivateUsage] -) - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - -logger: logging.Logger = logging.getLogger(__name__) - - -ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) - - -# region Azure OpenAI Chat Options TypedDict - - -class AzureUserSecurityContext(TypedDict, total=False): - """User security context for Azure AI applications. - - These fields help security operations teams investigate and mitigate security - incidents by providing context about the application and end user. - - Learn more: https://learn.microsoft.com/azure/well-architected/service-guides/cosmos-db - """ - - application_name: str - """Name of the application making the request.""" - - end_user_id: str - """Unique identifier for the end user (recommend hashing username/email).""" - - end_user_tenant_id: str - """Microsoft 365 tenant ID the end user belongs to. Required for multi-tenant apps.""" - - source_ip: str - """The original client's IP address.""" - - -class AzureOpenAIChatOptions(OpenAIChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): - """Azure OpenAI-specific chat options dict. - - Extends OpenAIChatOptions with Azure-specific options including - the "On Your Data" feature and enhanced security context. - - See: https://learn.microsoft.com/azure/ai-foundry/openai/reference-preview-latest - - Keys: - # Inherited from OpenAIChatOptions/ChatOptions: - model_id: The model to use for the request, - translates to ``model`` in Azure OpenAI API. - temperature: Sampling temperature between 0 and 2. - top_p: Nucleus sampling parameter. - max_tokens: Maximum number of tokens to generate, - translates to ``max_completion_tokens`` in Azure OpenAI API. - stop: Stop sequences. - seed: Random seed for reproducibility. - frequency_penalty: Frequency penalty between -2.0 and 2.0. - presence_penalty: Presence penalty between -2.0 and 2.0. - tools: List of tools (functions) available to the model. - tool_choice: How the model should use tools. - allow_multiple_tool_calls: Whether to allow parallel tool calls, - translates to ``parallel_tool_calls`` in Azure OpenAI API. - response_format: Structured output schema. - metadata: Request metadata for tracking. - user: End-user identifier for abuse monitoring. - store: Whether to store the conversation. - instructions: System instructions for the model. - logit_bias: Token bias values (-100 to 100). - logprobs: Whether to return log probabilities. - top_logprobs: Number of top log probabilities to return (0-20). - - # Azure-specific options: - data_sources: Azure "On Your Data" data sources configuration. - user_security_context: Enhanced security context for Azure Defender. - n: Number of chat completions to generate (not recommended, incurs costs). - """ - - # Azure-specific options - data_sources: list[dict[str, Any]] - """Azure "On Your Data" data sources for retrieval-augmented generation. - - Supported types: azure_search, azure_cosmos_db, elasticsearch, pinecone, mongo_db. - See: https://learn.microsoft.com/azure/ai-foundry/openai/references/on-your-data - """ - - user_security_context: AzureUserSecurityContext - """Enhanced security context for Azure Defender integration.""" - - n: int - """Number of chat completion choices to generate for each input message. - Note: You will be charged based on tokens across all choices. Keep n=1 to minimize costs.""" - - -AzureOpenAIChatOptionsT = TypeVar( - "AzureOpenAIChatOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureOpenAIChatOptions", - covariant=True, -) - - -# endregion - -ChatResponseT = TypeVar("ChatResponseT", ChatResponse, ChatResponseUpdate) -AzureOpenAIChatClientT = TypeVar("AzureOpenAIChatClientT", bound="AzureOpenAIChatClient") - - -class AzureOpenAIChatClient( # type: ignore[misc] - AzureOpenAIConfigMixin, - ChatMiddlewareLayer[AzureOpenAIChatOptionsT], - FunctionInvocationLayer[AzureOpenAIChatOptionsT], - ChatTelemetryLayer[AzureOpenAIChatOptionsT], - RawOpenAIChatClient[AzureOpenAIChatOptionsT], - Generic[AzureOpenAIChatOptionsT], -): - """Azure OpenAI Chat completion class with middleware, telemetry, and function invocation support.""" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - additional_properties: dict[str, Any] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - instruction_role: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: - """Initialize an Azure OpenAI Chat completion client. - - Keyword Args: - api_key: The API key. If provided, will override the value in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_API_KEY. - deployment_name: The deployment name. If provided, will override the value - (chat_deployment_name) in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_CHAT_DEPLOYMENT_NAME. - endpoint: The deployment endpoint. If provided will override the value - in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_ENDPOINT. - base_url: The deployment base URL. If provided will override the value - in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_BASE_URL. - api_version: The deployment API version. If provided will override the value - in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_API_VERSION. - token_endpoint: The token endpoint to request an Azure token. - Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT. - credential: Azure credential or token provider for authentication. Accepts a - ``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a - bearer token string (sync or async), for example from - ``azure.identity.get_bearer_token_provider()``. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - additional_properties: Additional properties stored on the client instance. - env_file_path: Use the environment settings file as a fallback to using env vars. - env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'. - instruction_role: The role to use for 'instruction' messages, for example, summarization - prompts could use `developer` or `system`. - middleware: Optional sequence of middleware to apply to requests. - function_invocation_configuration: Optional configuration for function invocation behavior. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureOpenAIChatClient - - # Using environment variables - # Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com - # Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME= - # Set AZURE_OPENAI_API_KEY=your-key - client = AzureOpenAIChatClient() - - # Or passing parameters directly - client = AzureOpenAIChatClient( - endpoint="https://your-endpoint.openai.azure.com", - deployment_name="", - api_key="your-key", - ) - - # Or loading from a .env file - client = AzureOpenAIChatClient(env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework.azure import AzureOpenAIChatOptions - - - class MyOptions(AzureOpenAIChatOptions, total=False): - my_custom_option: str - - - client: AzureOpenAIChatClient[MyOptions] = AzureOpenAIChatClient() - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings) - - chat_deployment_name = azure_openai_settings.get("chat_deployment_name") - if not chat_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." - ) - - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") - api_version_value = cast(str, azure_openai_settings.get("api_version")) - api_key_value = azure_openai_settings.get("api_key") - token_endpoint_value = azure_openai_settings.get("token_endpoint") - - super().__init__( - deployment_name=chat_deployment_name, - endpoint=endpoint_value, - base_url=base_url_value, - api_version=api_version_value, - api_key=api_key_value.get_secret_value() if api_key_value else None, - token_endpoint=token_endpoint_value, - credential=credential, - default_headers=default_headers, - client=async_client, - additional_properties=additional_properties, - instruction_role=instruction_role, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - - @override - def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: - """Parse the choice into a Content object with type='text'. - - Overwritten from RawOpenAIChatClient to deal with Azure On Your Data function. - For docs see: - https://learn.microsoft.com/en-us/azure/ai-foundry/openai/references/on-your-data?tabs=python#context - """ - message = choice.message if isinstance(choice, Choice) else choice.delta - # When you enable asynchronous content filtering in Azure OpenAI, you may receive empty deltas - if message is None: # type: ignore - return None - if hasattr(message, "refusal") and message.refusal: - return Content.from_text(text=message.refusal, raw_representation=choice) - if not message.content: - return None - text_content = Content.from_text(text=message.content, raw_representation=choice) - if not message.model_extra or "context" not in message.model_extra: - return text_content - - context_raw: object = cast(object, message.context) # type: ignore[union-attr] - if isinstance(context_raw, str): - try: - context_raw = json.loads(context_raw) - except json.JSONDecodeError: - logger.warning("Context is not a valid JSON string, ignoring context.") - return text_content - if not isinstance(context_raw, dict): - logger.warning("Context is not a valid dictionary, ignoring context.") - return text_content - context = cast(dict[str, Any], context_raw) - # `all_retrieved_documents` is currently not used, but can be retrieved - # through the raw_representation in the text content. - if intent := context.get("intent"): - text_content.additional_properties = {"intent": intent} - citations = context.get("citations") - if isinstance(citations, list) and citations: - annotations: list[Annotation] = [] - for citation_raw in cast(list[object], citations): - if not isinstance(citation_raw, dict): - continue - citation = cast(dict[str, Any], citation_raw) - annotations.append( - Annotation( - type="citation", - title=citation.get("title", ""), - url=citation.get("url", ""), - snippet=citation.get("content", ""), - file_id=citation.get("filepath", ""), - tool_name="Azure-on-your-Data", - additional_properties={"chunk_id": citation.get("chunk_id", "")}, - raw_representation=citation, - ) - ) - text_content.annotations = annotations - return text_content diff --git a/python/packages/core/agent_framework/azure/_embedding_client.py b/python/packages/core/agent_framework/azure/_embedding_client.py deleted file mode 100644 index 7003a4611f..0000000000 --- a/python/packages/core/agent_framework/azure/_embedding_client.py +++ /dev/null @@ -1,141 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -from collections.abc import Mapping -from typing import Generic - -from openai.lib.azure import AsyncAzureOpenAI - -from agent_framework.observability import EmbeddingTelemetryLayer -from agent_framework.openai import OpenAIEmbeddingOptions -from agent_framework.openai._embedding_client import RawOpenAIEmbeddingClient - -from .._settings import load_settings -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._shared import ( - AzureOpenAIConfigMixin, - AzureOpenAISettings, - _apply_azure_defaults, # pyright: ignore[reportPrivateUsage] -) - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - - -AzureOpenAIEmbeddingOptionsT = TypeVar( - "AzureOpenAIEmbeddingOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIEmbeddingOptions", - covariant=True, -) - - -class AzureOpenAIEmbeddingClient( - AzureOpenAIConfigMixin, - EmbeddingTelemetryLayer[str, list[float], AzureOpenAIEmbeddingOptionsT], - RawOpenAIEmbeddingClient[AzureOpenAIEmbeddingOptionsT], - Generic[AzureOpenAIEmbeddingOptionsT], -): - """Azure OpenAI embedding client with telemetry support. - - Keyword Args: - api_key: The API key. If provided, will override the value in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_API_KEY. - deployment_name: The deployment name. If provided, will override the value - (embedding_deployment_name) in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME. - endpoint: The deployment endpoint. - Can also be set via environment variable AZURE_OPENAI_ENDPOINT. - base_url: The deployment base URL. - Can also be set via environment variable AZURE_OPENAI_BASE_URL. - api_version: The deployment API version. - Can also be set via environment variable AZURE_OPENAI_API_VERSION. - token_endpoint: The token endpoint to request an Azure token. - Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureOpenAIEmbeddingClient - - # Using environment variables - # Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com - # Set AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-3-small - # Set AZURE_OPENAI_API_KEY=your-key - client = AzureOpenAIEmbeddingClient() - - # Or passing parameters directly - client = AzureOpenAIEmbeddingClient( - endpoint="https://your-endpoint.openai.azure.com", - deployment_name="text-embedding-3-small", - api_key="your-key", - ) - - result = await client.get_embeddings(["Hello, world!"]) - """ - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - otel_provider_name: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure OpenAI embedding client.""" - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - embedding_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings) - - embedding_deployment_name = azure_openai_settings.get("embedding_deployment_name") - if not embedding_deployment_name: - raise ValueError( - "Azure OpenAI embedding deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable." - ) - - api_key_secret = azure_openai_settings.get("api_key") - - super().__init__( - deployment_name=embedding_deployment_name, - endpoint=azure_openai_settings.get("endpoint"), - base_url=azure_openai_settings.get("base_url"), - api_version=azure_openai_settings.get("api_version") or "", - api_key=api_key_secret.get_secret_value() if api_key_secret else None, - token_endpoint=azure_openai_settings.get("token_endpoint"), - credential=credential, - default_headers=default_headers, - client=async_client, - otel_provider_name=otel_provider_name, - ) diff --git a/python/packages/core/agent_framework/azure/_responses_client.py b/python/packages/core/agent_framework/azure/_responses_client.py deleted file mode 100644 index 192576bd04..0000000000 --- a/python/packages/core/agent_framework/azure/_responses_client.py +++ /dev/null @@ -1,277 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, Generic -from urllib.parse import urljoin, urlparse - -from azure.ai.projects.aio import AIProjectClient -from openai import AsyncOpenAI - -from .._middleware import ChatMiddlewareLayer -from .._settings import load_settings -from .._telemetry import AGENT_FRAMEWORK_USER_AGENT -from .._tools import FunctionInvocationConfiguration, FunctionInvocationLayer -from ..observability import ChatTelemetryLayer -from ..openai._responses_client import RawOpenAIResponsesClient -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._shared import ( - AzureOpenAIConfigMixin, - AzureOpenAISettings, - _apply_azure_defaults, # pyright: ignore[reportPrivateUsage] -) - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from .._middleware import MiddlewareTypes - from ..openai._responses_client import OpenAIResponsesOptions - - -AzureOpenAIResponsesOptionsT = TypeVar( - "AzureOpenAIResponsesOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIResponsesOptions", - covariant=True, -) - - -class AzureOpenAIResponsesClient( # type: ignore[misc] - AzureOpenAIConfigMixin, - ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], - FunctionInvocationLayer[AzureOpenAIResponsesOptionsT], - ChatTelemetryLayer[AzureOpenAIResponsesOptionsT], - RawOpenAIResponsesClient[AzureOpenAIResponsesOptionsT], - Generic[AzureOpenAIResponsesOptionsT], -): - """Azure Responses completion class with middleware, telemetry, and function invocation support.""" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, - project_client: Any | None = None, - project_endpoint: str | None = None, - allow_preview: bool | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - instruction_role: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, - ) -> None: - """Initialize an Azure OpenAI Responses client. - - The client can be created in two ways: - - 1. **Direct Azure OpenAI** (default): Provide endpoint, api_key, or credential - to connect directly to an Azure OpenAI deployment. - 2. **Foundry project endpoint**: Provide a ``project_client`` or ``project_endpoint`` - (with ``credential``) to create the client via an Azure AI Foundry project. - This requires the ``azure-ai-projects`` package to be installed. - - Keyword Args: - api_key: The API key. If provided, will override the value in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_API_KEY. - deployment_name: The deployment name. If provided, will override the value - (responses_deployment_name) in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME. - endpoint: The deployment endpoint. If provided will override the value - in the env vars or .env file. - Can also be set via environment variable AZURE_OPENAI_ENDPOINT. - base_url: The deployment base URL. If provided will override the value - in the env vars or .env file. Currently, the base_url must end with "/openai/v1/". - Can also be set via environment variable AZURE_OPENAI_BASE_URL. - api_version: The deployment API version. If provided will override the value - in the env vars or .env file. Currently, the api_version must be "preview". - Can also be set via environment variable AZURE_OPENAI_API_VERSION. - token_endpoint: The token endpoint to request an Azure token. - Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT. - credential: Azure credential or token provider for authentication. Accepts a - ``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a - bearer token string (sync or async), for example from - ``azure.identity.get_bearer_token_provider()``. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - project_client: An existing ``AIProjectClient`` (from ``azure.ai.projects.aio``) to use. - The OpenAI client will be obtained via ``project_client.get_openai_client()``. - Requires the ``azure-ai-projects`` package. - project_endpoint: The Azure AI Foundry project endpoint URL. - When provided with ``credential``, an ``AIProjectClient`` will be created - and used to obtain the OpenAI client. Requires the ``azure-ai-projects`` package. - allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``. - env_file_path: Use the environment settings file as a fallback to using env vars. - env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'. - instruction_role: The role to use for 'instruction' messages, for example, summarization - prompts could use `developer` or `system`. - middleware: Optional sequence of middleware to apply to requests. - function_invocation_configuration: Optional configuration for function invocation behavior. - kwargs: Additional keyword arguments. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureOpenAIResponsesClient - - # Using environment variables - # Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com - # Set AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o - # Set AZURE_OPENAI_API_KEY=your-key - client = AzureOpenAIResponsesClient() - - # Or passing parameters directly - client = AzureOpenAIResponsesClient( - endpoint="https://your-endpoint.openai.azure.com", deployment_name="gpt-4o", api_key="your-key" - ) - - # Or loading from a .env file - client = AzureOpenAIResponsesClient(env_file_path="path/to/.env") - - # Using a Foundry project endpoint - from azure.identity import DefaultAzureCredential - - client = AzureOpenAIResponsesClient( - project_endpoint="https://your-project.services.ai.azure.com", - deployment_name="gpt-4o", - credential=DefaultAzureCredential(), - ) - - # Or using an existing AIProjectClient - from azure.ai.projects.aio import AIProjectClient - - project_client = AIProjectClient( - endpoint="https://your-project.services.ai.azure.com", - credential=DefaultAzureCredential(), - ) - client = AzureOpenAIResponsesClient( - project_client=project_client, - deployment_name="gpt-4o", - ) - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework.azure import AzureOpenAIResponsesOptions - - - class MyOptions(AzureOpenAIResponsesOptions, total=False): - my_custom_option: str - - - client: AzureOpenAIResponsesClient[MyOptions] = AzureOpenAIResponsesClient() - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - if (model_id := kwargs.pop("model_id", None)) and not deployment_name: - deployment_name = str(model_id) - - # Project client path: create OpenAI client from an Azure AI Foundry project - if async_client is None and (project_client is not None or project_endpoint is not None): - async_client = self._create_client_from_project( - project_client=project_client, - project_endpoint=project_endpoint, - credential=credential, - allow_preview=allow_preview, - ) - - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - responses_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings, default_api_version="preview") - # TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly - # while this feature is in preview. - # But we should only do this if we're on azure. Private deployments may not need this. - endpoint_value = azure_openai_settings.get("endpoint") - if ( - not azure_openai_settings.get("base_url") - and endpoint_value - and (hostname := urlparse(str(endpoint_value)).hostname) - and hostname.endswith(".openai.azure.com") - ): - azure_openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") - - responses_deployment_name = azure_openai_settings.get("responses_deployment_name") - if not responses_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." - ) - - api_key_secret = azure_openai_settings.get("api_key") - - super().__init__( - deployment_name=responses_deployment_name, - endpoint=azure_openai_settings.get("endpoint"), - base_url=azure_openai_settings.get("base_url"), - api_version=azure_openai_settings.get("api_version") or "", - api_key=api_key_secret.get_secret_value() if api_key_secret else None, - token_endpoint=azure_openai_settings.get("token_endpoint"), - credential=credential, - default_headers=default_headers, - client=async_client, - instruction_role=instruction_role, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - - @staticmethod - def _create_client_from_project( - *, - project_client: AIProjectClient | None, - project_endpoint: str | None, - credential: AzureCredentialTypes | AzureTokenProvider | None, - allow_preview: bool | None = None, - ) -> AsyncOpenAI: - """Create an AsyncOpenAI client from an Azure AI Foundry project.""" - if project_client is not None: - return project_client.get_openai_client() - - if not project_endpoint: - raise ValueError("Azure AI project endpoint is required when project_client is not provided.") - if not credential: - raise ValueError("Azure credential is required when using project_endpoint without a project_client.") - project_client_kwargs: dict[str, Any] = { - "endpoint": project_endpoint, - "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, - } - if allow_preview is not None: - project_client_kwargs["allow_preview"] = allow_preview - project_client = AIProjectClient(**project_client_kwargs) - return project_client.get_openai_client() - - @override - def _check_model_presence(self, options: dict[str, Any]) -> None: - if not options.get("model"): - if not self.model_id: - raise ValueError("deployment_name must be a non-empty string") - options["model"] = self.model_id diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py deleted file mode 100644 index 5e06fbbe74..0000000000 --- a/python/packages/core/agent_framework/azure/_shared.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -import sys -from collections.abc import Mapping -from copy import copy -from typing import Any, ClassVar, Final - -from openai import AsyncOpenAI -from openai.lib.azure import AsyncAzureOpenAI - -from .._settings import SecretString -from .._telemetry import APP_INFO, prepend_agent_framework_to_user_agent -from ..openai._shared import OpenAIBase -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider - -logger: logging.Logger = logging.getLogger(__name__) - -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - - -DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21" -DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105 - - -class AzureOpenAISettings(TypedDict, total=False): - """AzureOpenAI model settings. - - Settings are resolved in this order: explicit keyword arguments, values from an - explicitly provided .env file, then environment variables with the prefix - 'AZURE_OPENAI_'. If settings are missing after resolution, validation will fail. - - Keyword Args: - endpoint: The endpoint of the Azure deployment. This value - can be found in the Keys & Endpoint section when examining - your resource from the Azure portal, the endpoint should end in openai.azure.com. - If both base_url and endpoint are supplied, base_url will be used. - Can be set via environment variable AZURE_OPENAI_ENDPOINT. - chat_deployment_name: The name of the Azure Chat deployment. This value - will correspond to the custom name you chose for your deployment - when you deployed a model. This value can be found under - Resource Management > Deployments in the Azure portal or, alternatively, - under Management > Deployments in Azure AI Foundry. - Can be set via environment variable AZURE_OPENAI_CHAT_DEPLOYMENT_NAME. - responses_deployment_name: The name of the Azure Responses deployment. This value - will correspond to the custom name you chose for your deployment - when you deployed a model. This value can be found under - Resource Management > Deployments in the Azure portal or, alternatively, - under Management > Deployments in Azure AI Foundry. - Can be set via environment variable AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME. - embedding_deployment_name: The name of the Azure Embedding deployment. - Can be set via environment variable AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME. - api_key: The API key for the Azure deployment. This value can be - found in the Keys & Endpoint section when examining your resource in - the Azure portal. You can use either KEY1 or KEY2. - Can be set via environment variable AZURE_OPENAI_API_KEY. - api_version: The API version to use. The default value is `DEFAULT_AZURE_API_VERSION`. - Can be set via environment variable AZURE_OPENAI_API_VERSION. - base_url: The url of the Azure deployment. This value - can be found in the Keys & Endpoint section when examining - your resource from the Azure portal, the base_url consists of the endpoint, - followed by /openai/deployments/{deployment_name}/, - use endpoint if you only want to supply the endpoint. - Can be set via environment variable AZURE_OPENAI_BASE_URL. - token_endpoint: The token endpoint to use to retrieve the authentication token. - The default value is `DEFAULT_AZURE_TOKEN_ENDPOINT`. - Can be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureOpenAISettings - - # Using environment variables - # Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com - # Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4 - # Set AZURE_OPENAI_API_KEY=your-key - settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_") - - # Or passing parameters directly - settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - endpoint="https://your-endpoint.openai.azure.com", - chat_deployment_name="gpt-4", - api_key="your-key", - ) - - # Or loading from a .env file - settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_", env_file_path="path/to/.env") - """ - - chat_deployment_name: str | None - responses_deployment_name: str | None - embedding_deployment_name: str | None - endpoint: str | None - base_url: str | None - api_key: SecretString | None - api_version: str | None - token_endpoint: str | None - - -def _apply_azure_defaults( - settings: AzureOpenAISettings, - default_api_version: str = DEFAULT_AZURE_API_VERSION, - default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT, -) -> None: - """Apply default values for api_version and token_endpoint after loading settings. - - Args: - settings: The loaded Azure OpenAI settings dict. - default_api_version: The default API version to use if not set. - default_token_endpoint: The default token endpoint to use if not set. - """ - if not settings.get("api_version"): - settings["api_version"] = default_api_version - if not settings.get("token_endpoint"): - settings["token_endpoint"] = default_token_endpoint - - -_AZURE_DEFAULTS_APPLIER = _apply_azure_defaults - - -class AzureOpenAIConfigMixin(OpenAIBase): - """Internal class for configuring a connection to an Azure OpenAI service.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - # Note: INJECTABLE = {"client"} is inherited from OpenAIBase - - def __init__( - self, - deployment_name: str, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str = DEFAULT_AZURE_API_VERSION, - api_key: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - client: AsyncOpenAI | None = None, - instruction_role: str | None = None, - **kwargs: Any, - ) -> None: - """Internal class for configuring a connection to an Azure OpenAI service. - - The `validate_call` decorator is used with a configuration that allows arbitrary types. - This is necessary for types like `str` and `OpenAIModelTypes`. - - Args: - deployment_name: Name of the deployment. - endpoint: The specific endpoint URL for the deployment. - base_url: The base URL for Azure services. - api_version: Azure API version. Defaults to the defined DEFAULT_AZURE_API_VERSION. - api_key: API key for Azure services. - token_endpoint: Azure AD token scope used to obtain a bearer token from a credential. - credential: Azure credential or token provider for authentication. Accepts a - ``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a - bearer token string (sync or async). - default_headers: Default headers for HTTP requests. - client: An existing client to use. - instruction_role: The role to use for 'instruction' messages, for example, summarization - prompts could use `developer` or `system`. - kwargs: Additional keyword arguments. - - """ - # Merge APP_INFO into the headers if it exists - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - if not client: - # Resolve credential to a token provider if needed - ad_token_provider = None - if not api_key and credential: - ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint) - - if not api_key and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint and not base_url: - raise ValueError("Please provide an endpoint or a base_url") - - args: dict[str, Any] = { - "default_headers": merged_headers, - } - if api_version: - args["api_version"] = api_version - if ad_token_provider: - args["azure_ad_token_provider"] = ad_token_provider - if api_key: - args["api_key"] = api_key - if base_url: - args["base_url"] = str(base_url) - if endpoint and not base_url: - args["azure_endpoint"] = str(endpoint) - if deployment_name: - args["azure_deployment"] = deployment_name - if "websocket_base_url" in kwargs: - args["websocket_base_url"] = kwargs.pop("websocket_base_url") - - client = AsyncAzureOpenAI(**args) - - # Store configuration as instance attributes for serialization - self.endpoint = str(endpoint) - self.base_url = str(base_url) - self.api_version = api_version - self.deployment_name = deployment_name - self.instruction_role = instruction_role - # Store default_headers but filter out USER_AGENT_KEY for serialization - if default_headers: - from .._telemetry import USER_AGENT_KEY - - def_headers = {k: v for k, v in default_headers.items() if k != USER_AGENT_KEY} - else: - def_headers = None - self.default_headers = def_headers - - super().__init__(model_id=deployment_name, client=client, **kwargs) diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py new file mode 100644 index 0000000000..b8092909b4 --- /dev/null +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry integration namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from cloud Foundry and Foundry Local connector packages. +""" + +import importlib +from typing import Any + +_IMPORTS: dict[str, tuple[str, str]] = { + "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"), + "FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"), + "FoundryLocalSettings": ("agent_framework_foundry_local", "agent-framework-foundry-local"), + "RawFoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), + "RawFoundryAgentChatClient": ("agent_framework_foundry", "agent-framework-foundry"), + "RawFoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `foundry` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi new file mode 100644 index 0000000000..22c0b38b06 --- /dev/null +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft. All rights reserved. + +# Type stubs for the agent_framework.foundry lazy-loading namespace. +# Install the relevant packages for full type support. + +from agent_framework_foundry import ( + FoundryAgent, + FoundryChatClient, + FoundryChatOptions, + FoundryMemoryProvider, + RawFoundryAgent, + RawFoundryAgentChatClient, + RawFoundryChatClient, +) +from agent_framework_foundry_local import ( + FoundryLocalChatOptions, + FoundryLocalClient, + FoundryLocalSettings, +) + +__all__ = [ + "FoundryAgent", + "FoundryChatClient", + "FoundryChatOptions", + "FoundryLocalChatOptions", + "FoundryLocalClient", + "FoundryLocalSettings", + "FoundryMemoryProvider", + "RawFoundryAgent", + "RawFoundryAgentChatClient", + "RawFoundryChatClient", +] diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index bcc4e1365d..f673f5cc61 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 @@ -26,9 +27,6 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedD from dotenv import load_dotenv from opentelemetry import metrics, trace -from opentelemetry.sdk.resources import Resource -from opentelemetry.semconv.attributes import service_attributes -from opentelemetry.semconv_ai import Meters from . import __version__ as version_info from ._settings import load_settings @@ -42,6 +40,7 @@ if TYPE_CHECKING: # pragma: no cover from opentelemetry.sdk._logs.export import LogRecordExporter from opentelemetry.sdk.metrics.export import MetricExporter from opentelemetry.sdk.metrics.view import View + from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import Tracer from opentelemetry.util._decorator import _AgnosticContextManager # type: ignore[reportPrivateUsage] @@ -65,6 +64,7 @@ if TYPE_CHECKING: # pragma: no cover GeneratedEmbeddings, Message, ResponseStream, + UsageDetails, ) ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @@ -93,6 +93,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, @@ -192,6 +204,8 @@ class OtelAttr(str, Enum): TOOL_RESULT = "gen_ai.tool.call.result" # Agent attributes AGENT_ID = "gen_ai.agent.id" + SERVICE_NAME = "service.name" + SERVICE_VERSION = "service.version" # Client attributes # replaced TOKEN with T, because both ruff and bandit, # complain about TOKEN being a potential secret @@ -200,6 +214,8 @@ class OtelAttr(str, Enum): T_TYPE_INPUT = "input" T_TYPE_OUTPUT = "output" DURATION_UNIT = "s" + LLM_OPERATION_DURATION = "gen_ai.client.operation.duration" + LLM_TOKEN_USAGE = "gen_ai.client.token.usage" # nosec B105 # noqa: S105 - OpenTelemetry metric name, not a secret. # Agent attributes AGENT_NAME = "gen_ai.agent.name" @@ -210,8 +226,6 @@ class OtelAttr(str, Enum): INPUT_MESSAGES = "gen_ai.input.messages" OUTPUT_MESSAGES = "gen_ai.output.messages" SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions" - # Attributes previously from opentelemetry-semantic-conventions-ai SpanAttributes, - # removed in v0.4.14. Defined here for forward compatibility. SYSTEM = "gen_ai.system" REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens" REQUEST_TEMPERATURE = "gen_ai.request.temperature" @@ -268,7 +282,7 @@ class OtelAttr(str, Enum): CHAT_COMPLETION_OPERATION = "chat" EMBEDDING_OPERATION = "embeddings" TOOL_EXECUTION_OPERATION = "execute_tool" - # Describes GenAI agent creation and is usually applicable when working with remote agent services. + # Describes GenAI agent creation and is usually applicable when working with remote agent services. AGENT_CREATE_OPERATION = "create_agent" AGENT_INVOKE_OPERATION = "invoke_agent" @@ -362,11 +376,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 +393,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, ) @@ -558,25 +576,27 @@ def create_resource( # Load from custom .env file resource = create_resource(env_file_path="config/.env") """ - # Load environment variables from a .env file only when explicitly provided + try: + from opentelemetry.sdk.resources import Resource + except ModuleNotFoundError as ex: + raise ModuleNotFoundError( + "`opentelemetry-sdk` is required to use `create_resource()`. " + "Please install `opentelemetry-sdk` and update your dependencies." + ) from ex + if env_file_path is not None: load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding) - # Start with provided attributes resource_attributes: dict[str, Any] = dict(attributes) - # Set service name if service_name is None: service_name = os.getenv("OTEL_SERVICE_NAME", "agent_framework") - resource_attributes[service_attributes.SERVICE_NAME] = service_name + resource_attributes[OtelAttr.SERVICE_NAME] = service_name - # Set service version if service_version is None: service_version = os.getenv("OTEL_SERVICE_VERSION", version_info) - resource_attributes[service_attributes.SERVICE_VERSION] = service_version + resource_attributes[OtelAttr.SERVICE_VERSION] = service_version - # Parse OTEL_RESOURCE_ATTRIBUTES environment variable - # Format: key1=value1,key2=value2 if resource_attrs_env := os.getenv("OTEL_RESOURCE_ATTRIBUTES"): resource_attributes.update(_parse_headers(resource_attrs_env)) return Resource.create(resource_attributes) @@ -584,10 +604,15 @@ def create_resource( def create_metric_views() -> list[View]: """Create the default OpenTelemetry metric views for Agent Framework.""" - from opentelemetry.sdk.metrics.view import DropAggregation, View + try: + from opentelemetry.sdk.metrics.view import DropAggregation, View + except ModuleNotFoundError as ex: + raise ModuleNotFoundError( + "`opentelemetry-sdk` is required to use `create_metric_views()`. " + "Please install `opentelemetry-sdk` and update your dependencies." + ) from ex return [ - # Dropping all enable_instrumentation names except for those starting with "agent_framework" View(instrument_name="agent_framework*"), View(instrument_name="gen_ai*"), View(instrument_name="*", aggregation=DropAggregation()), @@ -641,7 +666,7 @@ class ObservabilitySettings: """ def __init__(self, **kwargs: Any) -> None: - """Initialize the settings and create the resource.""" + """Initialize the settings.""" env_file_path = kwargs.pop("env_file_path", None) env_file_encoding = kwargs.pop("env_file_encoding", None) data = load_settings( @@ -656,10 +681,6 @@ class ObservabilitySettings: self.vs_code_extension_port: int | None = data.get("vs_code_extension_port") self.env_file_path = env_file_path self.env_file_encoding = env_file_encoding - self._resource = create_resource( - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) self._executed_setup = False @property @@ -744,17 +765,27 @@ class ObservabilitySettings: exporters: A list of exporters for logs, metrics and/or spans. views: Optional list of OpenTelemetry views for metrics. Default is empty list. """ - from opentelemetry._logs import set_logger_provider - from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler - from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogRecordExporter - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter + try: + from opentelemetry._logs import set_logger_provider + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogRecordExporter + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter + except ModuleNotFoundError as ex: + raise ModuleNotFoundError( + "`opentelemetry-sdk` is required to use `configure_otel_providers()`. " + "Please install `opentelemetry-sdk` and update your dependencies." + ) from ex span_exporters: list[SpanExporter] = [] log_exporters: list[LogRecordExporter] = [] metric_exporters: list[MetricExporter] = [] + resource = create_resource( + env_file_path=self.env_file_path, + env_file_encoding=self.env_file_encoding, + ) for exp in exporters: if isinstance(exp, SpanExporter): span_exporters.append(exp) @@ -765,14 +796,14 @@ class ObservabilitySettings: # Tracing if span_exporters: - tracer_provider = TracerProvider(resource=self._resource) + tracer_provider = TracerProvider(resource=resource) trace.set_tracer_provider(tracer_provider) for exporter in span_exporters: tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) # Logging if log_exporters: - logger_provider = LoggerProvider(resource=self._resource) + logger_provider = LoggerProvider(resource=resource) for log_exporter in log_exporters: logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter)) # Attach a handler with the provider to the root logger @@ -787,7 +818,7 @@ class ObservabilitySettings: PeriodicExportingMetricReader(exporter, export_interval_millis=5000) for exporter in metric_exporters ], - resource=self._resource, + resource=resource, views=views or [], ) metrics.set_meter_provider(meter_provider) @@ -899,6 +930,25 @@ def get_meter( OBSERVABILITY_SETTINGS: ObservabilitySettings = ObservabilitySettings() +def _read_bool_env(name: str, *, default: bool = False) -> bool: + """Read a boolean from an environment variable.""" + value = os.getenv(name) + if value is None: + return default + return value.lower() in ("true", "1", "yes", "on") + + +def _read_int_env(name: str, *, default: int | None = None) -> int | None: + """Read an optional integer from an environment variable.""" + value = os.getenv(name) + if value is None: + return default + try: + return int(value) + except ValueError: + return default + + def enable_instrumentation( *, enable_sensitive_data: bool | None = None, @@ -920,11 +970,15 @@ def enable_instrumentation( OBSERVABILITY_SETTINGS.enable_instrumentation = True if enable_sensitive_data is not None: OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data + else: + # Re-read from current environment in case env vars were set after import (e.g. load_dotenv()) + OBSERVABILITY_SETTINGS.enable_sensitive_data = _read_bool_env("ENABLE_SENSITIVE_DATA") def configure_otel_providers( *, enable_sensitive_data: bool | None = None, + enable_console_exporters: bool | None = None, exporters: list[LogRecordExporter | SpanExporter | MetricExporter] | None = None, views: list[View] | None = None, vs_code_extension_port: int | None = None, @@ -963,6 +1017,8 @@ def configure_otel_providers( Keyword Args: enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides the environment variable ENABLE_SENSITIVE_DATA if set. Default is None. + enable_console_exporters: Enable console exporters for traces, logs, and metrics. + Overrides the environment variable ENABLE_CONSOLE_EXPORTERS if set. Default is None. exporters: A list of custom exporters for logs, metrics or spans, or any combination. These will be added in addition to exporters configured via environment variables. Default is None. @@ -1051,6 +1107,8 @@ def configure_otel_providers( settings_kwargs["env_file_encoding"] = env_file_encoding if enable_sensitive_data is not None: settings_kwargs["enable_sensitive_data"] = enable_sensitive_data + if enable_console_exporters is not None: + settings_kwargs["enable_console_exporters"] = enable_console_exporters if vs_code_extension_port is not None: settings_kwargs["vs_code_extension_port"] = vs_code_extension_port @@ -1061,15 +1119,23 @@ def configure_otel_providers( OBSERVABILITY_SETTINGS.vs_code_extension_port = updated_settings.vs_code_extension_port OBSERVABILITY_SETTINGS.env_file_path = updated_settings.env_file_path OBSERVABILITY_SETTINGS.env_file_encoding = updated_settings.env_file_encoding - OBSERVABILITY_SETTINGS._resource = updated_settings._resource # type: ignore[reportPrivateUsage] OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage] else: - # Update the observability settings with the provided values + # Re-read settings from current environment in case env vars were set + # after import (e.g. via load_dotenv()). Explicit parameters take precedence. OBSERVABILITY_SETTINGS.enable_instrumentation = True - if enable_sensitive_data is not None: - OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data - if vs_code_extension_port is not None: - OBSERVABILITY_SETTINGS.vs_code_extension_port = vs_code_extension_port + OBSERVABILITY_SETTINGS.enable_sensitive_data = ( + enable_sensitive_data if enable_sensitive_data is not None else _read_bool_env("ENABLE_SENSITIVE_DATA") + ) + OBSERVABILITY_SETTINGS.enable_console_exporters = ( + enable_console_exporters + if enable_console_exporters is not None + else _read_bool_env("ENABLE_CONSOLE_EXPORTERS") + ) + OBSERVABILITY_SETTINGS.vs_code_extension_port = ( + vs_code_extension_port if vs_code_extension_port is not None else _read_int_env("VS_CODE_EXTENSION_PORT") + ) + OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage] OBSERVABILITY_SETTINGS._configure( # type: ignore[reportPrivateUsage] additional_exporters=exporters, @@ -1082,7 +1148,7 @@ def configure_otel_providers( def _get_duration_histogram() -> metrics.Histogram: return get_meter().create_histogram( - name=Meters.LLM_OPERATION_DURATION, + name=OtelAttr.LLM_OPERATION_DURATION, unit=OtelAttr.DURATION_UNIT, description="Captures the duration of operations of function-invoking chat clients", explicit_bucket_boundaries_advisory=OPERATION_DURATION_BUCKET_BOUNDARIES, @@ -1091,7 +1157,7 @@ def _get_duration_histogram() -> metrics.Histogram: def _get_token_usage_histogram() -> metrics.Histogram: return get_meter().create_histogram( - name=Meters.LLM_TOKEN_USAGE, + name=OtelAttr.LLM_TOKEN_USAGE, unit=OtelAttr.T_UNIT, description="Captures the token usage of chat clients", explicit_bucket_boundaries_advisory=TOKEN_USAGE_BUCKET_BOUNDARIES, @@ -1273,6 +1339,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) @@ -1332,6 +1399,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", @@ -1486,8 +1554,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, @@ -1516,23 +1582,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 @@ -1572,8 +1649,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 @@ -1589,6 +1669,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 @@ -1600,41 +1682,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() @@ -1890,14 +1983,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/agent_framework/openai/__init__.py b/python/packages/core/agent_framework/openai/__init__.py index a3fe1fe8f6..90bc732bae 100644 --- a/python/packages/core/agent_framework/openai/__init__.py +++ b/python/packages/core/agent_framework/openai/__init__.py @@ -1,48 +1,55 @@ # Copyright (c) Microsoft. All rights reserved. -"""OpenAI namespace for built-in Agent Framework clients. +"""OpenAI namespace for Agent Framework clients. -This module re-exports objects from the core OpenAI implementation modules in -``agent_framework.openai``. +This module lazily re-exports objects from the ``agent-framework-openai`` package. +Install it with: ``pip install agent-framework-openai`` Supported classes include: -- OpenAIChatClient -- OpenAIResponsesClient -- OpenAIAssistantsClient -- OpenAIAssistantProvider +- OpenAIChatClient (Responses API) +- OpenAIChatCompletionClient (Chat Completions API) +- OpenAIEmbeddingClient +- OpenAIAssistantsClient (deprecated) """ -from ._assistant_provider import OpenAIAssistantProvider -from ._assistants_client import ( - AssistantToolResources, - OpenAIAssistantsClient, - OpenAIAssistantsOptions, -) -from ._chat_client import OpenAIChatClient, OpenAIChatOptions -from ._embedding_client import OpenAIEmbeddingClient, OpenAIEmbeddingOptions -from ._exceptions import ContentFilterResultSeverity, OpenAIContentFilterException -from ._responses_client import ( - OpenAIContinuationToken, - OpenAIResponsesClient, - OpenAIResponsesOptions, - RawOpenAIResponsesClient, -) -from ._shared import OpenAISettings +import importlib +from typing import Any -__all__ = [ - "AssistantToolResources", - "ContentFilterResultSeverity", - "OpenAIAssistantProvider", - "OpenAIAssistantsClient", - "OpenAIAssistantsOptions", - "OpenAIChatClient", - "OpenAIChatOptions", - "OpenAIContentFilterException", - "OpenAIContinuationToken", - "OpenAIEmbeddingClient", - "OpenAIEmbeddingOptions", - "OpenAIResponsesClient", - "OpenAIResponsesOptions", - "OpenAISettings", - "RawOpenAIResponsesClient", -] +_IMPORTS: dict[str, tuple[str, str]] = { + "OpenAIChatClient": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIChatOptions": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIContinuationToken": ("agent_framework_openai", "agent-framework-openai"), + "RawOpenAIChatClient": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIChatCompletionClient": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIChatCompletionOptions": ("agent_framework_openai", "agent-framework-openai"), + "RawOpenAIChatCompletionClient": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIEmbeddingClient": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIEmbeddingOptions": ("agent_framework_openai", "agent-framework-openai"), + "OpenAISettings": ("agent_framework_openai", "agent-framework-openai"), + "ContentFilterResultSeverity": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIContentFilterException": ("agent_framework_openai", "agent-framework-openai"), + "AssistantToolResources": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIAssistantProvider": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIAssistantsClient": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIAssistantsOptions": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIResponsesClient": ("agent_framework_openai", "agent-framework-openai"), + "OpenAIResponsesOptions": ("agent_framework_openai", "agent-framework-openai"), + "RawOpenAIResponsesClient": ("agent_framework_openai", "agent-framework-openai"), +} + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + import_path, package_name = _IMPORTS[name] + try: + return getattr(importlib.import_module(import_path), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The package {package_name} is required to use `{name}`. " + f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file." + ) from exc + raise AttributeError(f"Module `openai` has no attribute {name}.") + + +def __dir__() -> list[str]: + return list(_IMPORTS.keys()) diff --git a/python/packages/core/agent_framework/openai/__init__.pyi b/python/packages/core/agent_framework/openai/__init__.pyi new file mode 100644 index 0000000000..3f7ad148fb --- /dev/null +++ b/python/packages/core/agent_framework/openai/__init__.pyi @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft. All rights reserved. + +# Type stubs for the agent_framework.openai lazy-loading namespace. +# Install agent-framework-openai for full type support. + +from agent_framework_openai import ( + AssistantToolResources, + ContentFilterResultSeverity, + OpenAIAssistantProvider, + OpenAIAssistantsClient, + OpenAIAssistantsOptions, + OpenAIChatClient, + OpenAIChatCompletionClient, + OpenAIChatCompletionOptions, + OpenAIChatOptions, + OpenAIContentFilterException, + OpenAIContinuationToken, + OpenAIEmbeddingClient, + OpenAIEmbeddingOptions, + OpenAIResponsesClient, + OpenAIResponsesOptions, + OpenAISettings, + RawOpenAIChatClient, + RawOpenAIChatCompletionClient, + RawOpenAIResponsesClient, +) + +__all__ = [ + "AssistantToolResources", + "ContentFilterResultSeverity", + "OpenAIAssistantProvider", + "OpenAIAssistantsClient", + "OpenAIAssistantsOptions", + "OpenAIChatClient", + "OpenAIChatCompletionClient", + "OpenAIChatCompletionOptions", + "OpenAIChatOptions", + "OpenAIContentFilterException", + "OpenAIContinuationToken", + "OpenAIEmbeddingClient", + "OpenAIEmbeddingOptions", + "OpenAIResponsesClient", + "OpenAIResponsesOptions", + "OpenAISettings", + "RawOpenAIChatClient", + "RawOpenAIChatCompletionClient", + "RawOpenAIResponsesClient", +] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index e1c79ddb53..caeebc5319 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" @@ -23,28 +23,20 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - # utilities "typing-extensions>=4.15.0,<5", "pydantic>=2,<3", "python-dotenv>=1,<2", - # telemetry "opentelemetry-api>=1.39.0,<2", - "opentelemetry-sdk>=1.39.0,<2", - "opentelemetry-semantic-conventions-ai>=0.4.13,<0.4.14", - # connectors and functions - "openai>=1.99.0,<3", - "azure-identity>=1,<2", - "azure-ai-projects>=2.0.0,<3.0", - "mcp[ws]>=1.24.0,<2", - "packaging>=24.1,<25", ] [project.optional-dependencies] all = [ + "mcp>=1.24.0,<2", "agent-framework-a2a", "agent-framework-ag-ui", "agent-framework-azure-ai-search", "agent-framework-anthropic", + "agent-framework-openai", "agent-framework-claude", "agent-framework-azure-ai", "agent-framework-azurefunctions", @@ -54,6 +46,7 @@ all = [ "agent-framework-declarative", "agent-framework-devui", "agent-framework-durabletask", + "agent-framework-foundry", "agent-framework-foundry-local", "agent-framework-github-copilot; python_version >= '3.11'", "agent-framework-lab", @@ -121,9 +114,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework" -test = 'pytest -m "not integration" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' [tool.flit.module] name = "agent_framework" diff --git a/python/packages/core/tests/conftest.py b/python/packages/core/tests/conftest.py index 08f3f07762..b4900808f4 100644 --- a/python/packages/core/tests/conftest.py +++ b/python/packages/core/tests/conftest.py @@ -67,7 +67,7 @@ def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_da if enable_instrumentation or enable_sensitive_data: from opentelemetry.sdk.trace import TracerProvider - tracer_provider = TracerProvider(resource=observability_settings._resource) + tracer_provider = TracerProvider(resource=observability.create_resource()) trace.set_tracer_provider(tracer_provider) monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore 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_agents.py b/python/packages/core/tests/core/test_agents.py index cab55196f8..c751265fbe 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -1950,13 +1950,13 @@ async def test_shared_local_storage_cross_provider_responses_history_does_not_le from openai.types.chat.chat_completion_message import ChatCompletionMessage from agent_framework._sessions import InMemoryHistoryProvider - from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient @tool(approval_mode="never_require") def search_hotels(city: str) -> str: return f"Found 3 hotels in {city}" - responses_client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + responses_client = OpenAIChatClient(model="test-model", api_key="test-key") responses_agent = Agent( client=responses_client, tools=[search_hotels], @@ -2024,7 +2024,7 @@ async def test_shared_local_storage_cross_provider_responses_history_does_not_le responses_replay_call = next(item for item in responses_replay_input if item.get("type") == "function_call") assert responses_replay_call["id"] == "fc_provider123" - chat_client = OpenAIChatClient(model_id="test-model", api_key="test-key") + chat_client = OpenAIChatCompletionClient(model="test-model", api_key="test-key") chat_agent = Agent(client=chat_client) chat_response = ChatCompletion( diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index 7e150c47c6..73526298df 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -66,25 +66,24 @@ def test_base_client_as_agent_uses_explicit_additional_properties(chat_client_ba assert agent.additional_properties == {"team": "core"} -def test_openai_chat_client_get_response_docstring_surfaces_layered_runtime_docs() -> None: - from agent_framework.openai import OpenAIChatClient +def test_openai_chat_completion_client_get_response_docstring_surfaces_layered_runtime_docs() -> None: + from agent_framework.openai import OpenAIChatCompletionClient - docstring = inspect.getdoc(OpenAIChatClient.get_response) + docstring = inspect.getdoc(OpenAIChatCompletionClient.get_response) 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: - from agent_framework.openai import OpenAIChatClient +def test_openai_chat_completion_client_get_response_is_defined_on_openai_class() -> None: + from agent_framework.openai import OpenAIChatCompletionClient - signature = inspect.signature(OpenAIChatClient.get_response) + signature = inspect.signature(OpenAIChatCompletionClient.get_response) - assert OpenAIChatClient.get_response.__qualname__ == "OpenAIChatClient.get_response" - assert "function_middleware" in signature.parameters + assert OpenAIChatCompletionClient.get_response.__qualname__ == "OpenAIChatCompletionClient.get_response" assert "middleware" in signature.parameters @@ -350,15 +349,15 @@ def test_openai_responses_client_supports_all_tool_protocols(): assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) -def test_openai_chat_client_supports_web_search_only(): +def test_openai_chat_completion_client_supports_web_search_only(): """Test that OpenAIChatClient only supports web search tool.""" - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient - assert not isinstance(OpenAIChatClient, SupportsCodeInterpreterTool) - assert isinstance(OpenAIChatClient, SupportsWebSearchTool) - assert not isinstance(OpenAIChatClient, SupportsImageGenerationTool) - assert not isinstance(OpenAIChatClient, SupportsMCPTool) - assert not isinstance(OpenAIChatClient, SupportsFileSearchTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIChatCompletionClient, SupportsWebSearchTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsImageGenerationTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsMCPTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsFileSearchTool) def test_openai_assistants_client_supports_code_interpreter_and_file_search(): diff --git a/python/packages/core/tests/core/test_foundry_namespace.py b/python/packages/core/tests/core/test_foundry_namespace.py new file mode 100644 index 0000000000..91953b8233 --- /dev/null +++ b/python/packages/core/tests/core/test_foundry_namespace.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest +from agent_framework_foundry import FoundryChatClient, FoundryMemoryProvider +from agent_framework_foundry_local import FoundryLocalClient + +import agent_framework.azure as azure +import agent_framework.foundry as foundry + + +def test_foundry_namespace_exposes_cloud_and_local_symbols() -> None: + assert foundry.FoundryChatClient is FoundryChatClient + assert foundry.FoundryMemoryProvider is FoundryMemoryProvider + assert foundry.FoundryLocalClient is FoundryLocalClient + assert "FoundryChatClient" in dir(foundry) + assert "FoundryLocalClient" in dir(foundry) + + +def test_azure_namespace_no_longer_exposes_foundry_symbols() -> None: + assert "FoundryChatClient" not in dir(azure) + assert "FoundryLocalClient" not in dir(azure) + + with pytest.raises(AttributeError, match="Module `azure` has no attribute FoundryChatClient\\."): + _ = azure.FoundryChatClient 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_mcp.py b/python/packages/core/tests/core/test_mcp.py index b29ec1a794..eb233eea99 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. # type: ignore[reportPrivateUsage] +import json import logging import os from contextlib import _AsyncGeneratorContextManager # type: ignore @@ -23,13 +24,9 @@ from agent_framework import ( ) from agent_framework._mcp import ( MCPTool, + _build_prefixed_mcp_name, _get_input_model_from_mcp_prompt, _normalize_mcp_name, - _parse_content_from_mcp, - _parse_message_from_mcp, - _parse_tool_result_from_mcp, - _prepare_content_for_mcp, - _prepare_message_for_mcp, logger, ) from agent_framework._middleware import FunctionMiddlewarePipeline @@ -42,6 +39,17 @@ skip_if_mcp_integration_tests_disabled = pytest.mark.skipif( ) +def _mcp_result_to_text(result: str | list[Content]) -> str: + """Normalize an MCP tool result to text for assertions.""" + if isinstance(result, str): + return result + text = "\n".join(content.text for content in result if content.type == "text" and content.text) + return text or str(result) + + +_HELPER_MCP_TOOL = MCPTool(name="helper") + + # Helper function tests def test_normalize_mcp_name(): """Test MCP name normalization.""" @@ -53,6 +61,10 @@ def test_normalize_mcp_name(): assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes" +def test_build_prefixed_mcp_name_ignores_empty_normalized_prefix() -> None: + assert _build_prefixed_mcp_name("search", "---") == "search" + + def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None: assert MCPStdioTool(name="stdio", command="python", tool_name_prefix="stdio").tool_name_prefix == "stdio" assert ( @@ -131,7 +143,7 @@ async def test_load_prompts_with_tool_name_prefix() -> None: def test_mcp_prompt_message_to_ai_content(): """Test conversion from MCP prompt message to AI content.""" mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!")) - ai_content = _parse_message_from_mcp(mcp_message) + ai_content = _HELPER_MCP_TOOL._parse_message_from_mcp(mcp_message) assert isinstance(ai_content, Message) assert ai_content.role == "user" @@ -141,6 +153,55 @@ def test_mcp_prompt_message_to_ai_content(): assert ai_content.raw_representation == mcp_message +def test_mcp_tool_str_and_parse_prompt_result_rich_content() -> None: + tool = MCPTool(name="helper", description="Helper MCP tool") + prompt_result = types.GetPromptResult( + messages=[ + types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello")), + types.PromptMessage( + role="assistant", + content=types.ImageContent(type="image", data="eHl6", mimeType="image/png"), + ), + types.PromptMessage( + role="assistant", + content=types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"), + ), + types.PromptMessage( + role="assistant", + content=types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri=AnyUrl("file://prompt.txt"), + mimeType="text/plain", + text="Embedded prompt", + ), + ), + ), + types.PromptMessage( + role="assistant", + content=types.EmbeddedResource( + type="resource", + resource=types.BlobResourceContents( + uri=AnyUrl("file://prompt.bin"), + mimeType="application/pdf", + blob="ZGF0YQ==", + ), + ), + ), + ] + ) + + result = tool._parse_prompt_result_from_mcp(prompt_result) + parsed = json.loads(result) + + assert str(tool) == "MCPTool(name=helper, description=Helper MCP tool)" + assert parsed[0] == "Hello" + assert json.loads(parsed[1]) == {"type": "image", "data": "eHl6", "mimeType": "image/png"} + assert json.loads(parsed[2]) == {"type": "audio", "data": "YXVkaW8=", "mimeType": "audio/wav"} + assert parsed[3] == "Embedded prompt" + assert json.loads(parsed[4]) == {"type": "blob", "data": "ZGF0YQ==", "mimeType": "application/pdf"} + + def test_parse_tool_result_from_mcp(): """Test conversion from MCP tool result with images preserves original order.""" mcp_result = types.CallToolResult( @@ -151,7 +212,7 @@ def test_parse_tool_result_from_mcp(): types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), ] ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) # Results with images return a list of Content objects in original order assert isinstance(result, list) @@ -172,7 +233,7 @@ def test_parse_tool_result_from_mcp(): def test_parse_tool_result_from_mcp_single_text(): """Test conversion from MCP tool result with a single text item.""" mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")]) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) # Single text item returns list with one text Content assert isinstance(result, list) @@ -188,7 +249,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string(): _meta={"isError": True, "errorCode": "TOOL_ERROR"}, ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 assert result[0].text == "Error occurred" @@ -197,7 +258,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string(): def test_parse_tool_result_from_mcp_empty_content(): """Test that empty MCP content normalizes to JSON null text content.""" mcp_result = types.CallToolResult(content=[]) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 assert result[0].type == "text" @@ -214,7 +275,7 @@ def test_parse_tool_result_from_mcp_audio_content(): types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"), ] ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 @@ -237,7 +298,7 @@ def test_parse_tool_result_from_mcp_blob_plain_base64(): ), ] ) - result = _parse_tool_result_from_mcp(mcp_result) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) assert len(result) == 1 @@ -246,10 +307,39 @@ def test_parse_tool_result_from_mcp_blob_plain_base64(): assert "dGVzdCBkYXRh" in result[0].uri +def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown(): + """Test additional MCP tool result variants.""" + mcp_result = types.CallToolResult( + content=[ + types.ResourceLink( + type="resource_link", + uri=AnyUrl("https://example.com/resource"), + name="resource", + mimeType="application/json", + ), + types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri=AnyUrl("file://prompt.txt"), + mimeType="text/plain", + text="Embedded result", + ), + ), + ] + ) + + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert result[0].type == "uri" + assert result[0].uri == "https://example.com/resource" + assert result[1].type == "text" + assert result[1].text == "Embedded result" + + def test_mcp_content_types_to_ai_content_text(): """Test conversion of MCP text content to AI content.""" mcp_content = types.TextContent(type="text", text="Sample text") - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "text" assert ai_content.text == "Sample text" @@ -260,7 +350,7 @@ def test_mcp_content_types_to_ai_content_image(): """Test conversion of MCP image content to AI content.""" # MCP can send data as base64 string or as bytes mcp_content = types.ImageContent(type="image", data="YWJj", mimeType="image/jpeg") # base64 for b"abc" - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "data" assert ai_content.uri == "data:image/jpeg;base64,YWJj" @@ -272,7 +362,7 @@ def test_mcp_content_types_to_ai_content_audio(): """Test conversion of MCP audio content to AI content.""" # Use properly padded base64 mcp_content = types.AudioContent(type="audio", data="ZGVm", mimeType="audio/wav") # base64 for b"def" - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "data" assert ai_content.uri == "data:audio/wav;base64,ZGVm" @@ -288,7 +378,7 @@ def test_mcp_content_types_to_ai_content_resource_link(): name="test_resource", mimeType="application/json", ) - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "uri" assert ai_content.uri == "https://example.com/resource" @@ -304,7 +394,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text(): text="Embedded text content", ) mcp_content = types.EmbeddedResource(type="resource", resource=text_resource) - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "text" assert ai_content.text == "Embedded text content" @@ -320,7 +410,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob(): blob="data:application/octet-stream;base64,dGVzdCBkYXRh", ) mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource) - ai_content = _parse_content_from_mcp(mcp_content)[0] + ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0] assert ai_content.type == "data" assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh" @@ -328,10 +418,33 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob(): assert ai_content.raw_representation == mcp_content +def test_mcp_content_types_to_ai_content_tool_use_and_tool_result(): + """Test conversion of MCP tool use/result content to AI function call/result content.""" + tool_use_content = types.ToolUseContent(type="tool_use", id="call-1", name="calculator", input={"x": 1}) + tool_result_content = types.ToolResultContent( + type="tool_result", + toolUseId="call-1", + content=[types.TextContent(type="text", text="done")], + isError=True, + ) + + function_call = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_use_content)[0] + function_result = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_result_content)[0] + + assert function_call.type == "function_call" + assert function_call.call_id == "call-1" + assert function_call.name == "calculator" + assert function_call.arguments == {"x": 1} + assert function_result.type == "function_result" + assert function_result.call_id == "call-1" + assert function_result.result == "done" + assert function_result.exception == "" + + def test_ai_content_to_mcp_content_types_text(): """Test conversion of AI text content to MCP content.""" ai_content = Content.from_text(text="Sample text") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.TextContent) assert mcp_content.type == "text" @@ -341,7 +454,7 @@ def test_ai_content_to_mcp_content_types_text(): def test_ai_content_to_mcp_content_types_data_image(): """Test conversion of AI data content to MCP content.""" ai_content = Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.ImageContent) assert mcp_content.type == "image" @@ -352,7 +465,7 @@ def test_ai_content_to_mcp_content_types_data_image(): def test_ai_content_to_mcp_content_types_data_audio(): """Test conversion of AI data content to MCP content.""" ai_content = Content.from_uri(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.AudioContent) assert mcp_content.type == "audio" @@ -366,7 +479,7 @@ def test_ai_content_to_mcp_content_types_data_binary(): uri="data:application/octet-stream;base64,xyz", media_type="application/octet-stream", ) - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.EmbeddedResource) assert mcp_content.type == "resource" @@ -377,7 +490,7 @@ def test_ai_content_to_mcp_content_types_data_binary(): def test_ai_content_to_mcp_content_types_uri(): """Test conversion of AI URI content to MCP content.""" ai_content = Content.from_uri(uri="https://example.com/resource", media_type="application/json") - mcp_content = _prepare_content_for_mcp(ai_content) + mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content) assert isinstance(mcp_content, types.ResourceLink) assert mcp_content.type == "resource_link" @@ -393,12 +506,24 @@ def test_prepare_message_for_mcp(): Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png"), ], ) - mcp_contents = _prepare_message_for_mcp(message) + mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp(message) assert len(mcp_contents) == 2 assert isinstance(mcp_contents[0], types.TextContent) assert isinstance(mcp_contents[1], types.ImageContent) +def test_prepare_message_for_mcp_skips_unsupported_content() -> None: + unsupported = Content(type="annotations", text="ignored") + + assert _HELPER_MCP_TOOL._prepare_content_for_mcp(unsupported) is None + + mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp( + Message(role="user", contents=[Content.from_text("kept"), unsupported]) + ) + assert len(mcp_contents) == 1 + assert isinstance(mcp_contents[0], types.TextContent) + + @pytest.mark.parametrize( "test_id,input_schema", [ @@ -1279,6 +1404,18 @@ async def test_mcp_tool_approval_mode(approval_mode, expected_approvals): assert func.approval_mode == expected_approvals[func.name] +def test_mcp_tool_approval_mode_returns_none_for_unmatched_names() -> None: + tool = MCPTool( + name="test_tool", + approval_mode={ + "always_require_approval": ["tool_one"], + "never_require_approval": ["tool_two"], + }, + ) + + assert tool._determine_approval_mode("tool_three") is None + + @pytest.mark.parametrize( "allowed_tools,expected_count,expected_names", [ @@ -1401,8 +1538,7 @@ async def test_streamable_http_integration(): assert hasattr(func, "name") assert hasattr(func, "description") - result = await func.invoke(query="What is Agent Framework?") - assert isinstance(result, str) + result = _mcp_result_to_text(await func.invoke(query="What is Agent Framework?")) assert len(result) > 0 @@ -1430,7 +1566,7 @@ async def test_mcp_connection_reset_integration(): # Get the first function and invoke it func = tool.functions[0] - first_result = await func.invoke(query="What is Agent Framework?") + first_result = _mcp_result_to_text(await func.invoke(query="What is Agent Framework?")) assert first_result is not None assert len(first_result) > 0 @@ -1456,7 +1592,7 @@ async def test_mcp_connection_reset_integration(): tool.session.call_tool = call_tool_with_error # Invoke the function again - this should trigger automatic reconnection on ClosedResourceError - second_result = await func.invoke(query="What is Agent Framework?") + second_result = _mcp_result_to_text(await func.invoke(query="What is Agent Framework?")) assert second_result is not None assert len(second_result) > 0 @@ -1469,10 +1605,8 @@ async def test_mcp_connection_reset_integration(): # Verify tools are still available after reconnection assert len(tool.functions) > 0 - # Both results should be valid strings (we don't compare content as it may vary) - assert isinstance(first_result, str) + # Both results should include text (we don't compare content as it may vary) assert len(first_result) > 0 - assert isinstance(second_result, str) assert len(second_result) > 0 @@ -1562,12 +1696,15 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None result = await tool.sampling_callback(Mock(), params) assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR - assert "Failed to get chat message content: Chat client error" in result.message + assert "Failed to get chat message content" in result.message async def test_mcp_tool_sampling_callback_no_valid_content(): @@ -1605,6 +1742,9 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None result = await tool.sampling_callback(Mock(), params) @@ -1613,6 +1753,404 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): assert "Failed to get right content types from the response." in result.message +async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation(): + """Test sampling callback when the chat client returns no response and then valid content.""" + tool = MCPStdioTool(name="test_tool", command="python") + tool.client = AsyncMock() + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + tool.client.get_response.return_value = None + no_response = await tool.sampling_callback(Mock(), params) + + assert isinstance(no_response, types.ErrorData) + assert no_response.message == "Failed to get chat message content." + + tool.client.get_response.return_value = Mock( + messages=[Message(role="assistant", contents=[Content.from_text("Hello")])], + model_id="test-model", + ) + + success = await tool.sampling_callback(Mock(), params) + + assert isinstance(success, types.CreateMessageResult) + assert success.role == "assistant" + assert success.model == "test-model" + assert isinstance(success.content, types.TextContent) + assert success.content.text == "Hello" + + +async def test_mcp_tool_logging_callback_logs_at_requested_level() -> None: + tool = MCPStdioTool(name="test_tool", command="python") + + with patch.object(logger, "log") as mock_log: + await tool.logging_callback(types.LoggingMessageNotificationParams(level="warning", data="be careful")) + + mock_log.assert_called_once_with(logging.WARNING, "be careful") + + +async def test_mcp_tool_sampling_callback_forwards_system_prompt(): + """Test sampling callback passes systemPrompt as instructions in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = "You are a helpful assistant" + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("instructions") == "You are a helpful assistant" + + +async def test_mcp_tool_sampling_callback_forwards_tools(): + """Test sampling callback converts MCP tools to FunctionTools and passes them in options.""" + from agent_framework import FunctionTool, Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + mcp_tool = types.Tool( + name="get_weather", + description="Get weather", + inputSchema={"type": "object", "properties": {"city": {"type": "string"}}}, + ) + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = [mcp_tool] + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + tools = options.get("tools") + assert tools is not None + assert len(tools) == 1 + assert isinstance(tools[0], FunctionTool) + assert tools[0].name == "get_weather" + assert tools[0].description == "Get weather" + + +async def test_mcp_tool_sampling_callback_forwards_tool_choice(): + """Test sampling callback passes toolChoice mode in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = types.ToolChoice(mode="required") + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("tool_choice") == "required" + + +async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): + """Test sampling callback forwards empty string systemPrompt as instructions.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = "" + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("instructions") == "" + + +async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): + """Test sampling callback forwards empty tools list in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = [] + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("tools") == [] + + +async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options(): + """Test sampling callback passes temperature, max_tokens, and stop in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = 0.7 + params.maxTokens = 256 + params.stopSequences = ["STOP"] + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("temperature") == 0.7 + assert options.get("max_tokens") == 256 + assert options.get("stop") == ["STOP"] + # These should not be passed as top-level kwargs + assert "temperature" not in call_kwargs.kwargs + assert "max_tokens" not in call_kwargs.kwargs + assert "stop" not in call_kwargs.kwargs + + +async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): + """Test sampling callback does not set temperature in options when it is None.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert "temperature" not in options + assert options.get("max_tokens") == 100 + assert "stop" not in options + + +async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): + """Test sampling callback always sets max_tokens in options since maxTokens is a required int field.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = 200 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options["max_tokens"] == 200 + + +async def test_connect_sampling_capabilities_with_client(): + """Test connect() passes sampling_capabilities to ClientSession when client is set.""" + tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False) + tool.client = Mock() + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session = AsyncMock() + mock_session._request_id = 1 + + session_cm = AsyncMock() + session_cm.__aenter__ = AsyncMock(return_value=mock_session) + session_cm.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = session_cm + + await tool.connect() + + call_kwargs = mock_session_class.call_args.kwargs + sampling_caps = call_kwargs.get("sampling_capabilities") + assert sampling_caps is not None + assert isinstance(sampling_caps, types.SamplingCapability) + assert sampling_caps.tools is not None + assert isinstance(sampling_caps.tools, types.SamplingToolsCapability) + + +async def test_connect_no_sampling_capabilities_without_client(): + """Test connect() does not pass sampling_capabilities when no client is set.""" + tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False) + # No client set + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session = AsyncMock() + mock_session._request_id = 1 + + session_cm = AsyncMock() + session_cm.__aenter__ = AsyncMock(return_value=mock_session) + session_cm.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = session_cm + + await tool.connect() + + call_kwargs = mock_session_class.call_args.kwargs + assert call_kwargs.get("sampling_capabilities") is None + + # Test error handling in connect() method @@ -1628,7 +2166,7 @@ async def test_connect_session_creation_failure(): tool.get_mcp_client = Mock(return_value=mock_context_manager) # Mock ClientSession to raise an exception - with patch("agent_framework._mcp.ClientSession") as mock_session_class: + with patch("mcp.client.session.ClientSession") as mock_session_class: mock_session_class.side_effect = RuntimeError("Session creation failed") with pytest.raises(ToolException) as exc_info: @@ -1653,7 +2191,7 @@ async def test_connect_initialization_failure_http_no_command(): mock_session = Mock() mock_session.initialize = AsyncMock(side_effect=ConnectionError("Server not ready")) - with patch("agent_framework._mcp.ClientSession") as mock_session_class: + with patch("mcp.client.session.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) @@ -1682,6 +2220,18 @@ async def test_connect_cleanup_on_transport_failure(): tool._exit_stack.aclose.assert_called_once() +async def test_connect_cleanup_on_transport_failure_http_uses_generic_message(): + """Test HTTP transport failures use the generic connection message when no command exists.""" + tool = MCPStreamableHTTPTool(name="test", url="https://example.com/mcp") + tool._exit_stack.aclose = AsyncMock() + tool.get_mcp_client = Mock(side_effect=RuntimeError("Transport failed")) + + with pytest.raises(ToolException, match="Failed to connect to MCP server: Transport failed"): + await tool.connect() + + tool._exit_stack.aclose.assert_called_once() + + async def test_connect_cleanup_on_initialization_failure(): """Test that _exit_stack.aclose() is called when initialization fails.""" tool = MCPStdioTool(name="test", command="test-command") @@ -1700,7 +2250,7 @@ async def test_connect_cleanup_on_initialization_failure(): mock_session = Mock() mock_session.initialize = AsyncMock(side_effect=RuntimeError("Init failed")) - with patch("agent_framework._mcp.ClientSession") as mock_session_class: + with patch("mcp.client.session.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) @@ -1717,18 +2267,20 @@ def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs(): tool = MCPStdioTool( name="test", command="test-command", + encoding="utf-16", env=env_vars, custom_param="value1", another_param=42, ) - with patch("agent_framework._mcp.stdio_client"), patch("agent_framework._mcp.StdioServerParameters") as mock_params: + with patch("mcp.client.stdio.stdio_client"), patch("mcp.client.stdio.StdioServerParameters") as mock_params: tool.get_mcp_client() # Verify all parameters including custom kwargs were passed mock_params.assert_called_once_with( command="test-command", args=[], + encoding="utf-16", env=env_vars, custom_param="value1", another_param=42, @@ -1743,7 +2295,7 @@ def test_mcp_streamable_http_tool_get_mcp_client_all_params(): terminate_on_close=True, ) - with patch("agent_framework._mcp.streamable_http_client") as mock_http_client: + with patch("mcp.client.streamable_http.streamable_http_client") as mock_http_client: tool.get_mcp_client() # Verify streamable_http_client was called with None for http_client @@ -1765,7 +2317,7 @@ def test_mcp_websocket_tool_get_mcp_client_with_kwargs(): compression="deflate", ) - with patch("agent_framework._mcp.websocket_client") as mock_ws_client: + with patch("mcp.client.websocket.websocket_client") as mock_ws_client: tool.get_mcp_client() # Verify all kwargs were passed @@ -1923,8 +2475,8 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): # Mock the streamable_http_client to avoid actual connections with ( - patch("agent_framework._mcp.streamable_http_client") as mock_client, - patch("agent_framework._mcp.ClientSession") as mock_session_class, + patch("mcp.client.streamable_http.streamable_http_client") as mock_client, + patch("mcp.client.session.ClientSession") as mock_session_class, ): # Setup mock context manager for streamable_http_client mock_transport = (Mock(), Mock()) @@ -2042,6 +2594,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 @@ -2525,6 +3171,80 @@ async def test_mcp_tool_get_prompt_reconnection_on_closed_resource_error(): assert "failed to reconnect" in str(exc_info.value).lower() +async def test_mcp_tool_call_tool_requires_loaded_tools() -> None: + tool = MCPTool(name="test_tool", load_tools=False) + + with pytest.raises(ToolExecutionException, match="Tools are not loaded"): + await tool.call_tool("remote_tool") + + +async def test_mcp_tool_get_prompt_requires_loaded_prompts() -> None: + tool = MCPTool(name="test_tool", load_prompts=False) + + with pytest.raises(ToolExecutionException, match="Prompts are not loaded"): + await tool.get_prompt("remote_prompt") + + +async def test_mcp_tool_call_tool_raises_after_reconnection_still_fails() -> None: + from anyio.streams.memory import ClosedResourceError + + tool = MCPTool(name="test_tool", load_tools=True) + tool.session = Mock(call_tool=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()])) + + with ( + patch.object(tool, "connect", AsyncMock()) as mock_connect, + patch.object(logger, "error") as mock_error, + pytest.raises(ToolExecutionException, match="connection lost"), + ): + await tool.call_tool("remote_tool") + + mock_connect.assert_awaited_once_with(reset=True) + mock_error.assert_called_once() + + +async def test_mcp_tool_get_prompt_raises_after_reconnection_still_fails() -> None: + from anyio.streams.memory import ClosedResourceError + + tool = MCPTool(name="test_tool", load_prompts=True) + tool.session = Mock(get_prompt=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()])) + + with ( + patch.object(tool, "connect", AsyncMock()) as mock_connect, + patch.object(logger, "error") as mock_error, + pytest.raises(ToolExecutionException, match="connection lost"), + ): + await tool.get_prompt("remote_prompt") + + mock_connect.assert_awaited_once_with(reset=True) + mock_error.assert_called_once() + + +async def test_mcp_tool_wraps_unexpected_call_tool_and_get_prompt_errors() -> None: + tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True) + tool.session = Mock() + tool.session.call_tool = AsyncMock(side_effect=RuntimeError("tool boom")) + tool.session.get_prompt = AsyncMock(side_effect=RuntimeError("prompt boom")) + + with pytest.raises(ToolExecutionException, match="Failed to call tool 'remote_tool'"): + await tool.call_tool("remote_tool") + + with pytest.raises(ToolExecutionException, match="Failed to call prompt 'remote_prompt'"): + await tool.get_prompt("remote_prompt") + + +async def test_mcp_tool_aenter_wraps_unexpected_errors_and_closes() -> None: + tool = MCPStdioTool(name="test_tool", command="python") + + with ( + patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("boom"))), + patch.object(tool, "close", AsyncMock()) as mock_close, + pytest.raises(ToolExecutionException, match="Failed to enter context manager"), + ): + await tool.__aenter__() + + mock_close.assert_awaited_once() + + async def test_mcp_tool_close_cleans_up_in_original_task(caplog): """Closing an MCP tool from another task should still unwind contexts in the owner task.""" import asyncio @@ -2564,7 +3284,7 @@ async def test_mcp_tool_close_cleans_up_in_original_task(caplog): with ( patch.object(tool, "get_mcp_client", return_value=transport_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), ): await asyncio.create_task(tool.connect()) @@ -2622,7 +3342,7 @@ async def test_mcp_tool_connect_reset_cleans_up_in_original_task(caplog): with ( patch.object(tool, "get_mcp_client", side_effect=transport_contexts), - patch("agent_framework._mcp.ClientSession", side_effect=session_contexts), + patch("mcp.client.session.ClientSession", side_effect=session_contexts), ): await tool.connect() @@ -2806,7 +3526,7 @@ async def test_connect_sets_logging_level_when_logger_level_is_set(): with ( patch.object(tool, "get_mcp_client", return_value=mock_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), patch.object(logger, "level", logging.DEBUG), # Set logger level to DEBUG ): await tool.connect() @@ -2843,7 +3563,7 @@ async def test_connect_does_not_set_logging_level_when_logger_level_is_notset(): with ( patch.object(tool, "get_mcp_client", return_value=mock_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), patch.object(logger, "level", logging.NOTSET), # Set logger level to NOTSET ): await tool.connect() @@ -2881,7 +3601,7 @@ async def test_connect_handles_set_logging_level_exception(): with ( patch.object(tool, "get_mcp_client", return_value=mock_context), - patch("agent_framework._mcp.ClientSession", return_value=mock_session_context), + patch("mcp.client.session.ClientSession", return_value=mock_session_context), patch.object(logger, "level", logging.INFO), # Set logger level to INFO patch.object(logger, "warning") as mock_warning, ): @@ -2897,6 +3617,48 @@ async def test_connect_handles_set_logging_level_exception(): assert "Failed to set log level" in call_args[0][0] +async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompts() -> None: + tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True) + tool.is_connected = True + tool.session = Mock() + tool.session._request_id = 0 + tool.session.initialize = AsyncMock() + + with ( + patch.object(tool, "load_tools", AsyncMock()) as mock_load_tools, + patch.object(tool, "load_prompts", AsyncMock()) as mock_load_prompts, + patch.object(logger, "level", logging.NOTSET), + ): + await tool._connect_on_owner() + + tool.session.initialize.assert_awaited_once() + mock_load_tools.assert_awaited_once() + mock_load_prompts.assert_awaited_once() + assert tool._tools_loaded is True + assert tool._prompts_loaded is True + + +async def test_ensure_connected_reconnects_on_failed_ping() -> None: + tool = MCPTool(name="test_tool") + tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed"))) + + with patch.object(tool, "connect", AsyncMock()) as mock_connect: + await tool._ensure_connected() + + mock_connect.assert_awaited_once_with(reset=True) + + +async def test_ensure_connected_wraps_reconnect_failure() -> None: + tool = MCPTool(name="test_tool") + tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed"))) + + with ( + patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("still closed"))), + pytest.raises(ToolExecutionException, match="Failed to establish MCP connection"), + ): + await tool._ensure_connected() + + async def test_mcp_tool_filters_framework_kwargs(): """Test that call_tool filters out framework-specific kwargs before calling MCP session. 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 ff8f4b3ad4..7642ffe73a 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -3,7 +3,7 @@ import logging from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter @@ -11,12 +11,14 @@ from opentelemetry.trace import StatusCode from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, + Agent, AgentResponse, BaseChatClient, ChatResponse, ChatResponseUpdate, Content, Message, + RawAgent, ResponseStream, SupportsAgentRun, UsageDetails, @@ -472,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() @@ -491,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: @@ -1033,6 +1036,278 @@ def test_enable_instrumentation_with_sensitive_data(monkeypatch): assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True +def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch): + """Test enable_instrumentation re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false") + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + # Simulate load_dotenv() setting env var after import + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + + observability.enable_instrumentation() + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True + + +def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch): + """Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false") + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + # Simulate load_dotenv() setting env var after import + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers() + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True + + +def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch): + """Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed.""" + import importlib + from unittest.mock import patch as mock_patch + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None + + # Simulate load_dotenv() setting env var after import + monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317") + + # Mock _configure to avoid needing optional OTLP gRPC exporter dependency + with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers() + assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317 + + +def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch): + """Test that explicit parameters to configure_otel_providers override env vars.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + # Explicit False should override the env var True + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_sensitive_data=False) + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_enable_instrumentation_explicit_param_overrides_env(monkeypatch): + """Test that explicit enable_sensitive_data parameter to enable_instrumentation overrides env var.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + # Explicit False should override the env var True + observability.enable_instrumentation(enable_sensitive_data=False) + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_enable_instrumentation_does_not_touch_console_exporters(monkeypatch): + """Test enable_instrumentation does not modify enable_console_exporters (it is an exporter concern).""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False + + # Simulate load_dotenv() setting env var after import + monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true") + + observability.enable_instrumentation() + # enable_console_exporters is not managed by enable_instrumentation; + # it is only read by configure_otel_providers. + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False + + +def test_enable_instrumentation_does_not_clobber_console_exporters(monkeypatch): + """Test enable_instrumentation does not reset enable_console_exporters set by prior configure call.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + # Set console exporters via configure_otel_providers + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_console_exporters=True) + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True + + # Calling enable_instrumentation should not clobber the value + observability.enable_instrumentation() + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True + + +def test_enable_instrumentation_with_sensitive_data_does_not_touch_console_exporters(monkeypatch): + """Test enable_console_exporters is untouched even when enable_sensitive_data is explicitly passed.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + # Set console exporters via configure_otel_providers + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_console_exporters=True) + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True + + # Calling enable_instrumentation with explicit sensitive_data should not clobber console exporters + observability.enable_instrumentation(enable_sensitive_data=True) + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True + + +def test_enable_instrumentation_preserves_console_exporters_after_env_removed(monkeypatch): + """Test enable_instrumentation preserves enable_console_exporters when env var is removed after reload.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true") + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True + + # Remove the env var after reload + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + + # enable_instrumentation should not reset the value + observability.enable_instrumentation() + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True + + +def test_configure_otel_providers_reads_env_console_exporters(monkeypatch): + """Test configure_otel_providers re-reads ENABLE_CONSOLE_EXPORTERS from os.environ when not explicitly passed.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False + + # Simulate load_dotenv() setting env var after import + monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true") + + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers() + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True + + +def test_configure_otel_providers_explicit_console_exporters_overrides_env(monkeypatch): + """Test that explicit enable_console_exporters parameter overrides the environment variable.""" + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true") + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + # Explicit False should override the env var True + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_console_exporters=False) + assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False + + # region Test _to_otel_part content types @@ -1433,6 +1708,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 @@ -1718,6 +2011,14 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter assert len(spans) == 1 +def test_agent_middleware_wraps_agent_telemetry() -> None: + """Agent middleware must run outside telemetry so middleware time is excluded from agent latency.""" + from agent_framework import Agent + from agent_framework._middleware import AgentMiddlewareLayer + + assert Agent.__mro__.index(AgentMiddlewareLayer) < Agent.__mro__.index(AgentTelemetryLayer) + + # region Test AgentTelemetryLayer error cases @@ -2170,7 +2471,7 @@ def test_capture_response(span_exporter: InMemorySpanExporter): async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: InMemorySpanExporter): """Test that with correct layer ordering, spans appear in the expected sequence. - When using the correct layer ordering (ChatMiddlewareLayer, FunctionInvocationLayer, + When using the correct layer ordering (FunctionInvocationLayer, ChatMiddlewareLayer, ChatTelemetryLayer, BaseChatClient), the spans should appear in this order: 1. First 'chat' span (initial LLM call that returns function call) 2. 'execute_tool' span (function invocation) @@ -2187,11 +2488,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, ): @@ -2263,6 +2564,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 @@ -2686,11 +3063,12 @@ def test_configure_otel_providers_with_env_file_path(monkeypatch, tmp_path): env_file = tmp_path / ".env" env_file.write_text("ENABLE_INSTRUMENTATION=true\n") - observability.configure_otel_providers( - env_file_path=str(env_file), - enable_sensitive_data=True, - vs_code_extension_port=None, - ) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers( + env_file_path=str(env_file), + enable_sensitive_data=True, + vs_code_extension_port=None, + ) assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True @@ -2715,11 +3093,12 @@ def test_configure_otel_providers_with_env_file_and_vs_code_port(monkeypatch, tm env_file = tmp_path / ".env" env_file.write_text("ENABLE_INSTRUMENTATION=true\n") - observability.configure_otel_providers( - env_file_path=str(env_file), - env_file_encoding="utf-8", - vs_code_extension_port=4317, - ) + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers( + env_file_path=str(env_file), + env_file_encoding="utf-8", + vs_code_extension_port=4317, + ) assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317 @@ -2781,3 +3160,143 @@ def test_get_meter_typeerror_fallback(): meter = get_meter(name="test", attributes={"key": "val"}) assert meter is not None assert call_count == 2 + + +# region Agent token usage aggregation + + +@tool(name="get_weather", description="Get weather for a city", approval_mode="never_require") +def _get_weather(city: str) -> str: + """Get weather for a city.""" + return "Sunny, 72°F" + + +@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True) +async def test_agent_invoke_span_aggregates_usage_across_tool_calls(span_exporter: InMemorySpanExporter): + """The invoke_agent span should sum token usage from all chat completions in the function invocation loop.""" + from tests.core.conftest import MockBaseChatClient + + class _InstrumentedAgent(AgentTelemetryLayer, RawAgent): + pass + + client = MockBaseChatClient() + client.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "Seattle"}') + ], + ), + usage_details=UsageDetails(input_token_count=2239, output_token_count=192), + ), + ChatResponse( + messages=Message(role="assistant", text="The weather in Seattle is sunny."), + usage_details=UsageDetails(input_token_count=2569, output_token_count=99), + ), + ] + + agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id") + + span_exporter.clear() + await agent.run( + messages="What is the weather in Seattle?", + options={"tools": [_get_weather], "tool_choice": "auto"}, + ) + + spans = span_exporter.get_finished_spans() + + invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION] + assert len(invoke_spans) == 1 + agent_span = invoke_spans[0] + + chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION] + assert len(chat_spans) == 2 + + # Individual chat spans retain their own usage + assert chat_spans[0].attributes.get(OtelAttr.INPUT_TOKENS) == 2239 + assert chat_spans[0].attributes.get(OtelAttr.OUTPUT_TOKENS) == 192 + assert chat_spans[1].attributes.get(OtelAttr.INPUT_TOKENS) == 2569 + assert chat_spans[1].attributes.get(OtelAttr.OUTPUT_TOKENS) == 99 + + # The invoke_agent span must report the aggregate across all LLM round-trips + assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 2239 + 2569 + assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 192 + 99 + + +@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True) +async def test_agent_invoke_span_usage_single_call(span_exporter: InMemorySpanExporter): + """When only one chat completion occurs, the invoke_agent span usage equals that single call.""" + from tests.core.conftest import MockBaseChatClient + + class _InstrumentedAgent(AgentTelemetryLayer, RawAgent): + pass + + client = MockBaseChatClient() + client.run_responses = [ + ChatResponse( + messages=Message(role="assistant", text="Hello!"), + usage_details=UsageDetails(input_token_count=100, output_token_count=50), + ), + ] + + agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id") + + span_exporter.clear() + await agent.run(messages="Hi") + + spans = span_exporter.get_finished_spans() + invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION] + assert len(invoke_spans) == 1 + + assert invoke_spans[0].attributes.get(OtelAttr.INPUT_TOKENS) == 100 + assert invoke_spans[0].attributes.get(OtelAttr.OUTPUT_TOKENS) == 50 + + +@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True) +async def test_agent_invoke_span_aggregates_usage_on_max_iterations_exhaustion(span_exporter: InMemorySpanExporter): + """When the function invocation loop exhausts max_iterations, the final response aggregates usage + from all rounds.""" + from tests.core.conftest import MockBaseChatClient + + class _InstrumentedAgent(AgentTelemetryLayer, RawAgent): + pass + + client = MockBaseChatClient( + function_invocation_configuration={"max_iterations": 1}, + ) + client.run_responses = [ + # Iteration 0: model returns a tool call + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "Seattle"}') + ], + ), + usage_details=UsageDetails(input_token_count=500, output_token_count=100), + ), + # Exhaustion path: consumed by tool_choice="none" final call (mock ignores usage) + ChatResponse( + messages=Message(role="assistant", text="placeholder"), + usage_details=UsageDetails(input_token_count=300, output_token_count=60), + ), + ] + + agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id") + + span_exporter.clear() + await agent.run( + messages="What is the weather in Seattle?", + options={"tools": [_get_weather], "tool_choice": "auto"}, + ) + + spans = span_exporter.get_finished_spans() + + invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION] + assert len(invoke_spans) == 1 + agent_span = invoke_spans[0] + + # The invoke_agent span must aggregate usage from the in-loop call and the final exhaustion call + assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 500 + assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 100 diff --git a/python/packages/core/tests/core/test_optional_dependencies.py b/python/packages/core/tests/core/test_optional_dependencies.py new file mode 100644 index 0000000000..7c424b3454 --- /dev/null +++ b/python/packages/core/tests/core/test_optional_dependencies.py @@ -0,0 +1,181 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import sys + +import pytest + +import agent_framework +import agent_framework.observability as observability +from agent_framework import Agent + + +def _hide_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + for module_name in list(sys.modules): + if module_name == "opentelemetry.sdk" or module_name.startswith("opentelemetry.sdk."): + sys.modules.pop(module_name, None) + + def _import_without_otel_sdk( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "opentelemetry.sdk" or name.startswith("opentelemetry.sdk."): + raise ModuleNotFoundError(f"No module named '{name}'", name=name) + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_otel_sdk) + + +def test_create_resource_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + _hide_otel_sdk(monkeypatch) + + with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"): + observability.create_resource() + + +def test_observability_settings_initializes_without_cached_resource(monkeypatch: pytest.MonkeyPatch) -> None: + _hide_otel_sdk(monkeypatch) + + settings = observability.ObservabilitySettings() + + assert not hasattr(settings, "_resource") + + +def test_configure_otel_providers_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + _hide_otel_sdk(monkeypatch) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "VS_CODE_EXTENSION_PORT", + ]: + monkeypatch.delenv(key, raising=False) + + with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"): + observability.configure_otel_providers() + + +def test_agent_framework_mcp_exports_remain_importable_without_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + import agent_framework._mcp as mcp_module + + real_import = builtins.__import__ + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp" or name.startswith("mcp."): + raise ModuleNotFoundError("No module named 'mcp'") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + assert agent_framework.MCPStdioTool is mcp_module.MCPStdioTool + + with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"): + agent_framework.MCPStdioTool(name="test", command="python").get_mcp_client() + + +def test_mcp_streamable_http_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp" or name.startswith("mcp."): + raise ModuleNotFoundError("No module named 'mcp'") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + + with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"): + agent_framework.MCPStreamableHTTPTool(name="test", url="https://example.com").get_mcp_client() + + +def test_agent_as_mcp_server_requires_mcp(client, monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp" or name.startswith("mcp."): + raise ModuleNotFoundError("No module named 'mcp'") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + + agent = Agent(client=client) + + with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"): + agent.as_mcp_server() + + +def test_mcp_websocket_tool_requires_ws_support(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + sys.modules.pop("mcp.client.websocket", None) + + def _import_without_websocket_support( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp.client.websocket": + raise ModuleNotFoundError("No module named 'websockets'", name="websockets") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_websocket_support) + + with pytest.raises(ModuleNotFoundError, match=r"mcp\[ws\]"): + agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client() + + +def test_mcp_websocket_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + sys.modules.pop("mcp.client.websocket", None) + + def _import_without_mcp( + name: str, + globals_: dict[str, object] | None = None, + locals_: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "mcp.client.websocket": + raise ModuleNotFoundError("No module named 'mcp.client.websocket'", name="mcp.client.websocket") + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _import_without_mcp) + + with pytest.raises(ModuleNotFoundError, match=r"agent-framework-core\[mcp\]|mcp\[ws\]"): + agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client() diff --git a/python/packages/core/tests/core/test_tools_future_annotations.py b/python/packages/core/tests/core/test_tools_future_annotations.py new file mode 100644 index 0000000000..1c9649dcb9 --- /dev/null +++ b/python/packages/core/tests/core/test_tools_future_annotations.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for @tool with PEP 563 (from __future__ import annotations). + +When ``from __future__ import annotations`` is active, all annotations +become strings. _resolve_input_model must resolve them via +typing.get_type_hints() before passing them to Pydantic's create_model. +""" + +from __future__ import annotations + +from pydantic import BaseModel + +from agent_framework import tool +from agent_framework._middleware import FunctionInvocationContext + + +class SearchConfig(BaseModel): + max_results: int = 10 + + +def test_tool_with_context_parameter(): + """FunctionInvocationContext parameter is excluded from schema under PEP 563.""" + + @tool + def get_weather(location: str, ctx: FunctionInvocationContext) -> str: + """Get the weather for a given location.""" + return f"Weather in {location}" + + params = get_weather.parameters() + assert "ctx" not in params.get("properties", {}) + assert "location" in params["properties"] + + +def test_tool_with_context_parameter_first(): + """FunctionInvocationContext as the first parameter is excluded under PEP 563.""" + + @tool + def get_weather(ctx: FunctionInvocationContext, location: str) -> str: + """Get the weather for a given location.""" + return f"Weather in {location}" + + params = get_weather.parameters() + assert "ctx" not in params.get("properties", {}) + assert "location" in params["properties"] + + +def test_tool_with_optional_param(): + """Optional[int] is resolved to the actual type, not left as a string.""" + + @tool + def search(query: str, limit: int | None = None) -> str: + """Search for something.""" + return query + + params = search.parameters() + assert params["properties"]["query"]["type"] == "string" + limit_schema = params["properties"]["limit"] + limit_types = {t["type"] for t in limit_schema["anyOf"]} + assert limit_types == {"integer", "null"} + + +def test_tool_with_optional_param_and_context(): + """Optional param + FunctionInvocationContext both work under PEP 563.""" + + @tool + def search(query: str, limit: int | None = None, ctx: FunctionInvocationContext | None = None) -> str: + """Search for something.""" + return query + + params = search.parameters() + assert params["properties"]["query"]["type"] == "string" + limit_schema = params["properties"]["limit"] + limit_types = {t["type"] for t in limit_schema["anyOf"]} + assert limit_types == {"integer", "null"} + assert "ctx" not in params.get("properties", {}) + + +def test_tool_with_optional_custom_type(): + """Optional[CustomType] is resolved under PEP 563 (original bug pattern).""" + + @tool + def search(query: str, config: SearchConfig | None = None) -> str: + """Search for something.""" + return query + + params = search.parameters() + assert params["properties"]["query"]["type"] == "string" + config_schema = params["properties"]["config"] + config_types = [t.get("type") for t in config_schema["anyOf"]] + assert "null" in config_types + + +def test_tool_with_unresolvable_forward_ref(): + """Fallback to raw annotations when get_type_hints() fails.""" + import types + + # Build a function in an isolated namespace so get_type_hints() cannot resolve + # the forward reference, exercising the except-branch fallback. + ns: dict = {} + exec( + "def greet(name: str = 'world') -> str:\n '''Greet someone.'''\n return f'Hello {name}'\n", + ns, + ) + func = ns["greet"] + # Place the function in a throwaway module so get_type_hints() will fail on + # any non-builtin forward ref while still having a valid __module__. + mod = types.ModuleType("_phantom") + func.__module__ = mod.__name__ + + t = tool(func) + params = t.parameters() + assert params["properties"]["name"]["type"] == "string" + + +async def test_tool_invoke_with_context(): + """Full invocation with FunctionInvocationContext under PEP 563.""" + + @tool + def get_weather(location: str, ctx: FunctionInvocationContext) -> str: + """Get the weather for a given location.""" + user = ctx.kwargs.get("user", "anon") + return f"Weather in {location} for {user}" + + params = get_weather.parameters() + assert "ctx" not in params.get("properties", {}) + + context = FunctionInvocationContext( + function=get_weather, + arguments=get_weather.input_model(location="Seattle"), + kwargs={"user": "test_user"}, + ) + result = await get_weather.invoke(context=context) + assert result[0].text == "Weather in Seattle for test_user" diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 5e9469c8bd..8e5a0dc7d8 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import base64 +import json from collections.abc import AsyncIterable, Sequence from dataclasses import dataclass from datetime import datetime, timezone @@ -1030,11 +1031,11 @@ def test_chat_tool_mode_from_dict(): def test_chat_options_init() -> None: """Test that ChatOptions can be created as a TypedDict.""" options: ChatOptions = {} - assert options.get("model_id") is None + assert options.get("model") is None # With values - options_with_model: ChatOptions = {"model_id": "gpt-4o", "temperature": 0.7} - assert options_with_model.get("model_id") == "gpt-4o" + options_with_model: ChatOptions = {"model": "gpt-4o", "temperature": 0.7} + assert options_with_model.get("model") == "gpt-4o" assert options_with_model.get("temperature") == 0.7 @@ -1068,18 +1069,18 @@ def test_chat_options_tool_choice_validation(): def test_chat_options_merge(tool_tool, ai_tool) -> None: """Test merge_chat_options utility function.""" options1: ChatOptions = { - "model_id": "gpt-4o", + "model": "gpt-4o", "tools": [tool_tool], "logit_bias": {"x": 1}, "metadata": {"a": "b"}, } - options2: ChatOptions = {"model_id": "gpt-4.1", "tools": [ai_tool]} + options2: ChatOptions = {"model": "gpt-4.1", "tools": [ai_tool]} assert options1 != options2 # Merge options - override takes precedence for non-collection fields options3 = merge_chat_options(options1, options2) - assert options3.get("model_id") == "gpt-4.1" + assert options3.get("model") == "gpt-4.1" assert options3.get("tools") == [tool_tool, ai_tool] # tools are combined assert options3.get("logit_bias") == {"x": 1} # base value preserved assert options3.get("metadata") == {"a": "b"} # base value preserved @@ -1088,7 +1089,7 @@ def test_chat_options_merge(tool_tool, ai_tool) -> None: def test_chat_options_and_tool_choice_override() -> None: """Test that tool_choice from other takes precedence in ChatOptions merge.""" # Agent-level defaults to "auto" - agent_options: ChatOptions = {"model_id": "gpt-4o", "tool_choice": "auto"} + agent_options: ChatOptions = {"model": "gpt-4o", "tool_choice": "auto"} # Run-level specifies "required" run_options: ChatOptions = {"tool_choice": "required"} @@ -1096,19 +1097,19 @@ def test_chat_options_and_tool_choice_override() -> None: # Run-level should override agent-level assert merged.get("tool_choice") == "required" - assert merged.get("model_id") == "gpt-4o" # Other fields preserved + assert merged.get("model") == "gpt-4o" # Other fields preserved def test_chat_options_and_tool_choice_none_in_other_uses_self() -> None: """Test that when other.tool_choice is None, self.tool_choice is used.""" agent_options: ChatOptions = {"tool_choice": "auto"} - run_options: ChatOptions = {"model_id": "gpt-4.1"} # tool_choice is None + run_options: ChatOptions = {"model": "gpt-4.1"} # tool_choice is None merged = merge_chat_options(agent_options, run_options) # Should keep agent-level tool_choice since run-level is None assert merged.get("tool_choice") == "auto" - assert merged.get("model_id") == "gpt-4.1" + assert merged.get("model") == "gpt-4.1" def test_chat_options_and_tool_choice_with_tool_mode() -> None: @@ -1710,6 +1711,47 @@ def test_content_roundtrip_preserves_compaction_annotation_dict() -> None: assert annotation[GROUP_TOKEN_COUNT_KEY] is None +def test_content_from_dict_via_json() -> None: + """Test Content.from_dict with data parsed from a JSON string.""" + data = json.loads(json.dumps({"type": "text", "text": "Hello world"})) + content = Content.from_dict(data) + assert content.type == "text" + assert content.text == "Hello world" + + +def test_content_from_dict_roundtrip_via_json() -> None: + """Test Content.from_dict roundtrip via to_dict and json.dumps.""" + original = Content.from_function_call(call_id="call1", name="my_func", arguments={"key": "value"}) + data = json.loads(json.dumps(original.to_dict())) + restored = Content.from_dict(data) + assert restored.type == "function_call" + assert restored.call_id == "call1" + assert restored.name == "my_func" + assert restored.arguments == {"key": "value"} + + +def test_content_to_dict_exclude_none() -> None: + """Test Content.to_dict excludes None fields by default.""" + content = Content.from_text("Hello") + d = content.to_dict() + parsed = json.loads(json.dumps(d)) + assert "uri" not in parsed + + d_with_none = content.to_dict(exclude_none=False) + parsed_with_none = json.loads(json.dumps(d_with_none)) + assert "uri" in parsed_with_none + assert parsed_with_none["uri"] is None + + +def test_content_to_dict_exclude_fields() -> None: + """Test Content.to_dict with explicit field exclusion.""" + content = Content.from_text("Hello") + d = content.to_dict(exclude={"text"}) + parsed = json.loads(json.dumps(d)) + assert "text" not in parsed + assert parsed["type"] == "text" + + def test_chat_response_roundtrip_preserves_compaction_annotation_dict() -> None: response = ChatResponse( messages=[ @@ -1803,7 +1845,7 @@ def test_chat_response_complex_serialization(): "output_token_count": 8, "total_token_count": 13, }, - "model_id": "gpt-4", # Test alias handling + "model": "gpt-4", # Test alias handling } response = ChatResponse.from_dict(response_data) @@ -1819,7 +1861,7 @@ def test_chat_response_complex_serialization(): assert isinstance(response_dict["messages"][0], dict) assert isinstance(response_dict["finish_reason"], str) # FinishReason serializes to string assert isinstance(response_dict["usage_details"], dict) - assert response_dict["model_id"] == "gpt-4" # Should serialize as model_id + assert response_dict["model"] == "gpt-4" # Should serialize as model_id def test_chat_response_update_all_content_types(): @@ -2267,7 +2309,7 @@ def test_chat_response_deepcopy_deep_copies_additional_properties(): "total_token_count": 30, }, "response_id": "resp-123", - "model_id": "gpt-4", + "model": "gpt-4", }, id="chat_response", ), diff --git a/python/packages/core/tests/openai/conftest.py b/python/packages/core/tests/openai/conftest.py deleted file mode 100644 index beb20ead82..0000000000 --- a/python/packages/core/tests/openai/conftest.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from typing import Any - -from pytest import fixture - - -# region Connector Settings fixtures -@fixture -def exclude_list(request: Any) -> list[str]: - """Fixture that returns a list of environment variables to exclude.""" - return request.param if hasattr(request, "param") else [] - - -@fixture -def override_env_param_dict(request: Any) -> dict[str, str]: - """Fixture that returns a dict of environment variables to override.""" - return request.param if hasattr(request, "param") else {} - - -@fixture() -def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore - """Fixture to set environment variables for OpenAISettings.""" - - if exclude_list is None: - exclude_list = [] - - if override_env_param_dict is None: - override_env_param_dict = {} - - env_vars = { - "OPENAI_API_KEY": "test-dummy-key", - "OPENAI_ORG_ID": "test_org_id", - "OPENAI_RESPONSES_MODEL_ID": "test_responses_model_id", - "OPENAI_CHAT_MODEL_ID": "test_chat_model_id", - "OPENAI_TEXT_MODEL_ID": "test_text_model_id", - "OPENAI_EMBEDDING_MODEL_ID": "test_embedding_model_id", - "OPENAI_TEXT_TO_IMAGE_MODEL_ID": "test_text_to_image_model_id", - "OPENAI_AUDIO_TO_TEXT_MODEL_ID": "test_audio_to_text_model_id", - "OPENAI_TEXT_TO_AUDIO_MODEL_ID": "test_text_to_audio_model_id", - "OPENAI_REALTIME_MODEL_ID": "test_realtime_model_id", - } - - env_vars.update(override_env_param_dict) # type: ignore - - for key, value in env_vars.items(): - if key in exclude_list: - monkeypatch.delenv(key, raising=False) # type: ignore - continue - monkeypatch.setenv(key, value) # type: ignore - - return env_vars 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/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 8eff06022b..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", ] @@ -92,9 +92,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_declarative" -test = 'pytest -m "not integration" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_declarative" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json index 4a43bcd90f..e137c1053c 100644 --- a/python/packages/devui/frontend/package-lock.json +++ b/python/packages/devui/frontend/package-lock.json @@ -3892,9 +3892,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock index c0278b182e..24636d2146 100644 --- a/python/packages/devui/frontend/yarn.lock +++ b/python/packages/devui/frontend/yarn.lock @@ -1806,9 +1806,9 @@ flat-cache@^4.0.0: keyv "^4.5.4" flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 396dd22e5d..f8441af3b0 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" @@ -22,11 +22,13 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Typing :: Typed", ] -dependencies = [ - "agent-framework-core>=1.0.0rc4", - "fastapi>=0.115.0,<0.133.1", - "uvicorn[standard]>=0.30.0,<0.42.0" -] + dependencies = [ + "agent-framework-core>=1.0.0rc5", + "openai>=1.99.0,<3", + "opentelemetry-sdk>=1.39.0,<2", + "fastapi>=0.115.0,<0.133.1", + "uvicorn[standard]>=0.30.0,<0.42.0" + ] [project.optional-dependencies] dev = [ @@ -98,9 +100,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_devui" -test = 'pytest -m "not integration" --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_devui" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py index a10fceefa4..f1fb577e56 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py +++ b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py @@ -978,7 +978,7 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent): return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments) def to_ai_content(self) -> Content: - return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=self.arguments) + return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=json.dumps(self.arguments)) class DurableAgentStateFunctionResultContent(DurableAgentStateContent): @@ -1318,9 +1318,17 @@ class DurableAgentStateUnknownContent(DurableAgentStateContent): @staticmethod def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent: + if isinstance(content, Content): + return DurableAgentStateUnknownContent(content=content.to_dict()) return DurableAgentStateUnknownContent(content=content) def to_ai_content(self) -> Content: if not self.content: raise Exception("The content is missing and cannot be converted to valid AI content.") + content_value: Any = self.content + if isinstance(content_value, dict) and "type" in content_value: + try: + return Content.from_dict(cast(dict[str, Any], content_value)) + except (ValueError, TypeError): + pass return Content(type=self.type, additional_properties={"content": self.content}) # type: ignore diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index 44f87918d1..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", @@ -97,9 +97,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask" -test = 'pytest -m "not integration" --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/durabletask/tests/integration_tests/README.md b/python/packages/durabletask/tests/integration_tests/README.md index 6946cec665..5cce6712e4 100644 --- a/python/packages/durabletask/tests/integration_tests/README.md +++ b/python/packages/durabletask/tests/integration_tests/README.md @@ -14,7 +14,7 @@ cp .env.example .env Required variables: - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` +- `AZURE_OPENAI_DEPLOYMENT_NAME` - `AZURE_OPENAI_API_KEY` (optional if using Azure CLI authentication) - `ENDPOINT` (default: http://localhost:8080) - `TASKHUB` (default: default) @@ -97,7 +97,7 @@ If you see "DTS emulator is not available": If you see authentication or deployment errors: - Verify your `AZURE_OPENAI_ENDPOINT` is correct -- Confirm `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` matches your deployment +- Confirm `AZURE_OPENAI_DEPLOYMENT_NAME` matches your deployment - If using API key, check `AZURE_OPENAI_API_KEY` is valid - If using Azure CLI, ensure you're logged in: `az login` diff --git a/python/packages/durabletask/tests/integration_tests/conftest.py b/python/packages/durabletask/tests/integration_tests/conftest.py index 475963c057..94fce45109 100644 --- a/python/packages/durabletask/tests/integration_tests/conftest.py +++ b/python/packages/durabletask/tests/integration_tests/conftest.py @@ -289,9 +289,11 @@ def pytest_configure(config: pytest.Config) -> None: def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: """Skip tests based on markers and environment availability.""" - # Check Azure OpenAI environment variables - azure_openai_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + foundry_vars = ["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"] + foundry_available = all(os.getenv(var) for var in foundry_vars) + azure_openai_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"] azure_openai_available = all(os.getenv(var) for var in azure_openai_vars) + skip_foundry = pytest.mark.skip(reason=f"Missing required environment variables: {', '.join(foundry_vars)}") skip_azure_openai = pytest.mark.skip( reason=f"Missing required environment variables: {', '.join(azure_openai_vars)}" ) @@ -305,7 +307,11 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item skip_redis = pytest.mark.skip(reason="Redis is not available at redis://localhost:6379") for item in items: - if "requires_azure_openai" in item.keywords and not azure_openai_available: + if "requires_azure_openai" in item.keywords and not foundry_available: + item.add_marker(skip_foundry) + sample_marker = item.get_closest_marker("sample") + sample_name = sample_marker.args[0] if sample_marker and sample_marker.args else None + if sample_name == "06_multi_agent_orchestration_conditionals" and not azure_openai_available: item.add_marker(skip_azure_openai) if "requires_dts" in item.keywords and not dts_available: item.add_marker(skip_dts) @@ -333,10 +339,18 @@ def dts_available(dts_endpoint: str) -> bool: return False -@pytest.fixture(scope="session") -def check_azure_openai_env() -> None: - """Verify Azure OpenAI environment variables are set.""" - required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] +@pytest.fixture(scope="module") +def check_sample_env(request: pytest.FixtureRequest) -> None: + """Verify the environment variables required by the current sample are set.""" + sample_marker = request.node.get_closest_marker("sample") # type: ignore[union-attr] + if not sample_marker: + pytest.fail("Test class must have @pytest.mark.sample() marker") + + sample_name = cast(str, sample_marker.args[0]) # type: ignore[union-attr] + if sample_name == "06_multi_agent_orchestration_conditionals": + required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT_NAME"] + else: + required_vars = ["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"] missing = [var for var in required_vars if not os.getenv(var)] if missing: @@ -353,7 +367,7 @@ def unique_taskhub() -> str: @pytest.fixture(scope="module") def worker_process( dts_available: bool, - check_azure_openai_env: None, + check_sample_env: None, dts_endpoint: str, unique_taskhub: str, request: pytest.FixtureRequest, diff --git a/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py b/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py index bf4700824b..d20c67e20f 100644 --- a/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py +++ b/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py @@ -52,6 +52,7 @@ class TestMultiAgentOrchestrationConditionals: assert email_agent is not None assert email_agent.name == EMAIL_AGENT_NAME + @pytest.mark.skip(reason="Consistently fails due to orchestration timeouts - needs investigation") def test_conditional_branching(self): """Test that conditional branching works correctly.""" # Test with obvious spam @@ -65,26 +66,10 @@ class TestMultiAgentOrchestrationConditionals: input=spam_payload, ) - # Test with legitimate email - legit_payload = { - "email_id": "legit-001", - "email_content": "Hi team, please review the attached document before our meeting tomorrow.", - } - - legit_instance_id = self.dts_client.schedule_new_orchestration( - orchestrator="spam_detection_orchestration", - input=legit_payload, - ) - # Both should complete successfully (different branches) spam_metadata = self.orch_helper.wait_for_orchestration( instance_id=spam_instance_id, timeout=120.0, ) - legit_metadata = self.orch_helper.wait_for_orchestration( - instance_id=legit_instance_id, - timeout=120.0, - ) assert spam_metadata.runtime_status == OrchestrationStatus.COMPLETED - assert legit_metadata.runtime_status == OrchestrationStatus.COMPLETED diff --git a/python/packages/durabletask/tests/test_durable_agent_state.py b/python/packages/durabletask/tests/test_durable_agent_state.py index 24b31a747e..d3a36c9a7e 100644 --- a/python/packages/durabletask/tests/test_durable_agent_state.py +++ b/python/packages/durabletask/tests/test_durable_agent_state.py @@ -2,16 +2,20 @@ """Unit tests for DurableAgentState and related classes.""" +import json from datetime import datetime import pytest -from agent_framework import UsageDetails +from agent_framework import Content, Message, UsageDetails from agent_framework_durabletask._durable_agent_state import ( DurableAgentState, + DurableAgentStateContent, + DurableAgentStateFunctionCallContent, DurableAgentStateMessage, DurableAgentStateRequest, DurableAgentStateTextContent, + DurableAgentStateUnknownContent, DurableAgentStateUsage, ) from agent_framework_durabletask._models import RunRequest @@ -214,6 +218,38 @@ class TestDurableAgentState: assert len(restored.data.conversation_history) == len(state.data.conversation_history) assert restored.data.conversation_history[0].correlation_id == "test-456" + def test_function_call_round_trip_preserves_string_arguments(self) -> None: + """Function call arguments should remain strings across durable state replay.""" + original = Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call-123", + name="get_weather", + arguments='{"location":"Chicago"}', + ) + ], + ) + + durable_message = DurableAgentStateMessage.from_chat_message(original) + restored = durable_message.to_chat_message() + + assert restored.contents[0].type == "function_call" + assert restored.contents[0].arguments == '{"location": "Chicago"}' + + def test_function_call_content_supports_legacy_mapping_arguments(self) -> None: + """Existing persisted mapping arguments should still restore successfully.""" + content = DurableAgentStateFunctionCallContent( + call_id="call-123", + name="get_weather", + arguments={"location": "Chicago"}, + ) + + restored = content.to_ai_content() + + assert restored.type == "function_call" + assert restored.arguments == '{"location": "Chicago"}' + class TestDurableAgentStateUsage: """Test suite for DurableAgentStateUsage.""" @@ -373,5 +409,117 @@ class TestDurableAgentStateUsage: assert restored.get("total_token_count") == original.get("total_token_count") +class TestDurableAgentStateUnknownContent: + """Test suite for DurableAgentStateUnknownContent serialization.""" + + def test_unknown_content_from_content_object_produces_serializable_dict(self) -> None: + """Test that from_unknown_content serializes Content objects to dicts.""" + content = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="search", + server_name="learn-mcp", + arguments={"query": "azure functions"}, + ) + + unknown = DurableAgentStateUnknownContent.from_unknown_content(content) + result = unknown.to_dict() + + # The content field should be a dict, not a Content object + assert isinstance(result["content"], dict) + assert result["content"]["type"] == "mcp_server_tool_call" + + def test_unknown_content_to_dict_is_json_serializable(self) -> None: + """Test that to_dict output can be passed to json.dumps without error.""" + content = Content.from_mcp_server_tool_result( + call_id="call-1", + output="Azure Functions documentation...", + ) + + unknown = DurableAgentStateUnknownContent.from_unknown_content(content) + result = unknown.to_dict() + + # This must not raise TypeError + serialized = json.dumps(result) + assert serialized is not None + + def test_unknown_content_round_trip_preserves_content(self) -> None: + """Test that Content objects survive serialization and deserialization.""" + original = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="fetch", + server_name="learn-mcp", + arguments={"url": "https://example.com"}, + ) + + unknown = DurableAgentStateUnknownContent.from_unknown_content(original) + restored = unknown.to_ai_content() + + assert restored.type == "mcp_server_tool_call" + assert restored.tool_name == "fetch" + assert restored.server_name == "learn-mcp" + + def test_unknown_content_from_plain_dict_unchanged(self) -> None: + """Test that non-Content values are stored as-is.""" + plain = {"some": "data"} + + unknown = DurableAgentStateUnknownContent.from_unknown_content(plain) + + assert unknown.content == {"some": "data"} + + def test_unknown_content_to_ai_content_fallback_on_invalid_type_dict(self) -> None: + """Test that to_ai_content falls back when dict has 'type' but is not valid Content.""" + invalid = {"type": "bogus_not_a_real_content_type", "extra": "stuff"} + unknown = DurableAgentStateUnknownContent(content=invalid) + + result = unknown.to_ai_content() + + assert result.type == "unknown" + assert result.additional_properties == {"content": invalid} + + def test_from_ai_content_unknown_type_produces_serializable_state(self) -> None: + """Test that unknown content types in message conversion produce JSON-serializable state.""" + content = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="search", + server_name="learn-mcp", + arguments={"query": "create function app"}, + ) + + durable_content = DurableAgentStateContent.from_ai_content(content) + data = durable_content.to_dict() + + # Must be fully JSON-serializable + serialized = json.dumps(data) + assert serialized is not None + + def test_state_with_mcp_content_is_json_serializable(self) -> None: + """Test that full DurableAgentState with MCP content can be serialized to JSON. + + This reproduces the scenario from issue #4719 where agent state containing + MCP tool content could not be serialized by Azure Durable Functions. + """ + state = DurableAgentState() + mcp_content = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="search", + server_name="learn-mcp", + arguments={"query": "azure functions"}, + ) + message = DurableAgentStateMessage.from_chat_message(Message(role="assistant", contents=[mcp_content])) + state.data.conversation_history.append( + DurableAgentStateRequest( + correlation_id="test-mcp", + created_at=datetime.now(), + messages=[message], + ) + ) + + state_dict = state.to_dict() + + # This simulates what Azure Durable Functions does with entity state + serialized = json.dumps(state_dict) + assert serialized is not None + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/foundry/LICENSE b/python/packages/foundry/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/foundry/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md new file mode 100644 index 0000000000..abaa0cb9c3 --- /dev/null +++ b/python/packages/foundry/README.md @@ -0,0 +1,3 @@ +# Agent Framework Foundry + +This package contains the cloud Azure AI Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, and Foundry memory providers. diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py new file mode 100644 index 0000000000..3f7a5d5095 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._foundry_agent import FoundryAgent, RawFoundryAgent +from ._foundry_agent_client import RawFoundryAgentChatClient +from ._foundry_chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient +from ._foundry_memory_provider import FoundryMemoryProvider + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "FoundryAgent", + "FoundryChatClient", + "FoundryChatOptions", + "FoundryMemoryProvider", + "RawFoundryAgent", + "RawFoundryAgentChatClient", + "RawFoundryChatClient", + "__version__", +] diff --git a/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py b/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py new file mode 100644 index 0000000000..b1ae8a4739 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import Union + +from agent_framework.exceptions import ChatClientInvalidAuthException +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential + +logger: logging.Logger = logging.getLogger(__name__) + +AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] +"""A callable that returns a bearer token string, either synchronously or asynchronously.""" + +AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential] +"""Union of Azure credential types. + +Accepts: +- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``) +- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``) +""" + + +def resolve_credential_to_token_provider( + credential: AzureCredentialTypes | AzureTokenProvider, + token_endpoint: str | None, +) -> AzureTokenProvider: + """Convert an Azure credential or token provider into an ``ad_token_provider`` callable. + + If the credential is already a callable token provider, it is returned as-is + (``token_endpoint`` is not required in this case). + If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using + ``azure.identity.get_bearer_token_provider`` (sync or async variant) which + handles token caching and automatic refresh. + + Args: + credential: An Azure credential or token provider callable. + token_endpoint: The token scope/endpoint + (e.g. ``"https://cognitiveservices.azure.com/.default"``). + Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``. + + Returns: + A callable that returns a bearer token string (sync or async). + + Raises: + ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping. + """ + # Already a token provider callable (not a credential object) — use directly + if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)): + return credential + + if not token_endpoint: + raise ChatClientInvalidAuthException( + "A token endpoint must be provided either in settings, as an environment variable, or as an argument." + ) + + if isinstance(credential, AsyncTokenCredential): + from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider + + return get_async_bearer_token_provider(credential, token_endpoint) + + from azure.identity import get_bearer_token_provider + + return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type] diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py b/python/packages/foundry/agent_framework_foundry/_foundry_agent.py new file mode 100644 index 0000000000..19c298eaf8 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_foundry_agent.py @@ -0,0 +1,287 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry. + +This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses +that connect to existing PromptAgents or HostedAgents in Foundry. Use +``FoundryAgent`` for the recommended experience with full middleware and telemetry. +""" + +from __future__ import annotations + +import logging +import sys +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any + +from agent_framework import ( + AgentMiddlewareLayer, + BaseContextProvider, + RawAgent, +) +from agent_framework.observability import AgentTelemetryLayer +from azure.ai.projects.aio import AIProjectClient + +from ._entra_id_authentication import AzureCredentialTypes +from ._foundry_agent_client import ( + RawFoundryAgentChatClient, + _FoundryAgentChatClient, # pyright: ignore[reportPrivateUsage] +) + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +if TYPE_CHECKING: + from agent_framework._middleware import MiddlewareTypes + from agent_framework._tools import FunctionTool + from agent_framework_openai._chat_client import OpenAIChatOptions + +logger: logging.Logger = logging.getLogger("agent_framework.foundry") + +FoundryAgentOptionsT = TypeVar( + "FoundryAgentOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OpenAIChatOptions", + covariant=True, +) + + +class RawFoundryAgent( # type: ignore[misc] + RawAgent[FoundryAgentOptionsT], +): + """Raw Microsoft Foundry Agent without agent-level middleware or telemetry. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + For full middleware and telemetry support, use :class:`FoundryAgent`. + + Examples: + .. code-block:: python + + from agent_framework.foundry import RawFoundryAgent + from azure.identity import AzureCliCredential + + agent = RawFoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + result = await agent.run("Hello!") + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + agent_name: The name of the Foundry agent to connect to. + Can also be set via environment variable FOUNDRY_AGENT_NAME. + agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). + Can also be set via environment variable FOUNDRY_AGENT_VERSION. + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers for injecting dynamic context. + client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). + Defaults to ``_FoundryAgentChatClient`` (full client middleware). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments passed to the Agent base class. + """ + # Create the client + actual_client_type = client_type or _FoundryAgentChatClient + if not issubclass(actual_client_type, RawFoundryAgentChatClient): + raise TypeError( + f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" + ) + + client = actual_client_type( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + super().__init__( + client=client, # type: ignore[arg-type] + tools=tools, # type: ignore[arg-type] + context_providers=context_providers, + **kwargs, + ) + + async def configure_azure_monitor( + self, + enable_sensitive_data: bool = False, + **kwargs: Any, + ) -> None: + """Setup observability with Azure Monitor (Microsoft Foundry integration). + + This method configures Azure Monitor for telemetry collection using the + connection string from the Foundry project client (accessed via the internal client). + + Args: + enable_sensitive_data: Enable sensitive data logging (prompts, responses). + Should only be enabled in development/test environments. Default is False. + **kwargs: Additional arguments passed to configure_azure_monitor(). + + Raises: + ImportError: If azure-monitor-opentelemetry-exporter is not installed. + """ + from azure.core.exceptions import ResourceNotFoundError + + from ._foundry_agent_client import RawFoundryAgentChatClient + + client = self.client + if not isinstance(client, RawFoundryAgentChatClient): + raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") + + try: + conn_string = await client.project_client.telemetry.get_application_insights_connection_string() + except ResourceNotFoundError: + logger.warning( + "No Application Insights connection string found for the Foundry project. " + "Please ensure Application Insights is configured in your project, " + "or call configure_otel_providers() manually with custom exporters." + ) + return + + try: + from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] + except ImportError as exc: + raise ImportError( + "azure-monitor-opentelemetry is required for Azure Monitor integration. " + "Install it with: pip install azure-monitor-opentelemetry" + ) from exc + + from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation + + if "resource" not in kwargs: + kwargs["resource"] = create_resource() + + configure_azure_monitor( + connection_string=conn_string, + views=create_metric_views(), + **kwargs, + ) + + enable_instrumentation(enable_sensitive_data=enable_sensitive_data) + + +class FoundryAgent( # type: ignore[misc] + AgentMiddlewareLayer, + AgentTelemetryLayer, + RawFoundryAgent[FoundryAgentOptionsT], +): + """Microsoft Foundry Agent with full middleware and telemetry support. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + This is the recommended class for production use. + + Examples: + .. code-block:: python + + from agent_framework.foundry import FoundryAgent + from azure.identity import AzureCliCredential + + # Connect to a PromptAgent + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + tools=[my_function_tool], + ) + result = await agent.run("Hello!") + + # Connect to a HostedAgent (no version needed) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-hosted-agent", + credential=AzureCliCredential(), + ) + + # Custom client (e.g., raw client without client middleware) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-agent", + credential=AzureCliCredential(), + client_type=RawFoundryAgentChatClient, + ) + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent with full middleware and telemetry. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + agent_name: The name of the Foundry agent to connect to. + agent_version: The version of the agent (for PromptAgents). + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers. + middleware: Optional agent-level middleware. + client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments. + """ + super().__init__( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + tools=tools, + context_providers=context_providers, + middleware=middleware, + client_type=client_type, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, + ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py b/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py new file mode 100644 index 0000000000..0976d5572a --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py @@ -0,0 +1,395 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Microsoft Foundry Agent client for connecting to pre-configured agents in Foundry. + +This module provides ``RawFoundryAgentClient`` and ``FoundryAgentClient`` for +communicating with PromptAgents and HostedAgents via the Responses API. +""" + +from __future__ import annotations + +import logging +import sys +from collections.abc import Callable, Mapping, MutableMapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast + +from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._settings import load_settings +from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool +from agent_framework._types import Message +from agent_framework.observability import ChatTelemetryLayer +from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient +from azure.ai.projects.aio import AIProjectClient + +from ._entra_id_authentication import AzureCredentialTypes + +logger: logging.Logger = logging.getLogger(__name__) + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore # pragma: no cover +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +if TYPE_CHECKING: + from agent_framework import Agent, BaseContextProvider + from agent_framework._middleware import ( + ChatMiddleware, + ChatMiddlewareCallable, + FunctionMiddleware, + FunctionMiddlewareCallable, + MiddlewareTypes, + ) + from agent_framework._tools import ToolTypes + + +class FoundryAgentSettings(TypedDict, total=False): + """Settings for Microsoft FoundryAgentClient resolved from args and environment. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + agent_name: The name of the Foundry agent to connect to. + Can be set via environment variable FOUNDRY_AGENT_NAME. + agent_version: The version of the Foundry agent (for PromptAgents). + Can be set via environment variable FOUNDRY_AGENT_VERSION. + """ + + project_endpoint: str | None + agent_name: str | None + agent_version: str | None + + +FoundryAgentOptionsT = TypeVar( + "FoundryAgentOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OpenAIChatOptions", + covariant=True, +) + + +class RawFoundryAgentChatClient( # type: ignore[misc] + RawOpenAIChatClient[FoundryAgentOptionsT], + Generic[FoundryAgentOptionsT], +): + """Raw Microsoft Foundry Agent chat client for connecting to pre-configured agents in Foundry. + + Connects to existing PromptAgents or HostedAgents via the Responses API. + Does not create or delete agents — the agent must already exist in Foundry. + + This is a raw client without function invocation, chat middleware, or telemetry layers. + Tools passed in options are validated (only ``FunctionTool`` allowed) but **not invoked** — + the function invocation loop is handled by ``_FoundryAgentChatClient`` or a custom subclass + that includes ``FunctionInvocationLayer``. + + Use this class as an extension point when building a custom client with specific middleware + layers via subclassing:: + + from agent_framework._tools import FunctionInvocationLayer + from agent_framework.foundry import RawFoundryAgentChatClient + + + class MyClient(FunctionInvocationLayer, RawFoundryAgentChatClient): + pass + + + agent = FoundryAgent(..., client_type=MyClient) + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw Foundry Agent client. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + agent_name: The name of the Foundry agent to connect to. + Can also be set via environment variable FOUNDRY_AGENT_NAME. + agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). + Can also be set via environment variable FOUNDRY_AGENT_VERSION. + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments. + """ + settings = load_settings( + FoundryAgentSettings, + env_prefix="FOUNDRY_", + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + resolved_endpoint = settings.get("project_endpoint") + self.agent_name = settings.get("agent_name") + self.agent_version = settings.get("agent_version") + + if not self.agent_name: + raise ValueError( + "Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable." + ) + + # Create or use provided project client + self._should_close_client = False + if project_client is not None: + self.project_client = project_client + else: + if not resolved_endpoint: + raise ValueError( + "Either 'project_endpoint' or 'project_client' is required. " + "Set project_endpoint via parameter or 'FOUNDRY_PROJECT_ENDPOINT' environment variable." + ) + if not credential: + raise ValueError("Azure credential is required when using project_endpoint without a project_client.") + project_client_kwargs: dict[str, Any] = { + "endpoint": resolved_endpoint, + "credential": credential, + "user_agent": AGENT_FRAMEWORK_USER_AGENT, + } + if allow_preview is not None: + project_client_kwargs["allow_preview"] = allow_preview + self.project_client = AIProjectClient(**project_client_kwargs) + self._should_close_client = True + + # Get OpenAI client from project + async_client = self.project_client.get_openai_client() + + super().__init__(async_client=async_client, **kwargs) + + def _get_agent_reference(self) -> dict[str, str]: + """Build the agent reference dict for the Responses API.""" + ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item] + if self.agent_version: + ref["version"] = self.agent_version + return ref + + @override + def as_agent( + self, + *, + id: str | None = None, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + **kwargs: Any, + ) -> Agent[FoundryAgentOptionsT]: + """Create a FoundryAgent that reuses this client's Foundry configuration.""" + from ._foundry_agent import FoundryAgent + + function_tools = cast( + FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None, + tools, + ) + + return cast( + "Agent[FoundryAgentOptionsT]", + FoundryAgent( + project_client=self.project_client, + agent_name=self.agent_name, + agent_version=self.agent_version, + tools=function_tools, + context_providers=context_providers, + middleware=middleware, + client_type=cast(type[RawFoundryAgentChatClient], self.__class__), + id=id, + name=self.agent_name if name is None else name, + description=description, + instructions=instructions, + default_options=default_options, + **kwargs, + ), + ) + + @override + async def _prepare_options( + self, + messages: Sequence[Message], + options: Mapping[str, Any], + **kwargs: Any, + ) -> dict[str, Any]: + """Prepare options for the Responses API, injecting agent reference and validating tools.""" + # Validate tools — only FunctionTool allowed + tools = options.get("tools", []) + if tools: + for tool_item in tools: + if not isinstance(tool_item, FunctionTool): + raise TypeError( + f"Only FunctionTool objects are accepted for Foundry agents, " + f"got {type(tool_item).__name__}. Other tool types (MCPTool, dict schemas, " + f"hosted tools) must be defined on the Foundry agent definition in the service." + ) + + # Prepare messages: extract system/developer messages as instructions + prepared_messages, _instructions = self._prepare_messages_for_azure_ai(messages) + + # Call parent prepare_options (OpenAI Responses API format) + run_options = await super()._prepare_options(prepared_messages, options, **kwargs) + + # Apply Azure AI schema transforms + if "input" in run_options and isinstance(run_options["input"], list): + run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) + + # Inject agent reference + run_options["extra_body"] = {"agent_reference": self._get_agent_reference()} + + return run_options + + @override + def _check_model_presence(self, options: dict[str, Any]) -> None: + """Skip model check — model is configured on the Foundry agent.""" + pass + + def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: + """Extract system/developer messages as instructions for Azure AI. + + Foundry agents may not support system/developer messages directly. + Instead, extract them as instructions to prepend. + """ + prepared: list[Message] = [] + instructions_parts: list[str] = [] + for msg in messages: + if msg.role in ("system", "developer"): + if msg.text: + instructions_parts.append(msg.text) + else: + prepared.append(msg) + instructions = "\n".join(instructions_parts) if instructions_parts else None + return prepared, instructions + + def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Transform input items to match Azure AI Projects expected schema. + + Azure AI Projects 'create responses' API expects 'type' at item level + and 'annotations' for output_text content items. + """ + transformed: list[dict[str, Any]] = [] + for item in input_items: + new_item: dict[str, Any] = dict(item) + + if "role" in new_item and "type" not in new_item: + new_item["type"] = "message" + + if (content := new_item.get("content")) and isinstance(content, list): + new_content: list[Any] = [] + for content_item in content: # type: ignore[union-attr] + if isinstance(content_item, MutableMapping): + if content_item.get("type") == "output_text" and "annotations" not in content_item: # type: ignore[operator] + content_item["annotations"] = [] + new_content.append(content_item) + else: + new_content.append(content_item) + new_item["content"] = new_content + + transformed.append(new_item) + + return transformed + + async def close(self) -> None: + """Close the project client if we created it.""" + if self._should_close_client: + await self.project_client.close() + + +class _FoundryAgentChatClient( # type: ignore[misc] + FunctionInvocationLayer[FoundryAgentOptionsT], + ChatMiddlewareLayer[FoundryAgentOptionsT], + ChatTelemetryLayer[FoundryAgentOptionsT], + RawFoundryAgentChatClient[FoundryAgentOptionsT], + Generic[FoundryAgentOptionsT], +): + """Microsoft Foundry Agent client with middleware, telemetry, and function invocation support. + + Connects to existing PromptAgents or HostedAgents in Foundry. + + Examples: + .. code-block:: python + + from agent_framework import Agent + from agent_framework.foundry import FoundryAgentClient + from azure.identity import AzureCliCredential + + client = FoundryAgentClient( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + + agent = Agent(client=client, tools=[my_function_tool]) + result = await agent.run("Hello!") + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + middleware: ( + Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None + ) = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent client with full middleware support. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + agent_name: The name of the Foundry agent to connect to. + agent_version: The version of the agent (for PromptAgents). + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + middleware: Optional sequence of middleware. + function_invocation_configuration: Optional function invocation configuration. + kwargs: Additional keyword arguments. + """ + super().__init__( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + **kwargs, + ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py b/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py new file mode 100644 index 0000000000..8397f29639 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py @@ -0,0 +1,530 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +import sys +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal + +from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._settings import load_settings +from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer +from agent_framework._types import Content +from agent_framework.observability import ChatTelemetryLayer +from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import ( + AutoCodeInterpreterToolParam, + CodeInterpreterTool, + ImageGenTool, + WebSearchApproximateLocation, + WebSearchTool, + WebSearchToolFilters, +) +from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool +from azure.ai.projects.models import MCPTool as FoundryMCPTool + +from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider +from ._shared import resolve_file_ids + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore # pragma: no cover +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +if TYPE_CHECKING: + from agent_framework._middleware import ( + ChatMiddleware, + ChatMiddlewareCallable, + FunctionMiddleware, + FunctionMiddlewareCallable, + ) + +logger: logging.Logger = logging.getLogger("agent_framework.foundry") + + +class FoundrySettings(TypedDict, total=False): + """Settings for Microsoft FoundryChatClient resolved from args and environment. + + Keyword Args: + model: The model deployment name. + Can be set via environment variable FOUNDRY_MODEL. + project_endpoint: The Microsoft Foundry project endpoint URL. + Can be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + """ + + model: str | None + project_endpoint: str | None + + +FoundryChatOptionsT = TypeVar( + "FoundryChatOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OpenAIChatOptions", + covariant=True, +) + +FoundryChatOptions = OpenAIChatOptions + + +class RawFoundryChatClient( # type: ignore[misc] + RawOpenAIChatClient[FoundryChatOptionsT], + Generic[FoundryChatOptionsT], +): + """Raw Microsoft Foundry chat client using the OpenAI Responses API via a Foundry project. + + This client creates an OpenAI-compatible client from a Foundry project + and delegates to ``RawOpenAIChatClient`` for request handling. + + Environment variables: + - ``FOUNDRY_PROJECT_ENDPOINT`` to provide the Foundry project endpoint. + - ``FOUNDRY_MODEL`` to provide the Foundry model deployment name. + + Warning: + **This class should not normally be used directly.** Use ``FoundryChatClient`` + for a fully-featured client with middleware, telemetry, and function invocation. + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + project_endpoint: str | None = None, + project_client: AIProjectClient | None = None, + model: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + allow_preview: bool | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + instruction_role: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw Microsoft Foundry chat client. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + project_client: An existing AIProjectClient to use. If provided, + the OpenAI client will be obtained via ``project_client.get_openai_client()``. + model: The model deployment name. + Can also be set via environment variable FOUNDRY_MODEL. + credential: Azure credential or token provider for authentication. + Required when using ``project_endpoint`` without a ``project_client``. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + instruction_role: The role to use for 'instruction' messages. + kwargs: Additional keyword arguments. + """ + foundry_settings = load_settings( + FoundrySettings, + env_prefix="FOUNDRY_", + model=model, + project_endpoint=project_endpoint, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + resolved_model = foundry_settings.get("model") + if not resolved_model: + raise ValueError("Model is required. Set via 'model' parameter or 'FOUNDRY_MODEL' environment variable.") + + project_endpoint = foundry_settings.get("project_endpoint") + + if project_endpoint is None and project_client is None: + raise ValueError( + "Either 'project_endpoint' or 'project_client' is required. " + "Set project_endpoint via parameter or 'FOUNDRY_PROJECT_ENDPOINT' environment variable." + ) + if not project_client: + if not project_endpoint: + raise ValueError( + "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " + "or 'FOUNDRY_PROJECT_ENDPOINT' environment variable," + "or pass in a AIProjectClient." + ) + if not credential: + raise ValueError("Azure credential is required when using project_endpoint without a project_client.") + project_client_kwargs: dict[str, Any] = { + "endpoint": project_endpoint, + "credential": credential, # type: ignore[arg-type] + "user_agent": AGENT_FRAMEWORK_USER_AGENT, + } + if allow_preview is not None: + project_client_kwargs["allow_preview"] = allow_preview + project_client = AIProjectClient(**project_client_kwargs) + + super().__init__( + model=resolved_model, + async_client=project_client.get_openai_client(), + instruction_role=instruction_role, + **kwargs, + ) + self.project_client = project_client + + @override + def _check_model_presence(self, options: dict[str, Any]) -> None: + if not options.get("model"): + if not self.model: + raise ValueError("model must be a non-empty string") + options["model"] = self.model + + async def configure_azure_monitor( + self, + enable_sensitive_data: bool = False, + **kwargs: Any, + ) -> None: + """Setup observability with Azure Monitor (Microsoft Foundry integration). + + This method configures Azure Monitor for telemetry collection using the + connection string from the Foundry project client. + + Args: + enable_sensitive_data: Enable sensitive data logging (prompts, responses). + Should only be enabled in development/test environments. Default is False. + **kwargs: Additional arguments passed to configure_azure_monitor(). + Common options include: + - enable_live_metrics (bool): Enable Azure Monitor Live Metrics + - credential (TokenCredential): Azure credential for Entra ID auth + - resource (Resource): Custom OpenTelemetry resource + + Raises: + ImportError: If azure-monitor-opentelemetry-exporter is not installed. + """ + from azure.core.exceptions import ResourceNotFoundError + + try: + conn_string = await self.project_client.telemetry.get_application_insights_connection_string() + except ResourceNotFoundError: + logger.warning( + "No Application Insights connection string found for the Foundry project. " + "Please ensure Application Insights is configured in your project, " + "or call configure_otel_providers() manually with custom exporters." + ) + return + + try: + from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] + except ImportError as exc: + raise ImportError( + "azure-monitor-opentelemetry is required for Azure Monitor integration. " + "Install it with: pip install azure-monitor-opentelemetry" + ) from exc + + from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation + + if "resource" not in kwargs: + kwargs["resource"] = create_resource() + + configure_azure_monitor( + connection_string=conn_string, + views=create_metric_views(), + **kwargs, + ) + + enable_instrumentation(enable_sensitive_data=enable_sensitive_data) + + # region Tool factory methods (override OpenAI defaults with Foundry versions) + + @staticmethod + def get_code_interpreter_tool( # type: ignore[override] + *, + file_ids: list[str | Content] | None = None, + container: Literal["auto"] | dict[str, Any] = "auto", + **kwargs: Any, + ) -> CodeInterpreterTool: + """Create a code interpreter tool configuration for Foundry. + + Keyword Args: + file_ids: Optional list of file IDs or Content objects to make available. + container: Container configuration. Use "auto" for automatic management. + **kwargs: Additional arguments passed to the SDK CodeInterpreterTool constructor. + + Returns: + A CodeInterpreterTool ready to pass to an Agent. + """ + if file_ids is None and isinstance(container, dict): + file_ids = container.get("file_ids") + resolved = resolve_file_ids(file_ids) + tool_container = AutoCodeInterpreterToolParam(file_ids=resolved) + return CodeInterpreterTool(container=tool_container, **kwargs) + + @staticmethod + def get_file_search_tool( + *, + vector_store_ids: list[str], + max_num_results: int | None = None, + ranking_options: dict[str, Any] | None = None, + filters: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ProjectsFileSearchTool: + """Create a file search tool configuration for Foundry. + + Keyword Args: + vector_store_ids: List of vector store IDs to search. + max_num_results: Maximum number of results to return (1-50). + ranking_options: Ranking options for search results. + filters: A filter to apply (ComparisonFilter or CompoundFilter). + **kwargs: Additional arguments passed to the SDK FileSearchTool constructor. + + Returns: + A FileSearchTool ready to pass to an Agent. + """ + if not vector_store_ids: + raise ValueError("File search tool requires 'vector_store_ids' to be specified.") + return ProjectsFileSearchTool( + vector_store_ids=vector_store_ids, + max_num_results=max_num_results, + ranking_options=ranking_options, # type: ignore[arg-type] + filters=filters, # type: ignore[arg-type] + **kwargs, + ) + + @staticmethod + def get_web_search_tool( # type: ignore[override] + *, + user_location: dict[str, str] | None = None, + search_context_size: Literal["low", "medium", "high"] | None = None, + allowed_domains: list[str] | None = None, + custom_search_configuration: dict[str, Any] | None = None, + **kwargs: Any, + ) -> WebSearchTool: + """Create a web search tool configuration for Microsoft Foundry. + + Keyword Args: + user_location: Location context with keys like "city", "country", "region", "timezone". + search_context_size: Amount of context from search results ("low", "medium", "high"). + allowed_domains: List of domains to restrict search results to. + custom_search_configuration: Custom Bing search configuration. + **kwargs: Additional arguments passed to the SDK WebSearchTool constructor. + + Returns: + A WebSearchTool ready to pass to an Agent. + """ + ws_kwargs: dict[str, Any] = {**kwargs} + if search_context_size: + ws_kwargs["search_context_size"] = search_context_size + if allowed_domains: + ws_kwargs["filters"] = WebSearchToolFilters(allowed_domains=allowed_domains) + if custom_search_configuration: + ws_kwargs["custom_search_configuration"] = custom_search_configuration + ws_tool = WebSearchTool(**ws_kwargs) + if user_location: + ws_tool.user_location = WebSearchApproximateLocation( + city=user_location.get("city"), + country=user_location.get("country"), + region=user_location.get("region"), + timezone=user_location.get("timezone"), + ) + return ws_tool + + @staticmethod + def get_image_generation_tool( # type: ignore[override] + *, + model: Literal["gpt-image-1"] | str | None = None, + size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None, + output_format: Literal["png", "webp", "jpeg"] | None = None, + quality: Literal["low", "medium", "high", "auto"] | None = None, + background: Literal["transparent", "opaque", "auto"] | None = None, + partial_images: int | None = None, + moderation: Literal["auto", "low"] | None = None, + output_compression: int | None = None, + **kwargs: Any, + ) -> ImageGenTool: + """Create an image generation tool configuration for Foundry. + + Keyword Args: + model: The model to use for image generation. + size: Output image size. + output_format: Output image format. + quality: Output image quality. + background: Background transparency setting. + partial_images: Number of partial images to return during generation. + moderation: Moderation level. + output_compression: Compression level. + **kwargs: Additional arguments passed to the SDK ImageGenTool constructor. + + Returns: + An ImageGenTool ready to pass to an Agent. + """ + return ImageGenTool( # type: ignore[misc] + model=model, # type: ignore[arg-type] + size=size, + output_format=output_format, + quality=quality, + background=background, + partial_images=partial_images, + moderation=moderation, + output_compression=output_compression, + **kwargs, + ) + + @staticmethod + def get_mcp_tool( + *, + name: str, + url: str | None = None, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None, + allowed_tools: list[str] | None = None, + headers: dict[str, str] | None = None, + project_connection_id: str | None = None, + **kwargs: Any, + ) -> FoundryMCPTool: + """Create a hosted MCP tool configuration for Foundry. + + This configures an MCP server that runs remotely on Azure AI, not locally. + + Keyword Args: + name: A label/name for the MCP server. + url: The URL of the MCP server. Required if project_connection_id is not provided. + description: A description of what the MCP server provides. + approval_mode: Tool approval mode ("always_require", "never_require", or dict). + allowed_tools: List of allowed tool names from this MCP server. + headers: HTTP headers to include in requests to the MCP server. + project_connection_id: Foundry connection ID for managed MCP connections. + **kwargs: Additional arguments passed to the SDK MCPTool constructor. + + Returns: + An MCPTool configuration ready to pass to an Agent. + """ + mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs) + + if description: + mcp["server_description"] = description + if project_connection_id: + mcp["project_connection_id"] = project_connection_id + elif headers: + mcp["headers"] = headers + if allowed_tools: + mcp["allowed_tools"] = allowed_tools + if approval_mode: + if isinstance(approval_mode, str): + mcp["require_approval"] = "always" if approval_mode == "always_require" else "never" + else: + if always_require := approval_mode.get("always_require_approval"): + mcp["require_approval"] = {"always": {"tool_names": always_require}} + if never_require := approval_mode.get("never_require_approval"): + mcp["require_approval"] = {"never": {"tool_names": never_require}} + + return mcp + + # endregion + + +class FoundryChatClient( # type: ignore[misc] + FunctionInvocationLayer[FoundryChatOptionsT], + ChatMiddlewareLayer[FoundryChatOptionsT], + ChatTelemetryLayer[FoundryChatOptionsT], + RawFoundryChatClient[FoundryChatOptionsT], + Generic[FoundryChatOptionsT], +): + """Microsoft Foundry chat client using the OpenAI Responses API. + + Creates an OpenAI-compatible client from a Foundry project + with middleware, telemetry, and function invocation support. + + Environment variables: + - ``FOUNDRY_PROJECT_ENDPOINT`` to provide the Foundry project endpoint. + - ``FOUNDRY_MODEL`` to provide the Foundry model deployment name. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``. + project_client: An existing AIProjectClient to use. + model: The model deployment name. + Can also be set via environment variable ``FOUNDRY_MODEL``. + model_id: Deprecated alias for ``model``. + credential: Azure credential or token provider for authentication. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + instruction_role: The role to use for 'instruction' messages. + middleware: Optional sequence of middleware. + function_invocation_configuration: Optional function invocation configuration. + + Examples: + .. code-block:: python + + from azure.identity import AzureCliCredential + from agent_framework_foundry import FoundryChatClient + + client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), + ) + + # Or using an existing AIProjectClient + from azure.ai.projects.aio import AIProjectClient + + project_client = AIProjectClient( + endpoint="https://your-project.services.ai.azure.com", + credential=AzureCliCredential(), + ) + client = FoundryChatClient( + project_client=project_client, + model="gpt-4o", + ) + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + project_endpoint: str | None = None, + project_client: AIProjectClient | None = None, + model: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + allow_preview: bool | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + instruction_role: str | None = None, + middleware: ( + Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None + ) = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry chat client. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable ``FOUNDRY_PROJECT_ENDPOINT``. + project_client: An existing AIProjectClient to use. + model: The model deployment name. + Can also be set via environment variable ``FOUNDRY_MODEL``. + credential: Azure credential or token provider for authentication. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + instruction_role: The role to use for 'instruction' messages. + middleware: Optional sequence of middleware. + function_invocation_configuration: Optional function invocation configuration. + kwargs: Additional keyword arguments. + """ + super().__init__( + project_endpoint=project_endpoint, + project_client=project_client, + model=model, + credential=credential, + allow_preview=allow_preview, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + instruction_role=instruction_role, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + **kwargs, + ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_foundry_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py similarity index 94% rename from python/packages/azure-ai/agent_framework_azure_ai/_foundry_memory_provider.py rename to python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py index fe5ab47ac5..3c24e3380e 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_foundry_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py @@ -16,11 +16,11 @@ from typing import TYPE_CHECKING, Any, ClassVar from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext from agent_framework._settings import load_settings -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes from azure.ai.projects.aio import AIProjectClient from openai.types.responses import ResponseInputItemParam -from ._shared import AzureAISettings +from ._entra_id_authentication import AzureCredentialTypes +from ._shared import FoundryProjectSettings if sys.version_info >= (3, 11): from typing import Self # pragma: no cover @@ -72,7 +72,7 @@ class FoundryMemoryProvider(BaseContextProvider): Args: source_id: Unique identifier for this provider instance. project_client: Azure AI Project client for memory operations. - project_endpoint: Azure AI project endpoint URL. Used when project_client is not provided. + project_endpoint: Foundry project endpoint URL. Used when project_client is not provided. credential: Azure credential for authentication. Accepts a TokenCredential, AsyncTokenCredential, or a callable token provider. Required when project_client is not provided. @@ -86,20 +86,20 @@ class FoundryMemoryProvider(BaseContextProvider): env_file_encoding: Encoding of the environment file. """ super().__init__(source_id) - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", + foundry_settings = load_settings( + FoundryProjectSettings, + env_prefix="FOUNDRY_", project_endpoint=project_endpoint, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) if project_client is None: - resolved_endpoint = azure_ai_settings.get("project_endpoint") + resolved_endpoint = foundry_settings.get("project_endpoint") if not resolved_endpoint: raise ValueError( - "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " - "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." + "Foundry project endpoint is required. Set via 'project_endpoint' parameter " + "or 'FOUNDRY_PROJECT_ENDPOINT' environment variable." ) if not credential: raise ValueError("Azure credential is required when project_client is not provided.") diff --git a/python/packages/foundry/agent_framework_foundry/_shared.py b/python/packages/foundry/agent_framework_foundry/_shared.py new file mode 100644 index 0000000000..8eed50f067 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_shared.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +import sys +from collections.abc import Sequence + +from agent_framework import Content + +if sys.version_info >= (3, 11): + from typing import TypedDict # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +logger = logging.getLogger("agent_framework.foundry") + + +class FoundryProjectSettings(TypedDict, total=False): + """Foundry project settings loaded from FOUNDRY_ environment variables.""" + + project_endpoint: str | None + + +def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: + """Resolve file IDs from strings or hosted-file Content objects.""" + if not file_ids: + return None + + resolved: list[str] = [] + for item in file_ids: + if isinstance(item, str): + if not item: + raise ValueError("file_ids must not contain empty strings.") + resolved.append(item) + elif isinstance(item, Content): + if item.type != "hosted_file": + raise ValueError( + f"Unsupported Content type {item.type!r} for code interpreter file_ids. " + "Only Content.from_hosted_file() is supported." + ) + if item.file_id is None: + raise ValueError( + "Content.from_hosted_file() item is missing a file_id. " + "Ensure the Content object has a valid file_id before using it in file_ids." + ) + resolved.append(item.file_id) + + return resolved if resolved else None diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml new file mode 100644 index 0000000000..2cc81a5e43 --- /dev/null +++ b/python/packages/foundry/pyproject.toml @@ -0,0 +1,105 @@ +[project] +name = "agent-framework-foundry" +description = "Cloud Azure AI Foundry integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0rc5" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.0rc5", + "agent-framework-openai>=1.0.0rc5", + "azure-ai-projects>=2.0.0,<3.0", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_foundry"] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_foundry"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_foundry --cov-report=term-missing:skip-covered tests' + +[tool.poe.tasks.integration-tests] +help = "Run the package integration test suite." +cmd = """ +pytest --import-mode=importlib +-n logical --dist worksteal +tests +""" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/foundry/tests/conftest.py b/python/packages/foundry/tests/conftest.py new file mode 100644 index 0000000000..0e703e3f4e --- /dev/null +++ b/python/packages/foundry/tests/conftest.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft. All rights reserved. +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pytest import fixture + + +@fixture +def exclude_list(request: Any) -> list[str]: + """Fixture that returns a list of environment variables to exclude.""" + return request.param if hasattr(request, "param") else [] + + +@fixture +def override_env_param_dict(request: Any) -> dict[str, str]: + """Fixture that returns a dict of environment variables to override.""" + return request.param if hasattr(request, "param") else {} + + +@fixture() +def foundry_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore + """Fixture to set environment variables for Foundry settings.""" + + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + env_vars = { + "FOUNDRY_PROJECT_ENDPOINT": "https://test-project.services.ai.azure.com/", + "FOUNDRY_MODEL": "test-gpt-4o", + } + + env_vars.update(override_env_param_dict) # type: ignore + + for key, value in env_vars.items(): + if key in exclude_list: + monkeypatch.delenv(key, raising=False) # type: ignore + continue + monkeypatch.setenv(key, value) # type: ignore + + return env_vars + + +@fixture +def mock_agents_client() -> MagicMock: + """Fixture that provides a mock AgentsClient.""" + mock_client = MagicMock() + + # Mock agents property + mock_client.create_agent = AsyncMock() + mock_client.delete_agent = AsyncMock() + + # Mock agent creation response + mock_agent = MagicMock() + mock_agent.id = "test-agent-id" + mock_client.create_agent.return_value = mock_agent + + # Mock threads property + mock_client.threads = MagicMock() + mock_client.threads.create = AsyncMock() + mock_client.messages.create = AsyncMock() + + # Mock runs property + mock_client.runs = MagicMock() + mock_client.runs.list = AsyncMock() + mock_client.runs.cancel = AsyncMock() + mock_client.runs.stream = AsyncMock() + mock_client.runs.submit_tool_outputs_stream = AsyncMock() + + return mock_client + + +@fixture +def mock_azure_credential() -> MagicMock: + """Fixture that provides a mock AsyncTokenCredential.""" + return MagicMock() diff --git a/python/packages/foundry/tests/test_foundry_agent.py b/python/packages/foundry/tests/test_foundry_agent.py new file mode 100644 index 0000000000..549d922ff9 --- /dev/null +++ b/python/packages/foundry/tests/test_foundry_agent.py @@ -0,0 +1,374 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for FoundryAgentClient and FoundryAgent classes.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework._tools import tool + + +class TestRawFoundryAgentChatClient: + """Tests for RawFoundryAgentChatClient.""" + + def test_init_requires_agent_name(self) -> None: + """Test that agent_name is required.""" + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + with pytest.raises(ValueError, match="Agent name is required"): + RawFoundryAgentChatClient( + project_client=MagicMock(), + ) + + def test_init_with_agent_name(self) -> None: + """Test construction with agent_name and project_client.""" + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + assert client.agent_version == "1.0" + + def test_get_agent_reference_with_version(self) -> None: + """Test agent reference includes version when provided.""" + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="my-agent", + agent_version="2.0", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} + + def test_get_agent_reference_without_version(self) -> None: + """Test agent reference omits version for HostedAgents.""" + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "hosted-agent", "type": "agent_reference"} + assert "version" not in ref + + def test_as_agent_returns_foundry_agent_and_preserves_client_type(self) -> None: + """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" + from agent_framework_foundry._foundry_agent import FoundryAgent + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + class CustomClient(RawFoundryAgentChatClient): + pass + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = CustomClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + agent = client.as_agent(instructions="You are helpful.") + + assert isinstance(agent, FoundryAgent) + assert agent.name == "test-agent" + assert isinstance(agent.client, CustomClient) + assert agent.client.project_client is mock_project + assert agent.client.agent_name == "test-agent" + assert agent.client.agent_version == "1.0" + + named_agent = client.as_agent(name="display-name", instructions="You are helpful.") + assert named_agent.name == "display-name" + assert named_agent.client.agent_name == "test-agent" + + async def test_prepare_options_validates_tools(self) -> None: + """Test that _prepare_options rejects non-FunctionTool objects.""" + from agent_framework import Message + + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + # A dict tool should be rejected + with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"): + await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [{"type": "function", "function": {"name": "bad"}}]}, + ) + + async def test_prepare_options_accepts_function_tools(self) -> None: + """Test that _prepare_options accepts FunctionTool objects.""" + from agent_framework import Message + + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + return "ok" + + # Should not raise — patch the parent's _prepare_options + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={}, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [my_func]}, + ) + assert "extra_body" in result + assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + + def test_check_model_presence_is_noop(self) -> None: + """Test that _check_model_presence does nothing (model is on service).""" + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + options: dict[str, Any] = {} + client._check_model_presence(options) + assert "model" not in options + + +class TestFoundryAgentChatClient: + """Tests for _FoundryAgentChatClient (full middleware).""" + + def test_init(self) -> None: + """Test construction of the full-middleware client.""" + from agent_framework_foundry._foundry_agent_client import _FoundryAgentChatClient + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = _FoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + + +class TestRawFoundryAgent: + """Tests for RawFoundryAgent.""" + + def test_init_creates_client(self) -> None: + """Test that RawFoundryAgent creates a client internally.""" + from agent_framework_foundry._foundry_agent import RawFoundryAgent + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + def test_init_with_custom_client_type(self) -> None: + """Test that client_type parameter is respected.""" + from agent_framework_foundry._foundry_agent import RawFoundryAgent + from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + client_type=RawFoundryAgentChatClient, + ) + + assert isinstance(agent.client, RawFoundryAgentChatClient) + + def test_init_rejects_invalid_client_type(self) -> None: + """Test that invalid client_type raises TypeError.""" + from agent_framework_foundry._foundry_agent import RawFoundryAgent + + with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"): + RawFoundryAgent( + project_client=MagicMock(), + agent_name="test-agent", + client_type=object, # type: ignore[arg-type] + ) + + def test_init_with_function_tools(self) -> None: + """Test that FunctionTool and callables are accepted.""" + from agent_framework_foundry._foundry_agent import RawFoundryAgent + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + return "ok" + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + tools=[my_func], + ) + + assert agent.default_options.get("tools") is not None + + +class TestFoundryAgent: + """Tests for FoundryAgent (full middleware).""" + + def test_init(self) -> None: + """Test construction of the full-middleware agent.""" + from agent_framework_foundry._foundry_agent import FoundryAgent + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + def test_init_with_middleware(self) -> None: + """Test that agent-level middleware is accepted.""" + from agent_framework import ChatContext, ChatMiddleware + + from agent_framework_foundry._foundry_agent import FoundryAgent + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + class MyMiddleware(ChatMiddleware): + async def process(self, context: ChatContext) -> None: + pass + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + middleware=[MyMiddleware()], + ) + + assert agent.client is not None + + +class TestFoundryChatClientToolMethods: + """Tests for RawFoundryChatClient tool factory methods.""" + + def test_get_code_interpreter_tool(self) -> None: + """Test code interpreter tool creation.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool() + assert tool_obj is not None + + def test_get_code_interpreter_tool_with_file_ids(self) -> None: + """Test code interpreter tool with file IDs.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) + assert tool_obj is not None + + def test_get_file_search_tool(self) -> None: + """Test file search tool creation.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_file_search_tool(vector_store_ids=["vs_abc123"]) + assert tool_obj is not None + + def test_get_file_search_tool_requires_vector_store_ids(self) -> None: + """Test that empty vector_store_ids raises ValueError.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + with pytest.raises(ValueError, match="vector_store_ids"): + RawFoundryChatClient.get_file_search_tool(vector_store_ids=[]) + + def test_get_web_search_tool(self) -> None: + """Test web search tool creation.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_web_search_tool() + assert tool_obj is not None + + def test_get_web_search_tool_with_location(self) -> None: + """Test web search tool with user location.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + search_context_size="high", + ) + assert tool_obj is not None + + def test_get_image_generation_tool(self) -> None: + """Test image generation tool creation.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_image_generation_tool() + assert tool_obj is not None + + def test_get_mcp_tool(self) -> None: + """Test MCP tool creation.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="my_mcp", + url="https://mcp.example.com", + ) + assert tool_obj is not None + + def test_get_mcp_tool_with_connection_id(self) -> None: + """Test MCP tool with project connection ID.""" + from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="github_mcp", + project_connection_id="conn_abc123", + description="GitHub MCP via Foundry", + ) + assert tool_obj is not None diff --git a/python/packages/azure-ai/tests/test_foundry_memory_provider.py b/python/packages/foundry/tests/test_foundry_memory_provider.py similarity index 98% rename from python/packages/azure-ai/tests/test_foundry_memory_provider.py rename to python/packages/foundry/tests/test_foundry_memory_provider.py index 9788ee25e8..f7e02f8a89 100644 --- a/python/packages/azure-ai/tests/test_foundry_memory_provider.py +++ b/python/packages/foundry/tests/test_foundry_memory_provider.py @@ -10,7 +10,7 @@ import pytest from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message from agent_framework._sessions import AgentSession, SessionContext -from agent_framework_azure_ai._foundry_memory_provider import FoundryMemoryProvider +from agent_framework_foundry._foundry_memory_provider import FoundryMemoryProvider @pytest.fixture @@ -81,7 +81,7 @@ class TestInit: def test_init_with_project_endpoint_and_credential( self, mock_project_client: AsyncMock, mock_credential: Mock ) -> None: - with patch("agent_framework_azure_ai._foundry_memory_provider.AIProjectClient") as mock_ai_project_client: + with patch("agent_framework_foundry._foundry_memory_provider.AIProjectClient") as mock_ai_project_client: mock_ai_project_client.return_value = mock_project_client provider = FoundryMemoryProvider( project_endpoint="https://test.project.endpoint", @@ -100,7 +100,7 @@ class TestInit: def test_init_requires_project_endpoint_without_project_client(self) -> None: with ( - patch("agent_framework_azure_ai._foundry_memory_provider.load_settings") as mock_load_settings, + patch("agent_framework_foundry._foundry_memory_provider.load_settings") as mock_load_settings, patch.dict(os.environ, {}, clear=True), pytest.raises(ValueError, match="project endpoint is required"), ): diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index a4be2f4ff7..5957b51565 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -11,7 +11,7 @@ Integration with Azure AI Foundry Local for local model inference. ## Usage ```python -from agent_framework_foundry_local import FoundryLocalClient +from agent_framework.foundry import FoundryLocalClient client = FoundryLocalClient(model_id="your-local-model") response = await client.get_response("Hello") @@ -20,5 +20,5 @@ response = await client.get_response("Hello") ## Import Path ```python -from agent_framework_foundry_local import FoundryLocalClient +from agent_framework.foundry import FoundryLocalClient ``` diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index cf4f340bd3..269219fac2 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -10,4 +10,4 @@ and see the [README](https://github.com/microsoft/agent-framework/tree/main/pyth ## Foundry Local Sample -See the [Foundry Local provider sample](../../samples/02-agents/providers/foundry_local/foundry_local_agent.py) for a runnable example. +See the [Foundry Local provider sample](../../samples/02-agents/providers/foundry/foundry_local_agent.py) for a runnable example. 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..1cb16fc40c 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 @@ -15,7 +15,7 @@ from agent_framework import ( ) from agent_framework._settings import load_settings from agent_framework.observability import ChatTelemetryLayer -from agent_framework.openai._chat_client import RawOpenAIChatClient +from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient from foundry_local import FoundryLocalManager from foundry_local.models import DeviceType from openai import AsyncOpenAI @@ -126,21 +126,21 @@ class FoundryLocalSettings(TypedDict, total=False): (Env var FOUNDRY_LOCAL_MODEL_ID) """ - model_id: str | None + model: str | None class FoundryLocalClient( - ChatMiddlewareLayer[FoundryLocalChatOptionsT], FunctionInvocationLayer[FoundryLocalChatOptionsT], + ChatMiddlewareLayer[FoundryLocalChatOptionsT], ChatTelemetryLayer[FoundryLocalChatOptionsT], - RawOpenAIChatClient[FoundryLocalChatOptionsT], + RawOpenAIChatCompletionClient[FoundryLocalChatOptionsT], Generic[FoundryLocalChatOptionsT], ): """Foundry Local Chat completion class with middleware, telemetry, and function invocation support.""" def __init__( self, - model_id: str | None = None, + model: str | None = None, *, bootstrap: bool = True, timeout: float | None = None, @@ -155,7 +155,7 @@ class FoundryLocalClient( """Initialize a FoundryLocalClient. Keyword Args: - model_id: The Foundry Local model ID or alias to use. If not provided, + model: The Foundry Local model ID or alias to use. If not provided, it will be loaded from the FoundryLocalSettings. bootstrap: Whether to start the Foundry Local service if not already running. Default is True. @@ -180,7 +180,7 @@ class FoundryLocalClient( .. code-block:: python # Create a FoundryLocalClient with a specific model ID: - from agent_framework_foundry_local import FoundryLocalClient + from agent_framework.foundry import FoundryLocalClient client = FoundryLocalClient(model_id="phi-4-mini") @@ -225,7 +225,7 @@ class FoundryLocalClient( # Using custom ChatOptions with type safety: from typing import TypedDict - from agent_framework_foundry_local import FoundryLocalChatOptions + from agent_framework.foundry import FoundryLocalChatOptions class MyOptions(FoundryLocalChatOptions, total=False): my_custom_option: str @@ -242,25 +242,23 @@ class FoundryLocalClient( settings = load_settings( FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", - required_fields=["model_id"], - model_id=model_id, + required_fields=["model"], + model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - model_id_setting: str = settings["model_id"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] + model_setting: str = settings["model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout) model_info = manager.get_model_info( - alias_or_model_id=model_id_setting, + alias_or_model_id=model_setting, device=device, ) if model_info is None: message = ( - f"Model with ID or alias '{model_id_setting}:{device.value}' not found in Foundry Local." + f"Model with ID or alias '{model_setting}:{device.value}' not found in Foundry Local." if device - else ( - f"Model with ID or alias '{model_id_setting}' for your current device not found in Foundry Local." - ) + else (f"Model with ID or alias '{model_setting}' for your current device not found in Foundry Local.") ) raise ValueError(message) if prepare_model: @@ -268,8 +266,8 @@ class FoundryLocalClient( manager.load_model(alias_or_model_id=model_info.id, device=device) super().__init__( - model_id=model_info.id, - client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key), + model=model_info.id, + async_client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key), additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 6e21997c97..0137a1e491 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,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", + "agent-framework-openai>=1.0.0rc5", "foundry-local-sdk>=0.5.1,<0.5.2", ] @@ -84,9 +85,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_local" -test = 'pytest -m "not integration" --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_local" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/packages/foundry_local/tests/conftest.py b/python/packages/foundry_local/tests/conftest.py index 0afc223356..cd4356d0b5 100644 --- a/python/packages/foundry_local/tests/conftest.py +++ b/python/packages/foundry_local/tests/conftest.py @@ -27,7 +27,7 @@ def foundry_local_unit_test_env(monkeypatch: Any, exclude_list: list[str], overr override_env_param_dict = {} env_vars = { - "FOUNDRY_LOCAL_MODEL_ID": "test-model-id", + "FOUNDRY_LOCAL_MODEL": "test-model-id", } env_vars.update(override_env_param_dict) diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index ad2bf89c81..c5b4447b28 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -6,8 +6,8 @@ import pytest from agent_framework import SupportsChatGetResponse from agent_framework._settings import load_settings from agent_framework.exceptions import SettingNotFoundError +from agent_framework.foundry import FoundryLocalClient -from agent_framework_foundry_local import FoundryLocalClient from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings # Settings Tests @@ -17,7 +17,7 @@ def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[ """Test FoundryLocalSettings initialization from environment variables.""" settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_") - assert settings["model_id"] == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] + assert settings["model"] == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] def test_foundry_local_settings_init_with_explicit_values() -> None: @@ -25,29 +25,29 @@ def test_foundry_local_settings_init_with_explicit_values() -> None: settings = load_settings( FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", - model_id="custom-model-id", + model="custom-model-id", ) - assert settings["model_id"] == "custom-model-id" + assert settings["model"] == "custom-model-id" -@pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL_ID"]], indirect=True) -def test_foundry_local_settings_missing_model_id(foundry_local_unit_test_env: dict[str, str]) -> None: +@pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL"]], indirect=True) +def test_foundry_local_settings_missing_model(foundry_local_unit_test_env: dict[str, str]) -> None: """Test FoundryLocalSettings when model_id is missing raises error.""" - with pytest.raises(SettingNotFoundError, match="Required setting 'model_id'"): + with pytest.raises(SettingNotFoundError, match="Required setting 'model'"): load_settings( FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", - required_fields=["model_id"], + required_fields=["model"], ) def test_foundry_local_settings_explicit_overrides_env(foundry_local_unit_test_env: dict[str, str]) -> None: """Test that explicit values override environment variables.""" - settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model_id="override-model-id") + settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model="override-model-id") - assert settings["model_id"] == "override-model-id" - assert settings["model_id"] != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] + assert settings["model"] == "override-model-id" + assert settings["model"] != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] # Client Initialization Tests @@ -59,9 +59,9 @@ def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> Non "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - client = FoundryLocalClient(model_id="test-model-id") + client = FoundryLocalClient(model="test-model-id") - assert client.model_id == "test-model-id" + assert client.model == "test-model-id" assert client.manager is mock_foundry_local_manager assert isinstance(client, SupportsChatGetResponse) @@ -72,7 +72,7 @@ def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manag "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ) as mock_manager_class: - FoundryLocalClient(model_id="test-model-id", bootstrap=False) + FoundryLocalClient(model="test-model-id", bootstrap=False) mock_manager_class.assert_called_once_with( bootstrap=False, @@ -86,7 +86,7 @@ def test_foundry_local_client_init_with_timeout(mock_foundry_local_manager: Magi "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ) as mock_manager_class: - FoundryLocalClient(model_id="test-model-id", timeout=60.0) + FoundryLocalClient(model="test-model-id", timeout=60.0) mock_manager_class.assert_called_once_with( bootstrap=True, @@ -105,7 +105,7 @@ def test_foundry_local_client_init_model_not_found(mock_foundry_local_manager: M ), pytest.raises(ValueError, match="not found in Foundry Local"), ): - FoundryLocalClient(model_id="unknown-model") + FoundryLocalClient(model="unknown-model") def test_foundry_local_client_uses_model_info_id(mock_foundry_local_manager: MagicMock) -> None: @@ -118,9 +118,9 @@ def test_foundry_local_client_uses_model_info_id(mock_foundry_local_manager: Mag "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - client = FoundryLocalClient(model_id="model-alias") + client = FoundryLocalClient(model="model-alias") - assert client.model_id == "resolved-model-id" + assert client.model == "resolved-model-id" def test_foundry_local_client_init_from_env( @@ -133,7 +133,7 @@ def test_foundry_local_client_init_from_env( ): client = FoundryLocalClient() - assert client.model_id == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] + assert client.model == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] def test_foundry_local_client_init_with_device(mock_foundry_local_manager: MagicMock) -> None: @@ -144,7 +144,7 @@ def test_foundry_local_client_init_with_device(mock_foundry_local_manager: Magic "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - FoundryLocalClient(model_id="test-model-id", device=DeviceType.CPU) + FoundryLocalClient(model="test-model-id", device=DeviceType.CPU) mock_foundry_local_manager.get_model_info.assert_called_once_with( alias_or_model_id="test-model-id", @@ -173,7 +173,7 @@ def test_foundry_local_client_init_model_not_found_with_device(mock_foundry_loca ), pytest.raises(ValueError, match="unknown-model:GPU.*not found"), ): - FoundryLocalClient(model_id="unknown-model", device=DeviceType.GPU) + FoundryLocalClient(model="unknown-model", device=DeviceType.GPU) def test_foundry_local_client_init_with_prepare_model_false(mock_foundry_local_manager: MagicMock) -> None: @@ -182,7 +182,7 @@ def test_foundry_local_client_init_with_prepare_model_false(mock_foundry_local_m "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - FoundryLocalClient(model_id="test-model-id", prepare_model=False) + FoundryLocalClient(model="test-model-id", prepare_model=False) mock_foundry_local_manager.download_model.assert_not_called() mock_foundry_local_manager.load_model.assert_not_called() @@ -194,7 +194,7 @@ def test_foundry_local_client_init_calls_download_and_load(mock_foundry_local_ma "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - FoundryLocalClient(model_id="test-model-id") + FoundryLocalClient(model="test-model-id") mock_foundry_local_manager.download_model.assert_called_once_with( alias_or_model_id="test-model-id", 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/pyproject.toml b/python/packages/github_copilot/pyproject.toml index abf4a5680d..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'", ] @@ -85,14 +85,17 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -test = "pytest -m \"not integration\" --cov=agent_framework_github_copilot --cov-report=term-missing:skip-covered tests" +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = "pytest -m \"not integration\" --cov=agent_framework_github_copilot --cov-report=term-missing:skip-covered tests" [tool.poe.tasks.pyright] +help = "Run Pyright for this package, skipping automatically on unsupported Python versions." shell = "python -c \"import sys; exit(0 if sys.version_info < (3,11) else 1)\" || pyright" interpreter = "posix" [tool.poe.tasks.mypy] +help = "Run MyPy for this package, skipping automatically on unsupported Python versions." shell = "python -c \"import sys; exit(0 if sys.version_info < (3,11) else 1)\" || mypy --config-file $POE_ROOT/pyproject.toml agent_framework_github_copilot" interpreter = "posix" 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.""" diff --git a/python/packages/lab/README.md b/python/packages/lab/README.md index a46893c2d1..f505b4527f 100644 --- a/python/packages/lab/README.md +++ b/python/packages/lab/README.md @@ -71,10 +71,10 @@ uv run --directory packages/lab poe test uv run --directory packages/lab pytest -q -m "not integration" ``` -When you need to run package tasks from the repository root, use sequential mode to avoid launching all package tests in parallel: +When you need to run lab tests from the repository root, scope the root task to the lab package: ```bash -uv run poe test --seq +uv run poe test -P lab ``` Lightning observability tests intentionally exercise heavier tracing paths and are marked as `resource_intensive`: 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/lab/lightning/samples/train_math_agent.py b/python/packages/lab/lightning/samples/train_math_agent.py index f702b5a631..98e79484bc 100644 --- a/python/packages/lab/lightning/samples/train_math_agent.py +++ b/python/packages/lab/lightning/samples/train_math_agent.py @@ -140,8 +140,9 @@ def evaluate(result: AgentResponse, ground_truth: str) -> float: AGENT_INSTRUCTION = """ Solve the following math problem. Use the calculator tool to help you calculate math expressions. -Output the answer when you are ready. The answer should be after three sharps (`###`), with no extra punctuations or texts. For example: ### 123 -""".strip() # noqa: E501 +Output the answer when you are ready. The answer should be after three sharps (`###`), +with no extra punctuations or texts. For example: ### 123 +""".strip() # The @rollout decorator is the key integration point with agent-lightning. diff --git a/python/packages/lab/lightning/tests/test_lightning.py b/python/packages/lab/lightning/tests/test_lightning.py index d4f6d20adf..8f1602b566 100644 --- a/python/packages/lab/lightning/tests/test_lightning.py +++ b/python/packages/lab/lightning/tests/test_lightning.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, patch import pytest from agent_framework import AgentExecutor, AgentResponse, Agent, WorkflowBuilder, Workflow -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat.chat_completion import Choice @@ -54,11 +54,11 @@ def workflow_two_agents(): "os.environ", { "OPENAI_API_KEY": "test-key", - "OPENAI_CHAT_MODEL_ID": "gpt-4o", + "OPENAI_MODEL": "gpt-4o", }, ): - first_chat_client = OpenAIChatClient() - second_chat_client = OpenAIChatClient() + first_chat_client = OpenAIChatCompletionClient() + second_chat_client = OpenAIChatCompletionClient() # Mock the OpenAI API calls with ( diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 170b1d4b78..45a4ae374f 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,17 +22,18 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", ] [project.optional-dependencies] # GAIA benchmark module dependencies -gaia = [ - "pydantic>=2.0.0", - "opentelemetry-api>=1.39.0", - "tqdm>=4.60.0", - "huggingface-hub>=0.20.0", - "orjson>=3.10.7,<4", + gaia = [ + "pydantic>=2.0.0", + "opentelemetry-api>=1.39.0", + "opentelemetry-sdk>=1.39.0,<2", + "tqdm>=4.60.0", + "huggingface-hub>=0.20.0", + "orjson>=3.10.7,<4", "pyarrow>=18.0.0", # For reading parquet files ] @@ -146,17 +147,45 @@ exclude_dirs = ["gaia/tests", "lightning/tests", "tau2/tests"] [tool.poe] include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy-gaia = "mypy --config-file $POE_ROOT/pyproject.toml gaia/agent_framework_lab_gaia" -mypy-lightning = "mypy --config-file $POE_ROOT/pyproject.toml lightning/agent_framework_lab_lightning" -mypy-tau2 = "mypy --config-file $POE_ROOT/pyproject.toml tau2/agent_framework_lab_tau2" -mypy = ["mypy-gaia", "mypy-lightning", "mypy-tau2"] -test = 'pytest -m "not integration and not resource_intensive" --cov-report=term-missing:skip-covered --junitxml=test-results.xml' -test-gaia = "pytest gaia/tests --cov=agent_framework_lab_gaia --cov-report=term-missing:skip-covered" -test-lightning = "pytest lightning/tests --cov=agent_framework_lab_lightning --cov-report=term-missing:skip-covered" -test-tau2 = "pytest tau2/tests --cov=agent_framework_lab_tau2 --cov-report=term-missing:skip-covered" -build = "echo 'Skipping build'" -publish = "echo 'Skipping publish'" +[tool.poe.tasks.mypy-gaia] +help = "Run MyPy for the lab GAIA package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml gaia/agent_framework_lab_gaia" + +[tool.poe.tasks.mypy-lightning] +help = "Run MyPy for the lab Lightning package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml lightning/agent_framework_lab_lightning" + +[tool.poe.tasks.mypy-tau2] +help = "Run MyPy for the lab Tau2 package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml tau2/agent_framework_lab_tau2" + +[tool.poe.tasks.mypy] +help = "Run MyPy across all lab subpackages." +sequence = ["mypy-gaia", "mypy-lightning", "mypy-tau2"] + +[tool.poe.tasks.test] +help = "Run the default lab unit test suite." +cmd = 'pytest -m "not integration and not resource_intensive" --cov-report=term-missing:skip-covered --junitxml=test-results.xml' + +[tool.poe.tasks.test-gaia] +help = "Run the GAIA lab test suite." +cmd = "pytest gaia/tests --cov=agent_framework_lab_gaia --cov-report=term-missing:skip-covered" + +[tool.poe.tasks.test-lightning] +help = "Run the Lightning lab test suite." +cmd = "pytest lightning/tests --cov=agent_framework_lab_lightning --cov-report=term-missing:skip-covered" + +[tool.poe.tasks.test-tau2] +help = "Run the Tau2 lab test suite." +cmd = "pytest tau2/tests --cov=agent_framework_lab_tau2 --cov-report=term-missing:skip-covered" + +[tool.poe.tasks.build] +help = "Skip build for the lab package." +cmd = "echo 'Skipping build'" + +[tool.poe.tasks.publish] +help = "Skip publish for the lab package." +cmd = "echo 'Skipping publish'" [tool.pytest.ini_options] pythonpath = ["."] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index ea697ca046..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", ] @@ -85,9 +85,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_mem0" -test = 'pytest -m "not integration" --cov=agent_framework_mem0 --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_mem0" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_mem0 --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] 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/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index 52259f297a..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", ] @@ -88,9 +88,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ollama" -test = 'pytest -m "not integration" --cov=agent_framework_ollama --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ollama" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_ollama --cov-report=term-missing:skip-covered tests' [tool.uv.build-backend] module-name = "agent_framework_ollama" diff --git a/python/packages/openai/AGENTS.md b/python/packages/openai/AGENTS.md new file mode 100644 index 0000000000..d31506cf5d --- /dev/null +++ b/python/packages/openai/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md — agent-framework-openai + +OpenAI integration package for Agent Framework. Contains OpenAI Responses API and Chat Completions API clients. + +## Package Structure + +``` +agent_framework_openai/ +├── __init__.py # Public API exports +├── _chat_client.py # OpenAIChatClient (Responses API) + RawOpenAIChatClient +├── _chat_completion_client.py # OpenAIChatCompletionClient (Chat Completions API) + RawOpenAIChatCompletionClient +├── _embedding_client.py # OpenAIEmbeddingClient +├── _exceptions.py # OpenAI-specific exceptions +├── _shared.py # OpenAIBase, OpenAIConfigMixin, OpenAISettings +├── _assistants_client.py # OpenAIAssistantsClient (DEPRECATED) +└── _assistant_provider.py # OpenAIAssistantProvider (DEPRECATED) +``` + +## Key Classes + +| Class | API | Status | +|---|---|---| +| `OpenAIChatClient` | Responses API | Primary | +| `OpenAIChatCompletionClient` | Chat Completions API | Primary | +| `OpenAIEmbeddingClient` | Embeddings API | Primary | +| `OpenAIAssistantsClient` | Assistants API | Deprecated | + +All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`). + +## Dependencies + +- `agent-framework-core` — core abstractions +- `openai` — OpenAI Python SDK +- `packaging` — version checking diff --git a/python/packages/openai/LICENSE b/python/packages/openai/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/openai/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md new file mode 100644 index 0000000000..6ed4d20c03 --- /dev/null +++ b/python/packages/openai/README.md @@ -0,0 +1,17 @@ +# agent-framework-openai + +OpenAI integration for Microsoft Agent Framework. Provides chat clients for the OpenAI Responses API and Chat Completions API. + +## Installation + +```bash +pip install agent-framework-openai +``` + +## Usage + +```python +from agent_framework.openai import OpenAIChatClient + +client = OpenAIChatClient(model_id="gpt-4o") +``` diff --git a/python/packages/openai/agent_framework_openai/__init__.py b/python/packages/openai/agent_framework_openai/__init__.py new file mode 100644 index 0000000000..855dfb5f7a --- /dev/null +++ b/python/packages/openai/agent_framework_openai/__init__.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""OpenAI integration for Microsoft Agent Framework. + +This package provides OpenAI client implementations for the Agent Framework, +including clients for the Responses API and Chat Completions API. +""" + +import importlib.metadata +import sys + +if sys.version_info >= (3, 13): + from warnings import deprecated # type: ignore # pragma: no cover +else: + from typing_extensions import deprecated # type: ignore # pragma: no cover + +from ._assistant_provider import OpenAIAssistantProvider +from ._assistants_client import ( + AssistantToolResources, + OpenAIAssistantsClient, + OpenAIAssistantsOptions, +) +from ._chat_client import ( + OpenAIChatClient, + OpenAIChatOptions, + OpenAIContinuationToken, + RawOpenAIChatClient, +) +from ._chat_completion_client import ( + OpenAIChatCompletionClient, + OpenAIChatCompletionOptions, + RawOpenAIChatCompletionClient, +) +from ._embedding_client import OpenAIEmbeddingClient, OpenAIEmbeddingOptions +from ._exceptions import ContentFilterResultSeverity, OpenAIContentFilterException +from ._shared import OpenAISettings + +try: + __version__ = importlib.metadata.version("agent-framework-openai") +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +# Deprecated aliases for old names — use subclasses so the warning only fires for the alias + + +@deprecated( + "OpenAIResponsesClient is deprecated, use OpenAIChatClient instead.", + category=DeprecationWarning, +) +class OpenAIResponsesClient(OpenAIChatClient): # type: ignore[misc] + """Deprecated alias for :class:`OpenAIChatClient`.""" + + +@deprecated( + "RawOpenAIResponsesClient is deprecated, use RawOpenAIChatClient instead.", + category=DeprecationWarning, +) +class RawOpenAIResponsesClient(RawOpenAIChatClient): # type: ignore[misc] + """Deprecated alias for :class:`RawOpenAIChatClient`.""" + + +OpenAIResponsesOptions = OpenAIChatOptions +"""Deprecated alias for :class:`OpenAIChatOptions`.""" + + +__all__ = [ + "AssistantToolResources", + "ContentFilterResultSeverity", + "OpenAIAssistantProvider", + "OpenAIAssistantsClient", + "OpenAIAssistantsOptions", + "OpenAIChatClient", + "OpenAIChatCompletionClient", + "OpenAIChatCompletionOptions", + "OpenAIChatOptions", + "OpenAIContentFilterException", + "OpenAIContinuationToken", + "OpenAIEmbeddingClient", + "OpenAIEmbeddingOptions", + "OpenAIResponsesClient", + "OpenAIResponsesOptions", + "OpenAISettings", + "RawOpenAIChatClient", + "RawOpenAIChatCompletionClient", + "RawOpenAIResponsesClient", + "__version__", +] diff --git a/python/packages/core/agent_framework/openai/_assistant_provider.py b/python/packages/openai/agent_framework_openai/_assistant_provider.py similarity index 98% rename from python/packages/core/agent_framework/openai/_assistant_provider.py rename to python/packages/openai/agent_framework_openai/_assistant_provider.py index 9746725128..f0b88e1761 100644 --- a/python/packages/core/agent_framework/openai/_assistant_provider.py +++ b/python/packages/openai/agent_framework_openai/_assistant_provider.py @@ -6,16 +6,15 @@ import sys from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from typing import TYPE_CHECKING, Any, Generic, cast +from agent_framework._agents import Agent +from agent_framework._middleware import MiddlewareTypes +from agent_framework._sessions import BaseContextProvider +from agent_framework._settings import SecretString, load_settings +from agent_framework._tools import FunctionTool, ToolTypes, normalize_tools from openai import AsyncOpenAI from openai.types.beta.assistant import Assistant from pydantic import BaseModel -from agent_framework._settings import SecretString, load_settings - -from .._agents import Agent -from .._middleware import MiddlewareTypes -from .._sessions import BaseContextProvider -from .._tools import FunctionTool, ToolTypes, normalize_tools from ._assistants_client import OpenAIAssistantsClient from ._shared import OpenAISettings, from_assistant_tools, to_assistant_tools @@ -540,7 +539,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]): """ # Create the chat client with the assistant client = OpenAIAssistantsClient( - model_id=assistant.model, + model=assistant.model, assistant_id=assistant.id, assistant_name=assistant.name, assistant_description=assistant.description, diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/openai/agent_framework_openai/_assistants_client.py similarity index 96% rename from python/packages/core/agent_framework/openai/_assistants_client.py rename to python/packages/openai/agent_framework_openai/_assistants_client.py index b1d5e8795c..f5755d8640 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/openai/agent_framework_openai/_assistants_client.py @@ -15,6 +15,27 @@ from collections.abc import ( ) from typing import TYPE_CHECKING, Any, Generic, Literal, TypedDict, cast +from agent_framework._clients import BaseChatClient +from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._settings import load_settings +from agent_framework._tools import ( + FunctionInvocationConfiguration, + FunctionInvocationLayer, + FunctionTool, + normalize_tools, +) +from agent_framework._types import ( + Annotation, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + ResponseStream, + TextSpanRegion, + UsageDetails, +) +from agent_framework.observability import ChatTelemetryLayer from openai import AsyncOpenAI from openai.types.beta.threads import ( FileCitationAnnotation, @@ -37,27 +58,6 @@ from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput from openai.types.beta.threads.runs import RunStep from pydantic import BaseModel -from .._clients import BaseChatClient -from .._middleware import ChatMiddlewareLayer -from .._settings import load_settings -from .._tools import ( - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - normalize_tools, -) -from .._types import ( - Annotation, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - ResponseStream, - TextSpanRegion, - UsageDetails, -) -from ..observability import ChatTelemetryLayer from ._shared import OpenAIConfigMixin, OpenAISettings if sys.version_info >= (3, 13): @@ -76,7 +76,7 @@ else: from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from .._middleware import MiddlewareTypes + from agent_framework._middleware import MiddlewareTypes logger = logging.getLogger("agent_framework.openai") @@ -123,7 +123,7 @@ class OpenAIAssistantsOptions(ChatOptions[ResponseModelT], Generic[ResponseModel Keys: # Inherited from ChatOptions: - model_id: The model to use for the assistant, + model_id: Deprecated. The model to use for the assistant, translates to ``model`` in OpenAI API. temperature: Sampling temperature between 0 and 2. top_p: Nucleus sampling parameter. @@ -191,7 +191,7 @@ class OpenAIAssistantsOptions(ChatOptions[ResponseModelT], Generic[ResponseModel ASSISTANTS_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", + "model_id": "model", # backward compat: accept model_id in options "max_tokens": "max_completion_tokens", "allow_multiple_tool_calls": "parallel_tool_calls", } @@ -210,8 +210,8 @@ OpenAIAssistantsOptionsT = TypeVar( class OpenAIAssistantsClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[OpenAIAssistantsOptionsT], FunctionInvocationLayer[OpenAIAssistantsOptionsT], + ChatMiddlewareLayer[OpenAIAssistantsOptionsT], ChatTelemetryLayer[OpenAIAssistantsOptionsT], BaseChatClient[OpenAIAssistantsOptionsT], Generic[OpenAIAssistantsOptionsT], @@ -277,6 +277,7 @@ class OpenAIAssistantsClient( # type: ignore[misc] def __init__( self, *, + model: str | None = None, model_id: str | None = None, assistant_id: str | None = None, assistant_name: str | None = None, @@ -296,8 +297,9 @@ class OpenAIAssistantsClient( # type: ignore[misc] """Initialize an OpenAI Assistants client. Keyword Args: - model_id: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_CHAT_MODEL_ID. + model: OpenAI model name, see https://platform.openai.com/docs/models. + Can also be set via environment variable OPENAI_MODEL. + model_id: Deprecated alias for ``model``. assistant_id: The ID of an OpenAI assistant to use. If not provided, a new assistant will be created (and deleted after the request). assistant_name: The name to use when creating new assistants. @@ -328,11 +330,11 @@ class OpenAIAssistantsClient( # type: ignore[misc] # Using environment variables # Set OPENAI_API_KEY=sk-... - # Set OPENAI_CHAT_MODEL_ID=gpt-4 + # Set OPENAI_MODEL=gpt-4 client = OpenAIAssistantsClient() # Or passing parameters directly - client = OpenAIAssistantsClient(model_id="gpt-4", api_key="sk-...") + client = OpenAIAssistantsClient(model="gpt-4", api_key="sk-...") # Or loading from a .env file client = OpenAIAssistantsClient(env_file_path="path/to/.env") @@ -346,16 +348,21 @@ class OpenAIAssistantsClient( # type: ignore[misc] my_custom_option: str - client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model_id="gpt-4") + client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model="gpt-4") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ + if model_id is not None and model is None: + import warnings + + warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) + model = model_id openai_settings = load_settings( OpenAISettings, env_prefix="OPENAI_", api_key=api_key, base_url=base_url, org_id=org_id, - chat_model_id=model_id, + model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) @@ -366,15 +373,14 @@ class OpenAIAssistantsClient( # type: ignore[misc] "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." ) - chat_model_id = openai_settings.get("chat_model_id") - if not chat_model_id: + resolved_model = openai_settings.get("model") + if not resolved_model: raise ValueError( - "OpenAI model ID is required. " - "Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable." + "OpenAI model is required. Set via 'model' parameter or 'OPENAI_MODEL' environment variable." ) super().__init__( - model_id=chat_model_id, + model=resolved_model, api_key=self._get_api_key(api_key_value), org_id=openai_settings.get("org_id"), default_headers=default_headers, @@ -465,12 +471,12 @@ class OpenAIAssistantsClient( # type: ignore[misc] """ # If no assistant is provided, create a temporary assistant if self.assistant_id is None: - if not self.model_id: - raise ValueError("Parameter 'model_id' is required for assistant creation.") + if not self.model: + raise ValueError("Parameter 'model' is required for assistant creation.") client = await self._ensure_client() created_assistant = await client.beta.assistants.create( # type: ignore[reportDeprecated] - model=self.model_id, + model=self.model, description=self.assistant_description, name=self.assistant_name, ) @@ -781,13 +787,13 @@ class OpenAIAssistantsClient( # type: ignore[misc] options: Mapping[str, Any], **kwargs: Any, ) -> tuple[dict[str, Any], list[Content] | None]: - from .._types import validate_tool_mode + from agent_framework._types import validate_tool_mode run_options: dict[str, Any] = {**kwargs} # Extract options from the dict max_tokens = options.get("max_tokens") - model_id = options.get("model_id") + model = options.get("model") or options.get("model_id") # backward compat top_p = options.get("top_p") temperature = options.get("temperature") allow_multiple_tool_calls = options.get("allow_multiple_tool_calls") @@ -798,8 +804,8 @@ class OpenAIAssistantsClient( # type: ignore[misc] if max_tokens is not None: run_options["max_completion_tokens"] = max_tokens - if model_id is not None: - run_options["model"] = model_id + if model is not None: + run_options["model"] = model if top_p is not None: run_options["top_p"] = top_p if temperature is not None: diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py similarity index 86% rename from python/packages/core/agent_framework/openai/_responses_client.py rename to python/packages/openai/agent_framework_openai/_chat_client.py index 0769c3f1f9..86af86895e 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -14,6 +14,7 @@ from collections.abc import ( MutableMapping, Sequence, ) +from copy import copy from datetime import datetime, timezone from itertools import chain from typing import ( @@ -25,9 +26,45 @@ from typing import ( NoReturn, TypedDict, cast, + overload, ) +from urllib.parse import urljoin, urlparse -from openai import AsyncOpenAI, BadRequestError +from agent_framework._clients import BaseChatClient +from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._settings import SecretString +from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._tools import ( + SHELL_TOOL_KIND_VALUE, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + FunctionTool, + ToolTypes, + normalize_tools, + tool, +) +from agent_framework._types import ( + Annotation, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + Content, + ContinuationToken, + Message, + ResponseStream, + Role, + TextSpanRegion, + UsageDetails, + detect_media_type_from_base64, + prepend_instructions_to_messages, + validate_tool_mode, +) +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidRequestException, +) +from agent_framework.observability import ChatTelemetryLayer +from openai import AsyncAzureOpenAI, AsyncOpenAI, BadRequestError from openai.types.responses import FunctionShellTool from openai.types.responses.file_search_tool_param import FileSearchToolParam from openai.types.responses.function_tool_param import FunctionToolParam @@ -48,41 +85,13 @@ from openai.types.responses.tool_param import ( from openai.types.responses.web_search_tool_param import WebSearchToolParam from pydantic import BaseModel -from .._clients import BaseChatClient -from .._middleware import ChatMiddlewareLayer -from .._settings import load_settings -from .._tools import ( - SHELL_TOOL_KIND_VALUE, - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - ToolTypes, - normalize_tools, - tool, -) -from .._types import ( - Annotation, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - ContinuationToken, - Message, - ResponseStream, - Role, - TextSpanRegion, - UsageDetails, - detect_media_type_from_base64, - prepend_instructions_to_messages, - validate_tool_mode, -) -from ..exceptions import ( - ChatClientException, - ChatClientInvalidRequestException, -) -from ..observability import ChatTelemetryLayer from ._exceptions import OpenAIContentFilterException -from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings +from ._shared import ( + DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, + get_api_key, + load_openai_service_settings, + maybe_append_azure_endpoint_guidance, +) if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -98,7 +107,7 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from .._middleware import ( + from agent_framework._middleware import ( ChatMiddleware, ChatMiddlewareCallable, FunctionMiddleware, @@ -147,7 +156,7 @@ class StreamOptions(TypedDict, total=False): ResponseFormatT = TypeVar("ResponseFormatT", bound=BaseModel | None, default=None) -class OpenAIResponsesOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], total=False): +class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], total=False): """OpenAI Responses API-specific chat options. Extends ChatOptions with options specific to OpenAI's Responses API. @@ -222,10 +231,10 @@ class OpenAIResponsesOptions(ChatOptions[ResponseFormatT], Generic[ResponseForma completion or resume a streaming response.""" -OpenAIResponsesOptionsT = TypeVar( - "OpenAIResponsesOptionsT", +OpenAIChatOptionsT = TypeVar( + "OpenAIChatOptionsT", bound=TypedDict, # type: ignore[valid-type] - default="OpenAIResponsesOptions", + default="OpenAIChatOptions", covariant=True, ) @@ -236,10 +245,9 @@ OpenAIResponsesOptionsT = TypeVar( # region ResponsesClient -class RawOpenAIResponsesClient( # type: ignore[misc] - OpenAIBase, - BaseChatClient[OpenAIResponsesOptionsT], - Generic[OpenAIResponsesOptionsT], +class RawOpenAIChatClient( # type: ignore[misc] + BaseChatClient[OpenAIChatOptionsT], + Generic[OpenAIChatOptionsT], ): """Raw OpenAI Responses client without middleware, telemetry, or function invocation. @@ -249,17 +257,194 @@ 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. + Use ``OpenAIChatClient`` instead for a fully-featured client with all layers applied. """ + INJECTABLE: ClassVar[set[str]] = {"client"} STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] FILE_SEARCH_MAX_RESULTS: int = 50 + @overload + def __init__( + self, + *, + model: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + model: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + azure_endpoint: str, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: ... + + def __init__( + self, + *, + model: str | None = None, + model_id: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw OpenAI Responses client. + + Keyword Args: + model: OpenAI model name. + model_id: Deprecated alias for ``model``. + api_key: OpenAI API key, SecretString, or callable returning a key. + org_id: OpenAI organization ID. + base_url: Custom API base URL. + azure_endpoint: Azure OpenAI endpoint. When provided, the client uses + ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the + resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI + key auth, either pass the resource endpoint without that suffix to + ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. + Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` + is configured. + api_version: Azure OpenAI API version. Can also be set via + ``AZURE_OPENAI_API_VERSION``. + default_headers: Additional HTTP headers. + async_client: Pre-configured AsyncOpenAI client (skips client creation). + instruction_role: Role for instruction messages (e.g. ``"system"``). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + """ + if model_id is not None and model is None: + import warnings + + warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) + model = model_id + + openai_settings: dict[str, Any] = {} + use_azure_client = isinstance(async_client, AsyncAzureOpenAI) + if not async_client: + resolved_settings, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + org_id=org_id, + base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), + default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, + ) + openai_settings = dict(resolved_settings) + + api_key_value = openai_settings.get("api_key") + if not api_key_value: + raise ValueError( + "OpenAI API key is required. Set via the 'api_key' parameter or the " + "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." + ) + resolved_model = openai_settings.get("model") or model + if not resolved_model: + raise ValueError( + "OpenAI model is required. Set via the 'model' parameter or the " + "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." + ) + model = resolved_model + + resolved_api_key = get_api_key(api_key_value) + + # Merge APP_INFO into the headers + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + + client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} + if use_azure_client: + endpoint_value = openai_settings.get("azure_endpoint") + if ( + not openai_settings.get("base_url") + and endpoint_value + and (hostname := urlparse(str(endpoint_value)).hostname) + and hostname.endswith(".openai.azure.com") + ): + openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") + + client_args.pop("api_key") + if resolved_api_version := openai_settings.get("api_version"): + client_args["api_version"] = resolved_api_version + if resolved_base_url := openai_settings.get("base_url"): + client_args["base_url"] = resolved_base_url + elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): + client_args["azure_endpoint"] = resolved_azure_endpoint + if callable(resolved_api_key): + client_args["azure_ad_token_provider"] = resolved_api_key + else: + client_args["api_key"] = resolved_api_key + client_args["azure_deployment"] = resolved_model + async_client = AsyncAzureOpenAI(**client_args) + else: + if resolved_org_id := openai_settings.get("org_id"): + client_args["organization"] = resolved_org_id + if resolved_base_url := openai_settings.get("base_url"): + client_args["base_url"] = resolved_base_url + + async_client = AsyncOpenAI(**client_args) + + self.client = async_client + self.model: str | None = model.strip() if model else None + + # Store configuration for serialization + resolved_base_url = openai_settings.get("base_url") or base_url + resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint + resolved_api_version = openai_settings.get("api_version") or api_version + self.org_id = openai_settings.get("org_id") or org_id + self.base_url = str(resolved_base_url) if resolved_base_url else None + self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None + self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + if default_headers: + self.default_headers: dict[str, Any] | None = { + k: v for k, v in default_headers.items() if k != USER_AGENT_KEY + } + else: + self.default_headers = None + + if instruction_role is not None: + self.instruction_role = instruction_role + + if use_azure_client: + self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] + + super().__init__(**kwargs) + # region Inner Methods async def _prepare_request( @@ -273,7 +458,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Returns: Tuple of (client, run_options, validated_options). """ - client = await self._ensure_client() + client = self.client validated_options = await self._validate_options(options) run_options = await self._prepare_options(messages, validated_options, **kwargs) return client, run_options, validated_options @@ -286,7 +471,10 @@ class RawOpenAIResponsesClient( # type: ignore[misc] inner_exception=ex, ) from ex raise ChatClientException( - f"{type(self)} service failed to complete the prompt: {ex}", + maybe_append_azure_endpoint_guidance( + f"{type(self)} service failed to complete the prompt: {ex}", + azure_endpoint=self.azure_endpoint, + ), inner_exception=ex, ) from ex @@ -309,7 +497,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] nonlocal validated_options if continuation_token is not None: # Resume a background streaming response by retrieving with stream=True - client = await self._ensure_client() + client = self.client validated_options = await self._validate_options(options) try: stream_response = await client.responses.retrieve( @@ -356,7 +544,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] async def _get_response() -> ChatResponse: if continuation_token is not None: # Poll a background response by retrieving without stream - client = await self._ensure_client() + client = self.client validated_options = await self._validate_options(options) try: response = await client.responses.retrieve(continuation_token["response_id"]) @@ -540,13 +728,13 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient # Basic code interpreter - tool = OpenAIResponsesClient.get_code_interpreter_tool() + tool = OpenAIChatClient.get_code_interpreter_tool() # With file access - tool = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file-abc123"]) + tool = OpenAIChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) # Use with agent agent = ChatAgent(client, tools=[tool]) @@ -582,13 +770,13 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient # Basic web search - tool = OpenAIResponsesClient.get_web_search_tool() + tool = OpenAIChatClient.get_web_search_tool() # With location context - tool = OpenAIResponsesClient.get_web_search_tool( + tool = OpenAIChatClient.get_web_search_tool( user_location={"city": "Seattle", "country": "US"}, search_context_size="medium", ) @@ -644,13 +832,13 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient # Basic image generation - tool = OpenAIResponsesClient.get_image_generation_tool() + tool = OpenAIChatClient.get_image_generation_tool() # High quality large image - tool = OpenAIResponsesClient.get_image_generation_tool( + tool = OpenAIChatClient.get_image_generation_tool( size="1536x1024", quality="high", output_format="png", @@ -712,18 +900,16 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient # Hosted shell (OpenAI container) - tool = OpenAIResponsesClient.get_shell_tool() + tool = OpenAIChatClient.get_shell_tool() # Hosted shell with custom environment - tool = OpenAIResponsesClient.get_shell_tool( - environment={"type": "container_auto", "file_ids": ["file-abc"]} - ) + tool = OpenAIChatClient.get_shell_tool(environment={"type": "container_auto", "file_ids": ["file-abc"]}) # Local shell execution - tool = OpenAIResponsesClient.get_shell_tool( + tool = OpenAIChatClient.get_shell_tool( func=my_shell_func, ) """ @@ -801,16 +987,16 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient # Basic MCP tool - tool = OpenAIResponsesClient.get_mcp_tool( + tool = OpenAIChatClient.get_mcp_tool( name="my_mcp", url="https://mcp.example.com", ) # With approval settings - tool = OpenAIResponsesClient.get_mcp_tool( + tool = OpenAIChatClient.get_mcp_tool( name="github_mcp", url="https://mcp.github.com", description="GitHub MCP server", @@ -819,7 +1005,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) # With specific tool approvals - tool = OpenAIResponsesClient.get_mcp_tool( + tool = OpenAIChatClient.get_mcp_tool( name="tools_mcp", url="https://tools.example.com", approval_mode={ @@ -874,15 +1060,15 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient # Basic file search - tool = OpenAIResponsesClient.get_file_search_tool( + tool = OpenAIChatClient.get_file_search_tool( vector_store_ids=["vs_abc123"], ) # With result limit - tool = OpenAIResponsesClient.get_file_search_tool( + tool = OpenAIChatClient.get_file_search_tool( vector_store_ids=["vs_abc123", "vs_def456"], max_num_results=10, ) @@ -943,7 +1129,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] # translations between options and Responses API translations = { - "model_id": "model", + "model_id": "model", # backward compat: accept model_id in options "allow_multiple_tool_calls": "parallel_tool_calls", "conversation_id": "previous_response_id", "max_tokens": "max_output_tokens", @@ -1003,9 +1189,9 @@ class RawOpenAIResponsesClient( # type: ignore[misc] Since AzureAIClients use a different param for this, this method is overridden in those clients. """ if not options.get("model"): - if not self.model_id: - raise ValueError("model_id must be a non-empty string") - options["model"] = self.model_id + if not self.model: + raise ValueError("model must be a non-empty string") + options["model"] = self.model def _get_current_conversation_id(self, options: Mapping[str, Any], **kwargs: Any) -> str | None: """Get the current conversation ID, preferring kwargs over options. @@ -1542,7 +1728,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) ) case "mcp_call": - call_id = item.id + call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or "" contents.append( Content.from_mcp_server_tool_call( call_id=call_id, @@ -1684,7 +1870,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] "%Y-%m-%dT%H:%M:%S.%fZ" ), "messages": response_message, - "model_id": response.model, + "model": response.model, "additional_properties": metadata, "raw_representation": response, } @@ -1717,7 +1903,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] conversation_id: str | None = None response_id: str | None = None continuation_token: OpenAIContinuationToken | None = None - model = self.model_id + model = self.model match event.type: # types: # ResponseAudioDeltaEvent, @@ -1932,27 +2118,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] raw_representation=event_item, ) ) - result_output = ( - getattr(event_item, "result", None) - or getattr(event_item, "output", None) - or getattr(event_item, "outputs", None) - ) - parsed_output: list[Content] | None = None - if result_output: - normalized = ( # pyright: ignore[reportUnknownVariableType] - result_output - if isinstance(result_output, Sequence) - and not isinstance(result_output, (str, bytes, MutableMapping)) - else [result_output] - ) - parsed_output = [Content.from_dict(output_item) for output_item in normalized] # pyright: ignore[reportArgumentType,reportUnknownVariableType] - contents.append( - Content.from_mcp_server_tool_result( - call_id=call_id, - output=parsed_output, - raw_representation=event_item, - ) - ) + # Result deferred to response.output_item.done case "code_interpreter_call": # ResponseOutputCodeInterpreterCall call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None) outputs: list[Content] = [] @@ -2222,6 +2388,21 @@ class RawOpenAIResponsesClient( # type: ignore[misc] ) else: logger.debug("Unparsed annotation type in streaming: %s", ann_type) + case "response.output_item.done": + done_item = event.item + if getattr(done_item, "type", None) == "mcp_call": + call_id = getattr(done_item, "id", None) or getattr(done_item, "call_id", None) or "" + output_text = getattr(done_item, "output", None) + parsed_output: list[Content] | None = ( + [Content.from_text(text=output_text)] if isinstance(output_text, str) else None + ) + contents.append( + Content.from_mcp_server_tool_result( + call_id=call_id, + output=parsed_output, + raw_representation=done_item, + ) + ) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) @@ -2230,7 +2411,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] conversation_id=conversation_id, response_id=response_id, role="assistant", - model_id=model, + model=model, continuation_token=continuation_token, additional_properties=metadata, raw_representation=event, @@ -2257,23 +2438,66 @@ class RawOpenAIResponsesClient( # type: ignore[misc] return {} -class OpenAIResponsesClient( # type: ignore[misc] - OpenAIConfigMixin, - ChatMiddlewareLayer[OpenAIResponsesOptionsT], - FunctionInvocationLayer[OpenAIResponsesOptionsT], - ChatTelemetryLayer[OpenAIResponsesOptionsT], - RawOpenAIResponsesClient[OpenAIResponsesOptionsT], - Generic[OpenAIResponsesOptionsT], +class OpenAIChatClient( # type: ignore[misc] + FunctionInvocationLayer[OpenAIChatOptionsT], + ChatMiddlewareLayer[OpenAIChatOptionsT], + ChatTelemetryLayer[OpenAIChatOptionsT], + RawOpenAIChatClient[OpenAIChatOptionsT], + Generic[OpenAIChatOptionsT], ): """OpenAI Responses client class with middleware, telemetry, and function invocation support.""" + OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + + @overload + def __init__( + self, + *, + model: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + middleware: ( + Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None + ) = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + model: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + azure_endpoint: str, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + middleware: ( + Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None + ) = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: ... + def __init__( self, *, - model_id: str | None = None, + model: str | None = None, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, @@ -2288,14 +2512,22 @@ class OpenAIResponsesClient( # type: ignore[misc] """Initialize an OpenAI Responses client. Keyword Args: - model_id: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_RESPONSES_MODEL_ID. + model: OpenAI model name, see https://platform.openai.com/docs/models. + Can also be set via environment variable OPENAI_MODEL. api_key: The API key to use. If provided will override the env vars or .env file value. Can also be set via environment variable OPENAI_API_KEY. org_id: The org ID to use. If provided will override the env vars or .env file value. Can also be set via environment variable OPENAI_ORG_ID. base_url: The base URL to use. If provided will override the standard value. Can also be set via environment variable OPENAI_BASE_URL. + azure_endpoint: Azure OpenAI endpoint. When provided, the client uses + ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and + should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass + the resource endpoint without that suffix to ``azure_endpoint`` or pass the + full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered + from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. + api_version: Azure OpenAI API version. Can also be set via + ``AZURE_OPENAI_API_VERSION``. default_headers: The default headers mapping of string keys to string values for HTTP requests. async_client: An existing client to use. @@ -2311,63 +2543,65 @@ class OpenAIResponsesClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient # Using environment variables # Set OPENAI_API_KEY=sk-... - # Set OPENAI_RESPONSES_MODEL_ID=gpt-4o - client = OpenAIResponsesClient() + # Set OPENAI_MODEL=gpt-4o + client = OpenAIChatClient() # Or passing parameters directly - client = OpenAIResponsesClient(model_id="gpt-4o", api_key="sk-...") + client = OpenAIChatClient(model="gpt-4o", api_key="sk-...") # Or loading from a .env file - client = OpenAIResponsesClient(env_file_path="path/to/.env") + client = OpenAIChatClient(env_file_path="path/to/.env") # Using custom ChatOptions with type safety: from typing import TypedDict - from agent_framework.openai import OpenAIResponsesOptions + from agent_framework.openai import OpenAIChatOptions - class MyOptions(OpenAIResponsesOptions, total=False): + class MyOptions(OpenAIChatOptions, total=False): my_custom_option: str - client: OpenAIResponsesClient[MyOptions] = OpenAIResponsesClient(model_id="gpt-4o") + client: OpenAIChatClient[MyOptions] = OpenAIChatClient(model="gpt-4o") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", + super().__init__( + model=model, api_key=api_key, org_id=org_id, base_url=base_url, - responses_model_id=model_id, + azure_endpoint=azure_endpoint, + api_version=api_version, + default_headers=default_headers, + async_client=async_client, + instruction_role=instruction_role, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - ) - - api_key_setting = openai_settings.get("api_key") - if not async_client and not api_key_setting: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - responses_model_id = openai_settings.get("responses_model_id") - if not responses_model_id: - raise ValueError( - "OpenAI model ID is required. " - "Set via 'model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable." - ) - - super().__init__( - model_id=responses_model_id, - api_key=self._get_api_key(api_key_setting), - org_id=openai_settings.get("org_id"), - default_headers=default_headers, - client=async_client, - instruction_role=instruction_role, - base_url=openai_settings.get("base_url"), middleware=middleware, function_invocation_configuration=function_invocation_configuration, **kwargs, ) + + +def _apply_openai_chat_client_docstrings() -> None: + """Align OpenAI Responses client docstrings with the raw implementation.""" + from agent_framework._clients import BaseChatClient + from agent_framework._docstrings import apply_layered_docstring + + apply_layered_docstring(RawOpenAIChatClient.get_response, BaseChatClient.get_response) + apply_layered_docstring( + OpenAIChatClient.get_response, + RawOpenAIChatClient.get_response, + extra_keyword_args={ + "middleware": """ + Optional per-call chat and function middleware. + This is merged with any middleware configured on the client for the current request. + """, + }, + ) + + +_apply_openai_chat_client_docstrings() diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py similarity index 72% rename from python/packages/core/agent_framework/openai/_chat_client.py rename to python/packages/openai/agent_framework_openai/_chat_completion_client.py index 6df57fe428..aa78079dd2 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -13,11 +13,39 @@ from collections.abc import ( MutableMapping, Sequence, ) +from copy import copy from datetime import datetime, timezone from itertools import chain -from typing import Any, Generic, Literal, cast, overload +from typing import Any, ClassVar, Generic, Literal, cast, overload -from openai import AsyncOpenAI, BadRequestError +from agent_framework._clients import BaseChatClient +from agent_framework._docstrings import apply_layered_docstring +from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer +from agent_framework._settings import SecretString +from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._tools import ( + FunctionInvocationConfiguration, + FunctionInvocationLayer, + FunctionTool, + ToolTypes, + normalize_tools, +) +from agent_framework._types import ( + ChatOptions, + ChatResponse, + ChatResponseUpdate, + Content, + FinishReason, + Message, + ResponseStream, + UsageDetails, +) +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidRequestException, +) +from agent_framework.observability import ChatTelemetryLayer +from openai import AsyncAzureOpenAI, AsyncOpenAI, BadRequestError from openai.lib._parsing._completions import type_to_response_format_param from openai.types import CompletionUsage from openai.types.chat.chat_completion import ChatCompletion, Choice @@ -29,34 +57,13 @@ from openai.types.chat.chat_completion_message_custom_tool_call import ( from openai.types.chat.completion_create_params import WebSearchOptions from pydantic import BaseModel -from .._clients import BaseChatClient -from .._docstrings import apply_layered_docstring -from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionMiddlewareTypes -from .._settings import load_settings -from .._tools import ( - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - ToolTypes, - normalize_tools, -) -from .._types import ( - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - FinishReason, - Message, - ResponseStream, - UsageDetails, -) -from ..exceptions import ( - ChatClientException, - ChatClientInvalidRequestException, -) -from ..observability import ChatTelemetryLayer from ._exceptions import OpenAIContentFilterException -from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings +from ._shared import ( + DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, + get_api_key, + load_openai_service_settings, + maybe_append_azure_endpoint_guidance, +) if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -94,7 +101,7 @@ class Prediction(TypedDict, total=False): content: str | list[PredictionTextContent] -class OpenAIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): +class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """OpenAI-specific chat options dict. Extends ChatOptions with options specific to OpenAI's Chat Completions API. @@ -133,20 +140,24 @@ class OpenAIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to prediction: Prediction -OpenAIChatOptionsT = TypeVar("OpenAIChatOptionsT", bound=TypedDict, default="OpenAIChatOptions", covariant=True) # type: ignore[valid-type] +OpenAIChatCompletionOptionsT = TypeVar( + "OpenAIChatCompletionOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OpenAIChatCompletionOptions", + covariant=True, +) OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", + "model_id": "model", # backward compat: accept model_id in options "allow_multiple_tool_calls": "parallel_tool_calls", "max_tokens": "max_completion_tokens", } # region Base Client -class RawOpenAIChatClient( # type: ignore[misc] - OpenAIBase, - BaseChatClient[OpenAIChatOptionsT], - Generic[OpenAIChatOptionsT], +class RawOpenAIChatCompletionClient( # type: ignore[misc] + BaseChatClient[OpenAIChatCompletionOptionsT], + Generic[OpenAIChatCompletionOptionsT], ): """Raw OpenAI Chat completion class without middleware, telemetry, or function invocation. @@ -156,13 +167,182 @@ 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. + Use ``OpenAIChatCompletionClient`` instead for a fully-featured client with all layers applied. """ + INJECTABLE: ClassVar[set[str]] = {"client"} + + @overload + def __init__( + self, + *, + model: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + model: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + azure_endpoint: str, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: ... + + def __init__( + self, + *, + model: str | None = None, + model_id: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw OpenAI Chat completion client. + + Keyword Args: + model: OpenAI model name. + model_id: Deprecated alias for ``model``. + api_key: OpenAI API key, SecretString, or callable returning a key. + org_id: OpenAI organization ID. + base_url: Custom API base URL. + azure_endpoint: Azure OpenAI endpoint. When provided, the client uses + ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the + resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI + key auth, either pass the resource endpoint without that suffix to + ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. + Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` + is configured. + api_version: Azure OpenAI API version. Can also be set via + ``AZURE_OPENAI_API_VERSION``. + default_headers: Additional HTTP headers. + async_client: Pre-configured AsyncOpenAI client (skips client creation). + instruction_role: Role for instruction messages (e.g. ``"system"``). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + """ + if model_id is not None and model is None: + import warnings + + warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) + model = model_id + + openai_settings: dict[str, Any] = {} + use_azure_client = isinstance(async_client, AsyncAzureOpenAI) + if not async_client: + resolved_settings, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + org_id=org_id, + base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), + default_azure_api_version=DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, + ) + openai_settings = dict(resolved_settings) + + api_key_value = openai_settings.get("api_key") + if not api_key_value: + raise ValueError( + "OpenAI API key is required. Set via the 'api_key' parameter or the " + "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." + ) + resolved_model = openai_settings.get("model") or model + if not resolved_model: + raise ValueError( + "OpenAI model is required. Set via the 'model' parameter or the " + "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." + ) + model = resolved_model + + resolved_api_key = get_api_key(api_key_value) + + # Merge APP_INFO into the headers + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + + client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} + if use_azure_client: + client_args.pop("api_key") + if resolved_api_version := openai_settings.get("api_version"): + client_args["api_version"] = resolved_api_version + if resolved_base_url := openai_settings.get("base_url"): + client_args["base_url"] = resolved_base_url + elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): + client_args["azure_endpoint"] = resolved_azure_endpoint + if callable(resolved_api_key): + client_args["azure_ad_token_provider"] = resolved_api_key + else: + client_args["api_key"] = resolved_api_key + client_args["azure_deployment"] = resolved_model + async_client = AsyncAzureOpenAI(**client_args) + else: + if resolved_org_id := openai_settings.get("org_id"): + client_args["organization"] = resolved_org_id + if resolved_base_url := openai_settings.get("base_url"): + client_args["base_url"] = resolved_base_url + + async_client = AsyncOpenAI(**client_args) + + self.client = async_client + self.model: str | None = model.strip() if model else None + + # Store configuration for serialization + resolved_base_url = openai_settings.get("base_url") or base_url + resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint + resolved_api_version = openai_settings.get("api_version") or api_version + self.org_id = openai_settings.get("org_id") or org_id + self.base_url = str(resolved_base_url) if resolved_base_url else None + self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None + self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + if default_headers: + self.default_headers: dict[str, Any] | None = { + k: v for k, v in default_headers.items() if k != USER_AGENT_KEY + } + else: + self.default_headers = None + + if instruction_role is not None: + self.instruction_role = instruction_role + + if use_azure_client: + self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] + + super().__init__(**kwargs) + # region Hosted Tool Factory Methods @staticmethod @@ -188,13 +368,13 @@ class RawOpenAIChatClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient # Basic web search - tool = OpenAIChatClient.get_web_search_tool() + tool = OpenAIChatCompletionClient.get_web_search_tool() # With location context - tool = OpenAIChatClient.get_web_search_tool( + tool = OpenAIChatCompletionClient.get_web_search_tool( web_search_options={ "user_location": { "type": "approximate", @@ -231,7 +411,7 @@ class RawOpenAIChatClient( # type: ignore[misc] messages: Sequence[Message], *, stream: Literal[False] = ..., - options: OpenAIChatOptionsT | ChatOptions[None] | None = None, + options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -241,7 +421,7 @@ class RawOpenAIChatClient( # type: ignore[misc] messages: Sequence[Message], *, stream: Literal[True], - options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, + options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -251,7 +431,7 @@ class RawOpenAIChatClient( # type: ignore[misc] messages: Sequence[Message], *, stream: bool = False, - options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, + options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from the raw OpenAI chat client.""" @@ -283,7 +463,7 @@ class RawOpenAIChatClient( # type: ignore[misc] options_dict["stream_options"] = {"include_usage": True} async def _stream() -> AsyncIterable[ChatResponseUpdate]: - client = await self._ensure_client() + client = self.client try: async for chunk in await client.chat.completions.create(stream=True, **options_dict): if len(chunk.choices) == 0 and chunk.usage is None: @@ -296,12 +476,18 @@ class RawOpenAIChatClient( # type: ignore[misc] inner_exception=ex, ) from ex raise ChatClientException( - f"{type(self)} service failed to complete the prompt: {ex}", + maybe_append_azure_endpoint_guidance( + f"{type(self)} service failed to complete the prompt: {ex}", + azure_endpoint=self.azure_endpoint, + ), inner_exception=ex, ) from ex except Exception as ex: raise ChatClientException( - f"{type(self)} service failed to complete the prompt: {ex}", + maybe_append_azure_endpoint_guidance( + f"{type(self)} service failed to complete the prompt: {ex}", + azure_endpoint=self.azure_endpoint, + ), inner_exception=ex, ) from ex @@ -309,7 +495,7 @@ class RawOpenAIChatClient( # type: ignore[misc] # Non-streaming mode async def _get_response() -> ChatResponse: - client = await self._ensure_client() + client = self.client try: return self._parse_response_from_openai( await client.chat.completions.create(stream=False, **options_dict), options @@ -321,12 +507,18 @@ class RawOpenAIChatClient( # type: ignore[misc] inner_exception=ex, ) from ex raise ChatClientException( - f"{type(self)} service failed to complete the prompt: {ex}", + maybe_append_azure_endpoint_guidance( + f"{type(self)} service failed to complete the prompt: {ex}", + azure_endpoint=self.azure_endpoint, + ), inner_exception=ex, ) from ex except Exception as ex: raise ChatClientException( - f"{type(self)} service failed to complete the prompt: {ex}", + maybe_append_azure_endpoint_guidance( + f"{type(self)} service failed to complete the prompt: {ex}", + azure_endpoint=self.azure_endpoint, + ), inner_exception=ex, ) from ex @@ -374,7 +566,7 @@ class RawOpenAIChatClient( # type: ignore[misc] def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, Any]) -> dict[str, Any]: # Prepend instructions from options if they exist - from .._types import prepend_instructions_to_messages, validate_tool_mode + from agent_framework._types import prepend_instructions_to_messages, validate_tool_mode if instructions := options.get("instructions"): messages = prepend_instructions_to_messages(list(messages), instructions, role="system") @@ -397,9 +589,9 @@ class RawOpenAIChatClient( # type: ignore[misc] # model id if not run_options.get("model"): - if not self.model_id: - raise ValueError("model_id must be a non-empty string") - run_options["model"] = self.model_id + if not self.model: + raise ValueError("model must be a non-empty string") + run_options["model"] = self.model # tools tools = options.get("tools") @@ -452,7 +644,7 @@ class RawOpenAIChatClient( # type: ignore[misc] created_at=datetime.fromtimestamp(response.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), usage_details=self._parse_usage_from_openai(response.usage) if response.usage else None, messages=messages, - model_id=response.model, + model=response.model, additional_properties=response_metadata, finish_reason=finish_reason, response_format=options.get("response_format"), @@ -488,7 +680,7 @@ class RawOpenAIChatClient( # type: ignore[misc] created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), contents=contents, role="assistant", - model_id=chunk.model, + model=chunk.model, additional_properties=chunk_metadata, finish_reason=finish_reason, raw_representation=chunk, @@ -713,9 +905,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: @@ -770,16 +966,17 @@ class RawOpenAIChatClient( # type: ignore[misc] # region Public client -class OpenAIChatClient( # type: ignore[misc] - OpenAIConfigMixin, - ChatMiddlewareLayer[OpenAIChatOptionsT], - FunctionInvocationLayer[OpenAIChatOptionsT], - ChatTelemetryLayer[OpenAIChatOptionsT], - RawOpenAIChatClient[OpenAIChatOptionsT], - Generic[OpenAIChatOptionsT], +class OpenAIChatCompletionClient( # type: ignore[misc] + FunctionInvocationLayer[OpenAIChatCompletionOptionsT], + ChatMiddlewareLayer[OpenAIChatCompletionOptionsT], + ChatTelemetryLayer[OpenAIChatCompletionOptionsT], + RawOpenAIChatCompletionClient[OpenAIChatCompletionOptionsT], + Generic[OpenAIChatCompletionOptionsT], ): """OpenAI Chat completion class with middleware, telemetry, and function invocation support.""" + OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + @overload def get_response( self, @@ -787,7 +984,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, @@ -800,8 +996,7 @@ class OpenAIChatClient( # type: ignore[misc] messages: Sequence[Message], *, stream: Literal[False] = ..., - options: OpenAIChatOptionsT | ChatOptions[None] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -814,8 +1009,7 @@ class OpenAIChatClient( # type: ignore[misc] messages: Sequence[Message], *, stream: Literal[True], - options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -828,8 +1022,7 @@ class OpenAIChatClient( # type: ignore[misc] messages: Sequence[Message], *, stream: bool = False, - options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -840,27 +1033,30 @@ 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, ) def __init__( self, *, - model_id: str | None = None, + model: str | None = None, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, @@ -869,8 +1065,8 @@ class OpenAIChatClient( # type: ignore[misc] """Initialize an OpenAI Chat completion client. Keyword Args: - model_id: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_CHAT_MODEL_ID. + model: OpenAI model name, see https://platform.openai.com/docs/models. + Can also be set via environment variable OPENAI_MODEL. api_key: The API key to use. If provided will override the env vars or .env file value. Can also be set via environment variable OPENAI_API_KEY. org_id: The org ID to use. If provided will override the env vars or .env file value. @@ -883,6 +1079,14 @@ class OpenAIChatClient( # type: ignore[misc] base_url: The base URL to use. If provided will override the standard value for an OpenAI connector, the env vars or .env file value. Can also be set via environment variable OPENAI_BASE_URL. + azure_endpoint: Azure OpenAI endpoint. When provided, the client uses + ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and + should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass + the resource endpoint without that suffix to ``azure_endpoint`` or pass the + full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered + from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. + api_version: Azure OpenAI API version. Can also be set via + ``AZURE_OPENAI_API_VERSION``. middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. function_invocation_configuration: Optional configuration for function invocation support. env_file_path: Use the environment settings file as a fallback @@ -892,81 +1096,55 @@ class OpenAIChatClient( # type: ignore[misc] Examples: .. code-block:: python - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient # Using environment variables # Set OPENAI_API_KEY=sk-... - # Set OPENAI_CHAT_MODEL_ID= - client = OpenAIChatClient() + # Set OPENAI_MODEL= + client = OpenAIChatCompletionClient() # Or passing parameters directly - client = OpenAIChatClient(model_id="", api_key="sk-...") + client = OpenAIChatCompletionClient(model="", api_key="sk-...") # Or loading from a .env file - client = OpenAIChatClient(env_file_path="path/to/.env") + client = OpenAIChatCompletionClient(env_file_path="path/to/.env") # Using custom ChatOptions with type safety: from typing import TypedDict - from agent_framework.openai import OpenAIChatOptions + from agent_framework.openai import OpenAIChatCompletionOptions - class MyOptions(OpenAIChatOptions, total=False): + class MyOptions(OpenAIChatCompletionOptions, total=False): my_custom_option: str - client: OpenAIChatClient[MyOptions] = OpenAIChatClient(model_id="") + client: OpenAIChatCompletionClient[MyOptions] = OpenAIChatCompletionClient(model="") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", + super().__init__( + model=model, api_key=api_key, - base_url=base_url, org_id=org_id, - chat_model_id=model_id, + base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, + default_headers=default_headers, + async_client=async_client, + instruction_role=instruction_role, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - ) - - api_key_value = openai_settings.get("api_key") - if not async_client and not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - - chat_model_id = openai_settings.get("chat_model_id") - if not chat_model_id: - raise ValueError( - "OpenAI model ID is required. " - "Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable." - ) - - base_url_value = openai_settings.get("base_url") - - super().__init__( - model_id=chat_model_id, - api_key=self._get_api_key(api_key_value), - base_url=base_url_value if base_url_value else None, - org_id=openai_settings.get("org_id"), - default_headers=default_headers, - client=async_client, - instruction_role=instruction_role, middleware=middleware, function_invocation_configuration=function_invocation_configuration, ) -def _apply_openai_chat_client_docstrings() -> None: - """Align OpenAI chat-client docstrings with the raw implementation.""" - apply_layered_docstring(RawOpenAIChatClient.get_response, BaseChatClient.get_response) +def _apply_openai_chat_completion_client_docstrings() -> None: + """Align OpenAI chat completion client docstrings with the raw implementation.""" + apply_layered_docstring(RawOpenAIChatCompletionClient.get_response, BaseChatClient.get_response) apply_layered_docstring( - OpenAIChatClient.get_response, - RawOpenAIChatClient.get_response, + OpenAIChatCompletionClient.get_response, + RawOpenAIChatCompletionClient.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. @@ -975,4 +1153,4 @@ def _apply_openai_chat_client_docstrings() -> None: ) -_apply_openai_chat_client_docstrings() +_apply_openai_chat_completion_client_docstrings() diff --git a/python/packages/core/agent_framework/openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py similarity index 51% rename from python/packages/core/agent_framework/openai/_embedding_client.py rename to python/packages/openai/agent_framework_openai/_embedding_client.py index b940e47c7c..ad959d5b39 100644 --- a/python/packages/core/agent_framework/openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -6,15 +6,17 @@ import base64 import struct import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from typing import Any, Generic, Literal, TypedDict +from copy import copy +from typing import Any, ClassVar, Generic, Literal, TypedDict +from agent_framework._clients import BaseEmbeddingClient +from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails +from agent_framework.observability import EmbeddingTelemetryLayer from openai import AsyncOpenAI -from .._clients import BaseEmbeddingClient -from .._settings import load_settings -from .._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails -from ..observability import EmbeddingTelemetryLayer -from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings +from ._shared import OpenAISettings, get_api_key if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -33,7 +35,7 @@ class OpenAIEmbeddingOptions(EmbeddingGenerationOptions, total=False): from agent_framework.openai import OpenAIEmbeddingOptions options: OpenAIEmbeddingOptions = { - "model_id": "text-embedding-3-small", + "model": "text-embedding-3-small", "dimensions": 1536, "encoding_format": "float", } @@ -52,12 +54,103 @@ OpenAIEmbeddingOptionsT = TypeVar( class RawOpenAIEmbeddingClient( - OpenAIBase, BaseEmbeddingClient[str, list[float], OpenAIEmbeddingOptionsT], Generic[OpenAIEmbeddingOptionsT], ): """Raw OpenAI embedding client without telemetry.""" + INJECTABLE: ClassVar[set[str]] = {"client"} + + def __init__( + self, + *, + model: str | None = None, + model_id: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw OpenAI embedding client. + + Keyword Args: + model: OpenAI embedding model name. + model_id: Deprecated alias for ``model``. + api_key: OpenAI API key, SecretString, or callable returning a key. + org_id: OpenAI organization ID. + base_url: Custom API base URL. + default_headers: Additional HTTP headers. + async_client: Pre-configured AsyncOpenAI client (skips client creation). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments forwarded to ``BaseEmbeddingClient``. + """ + if model_id is not None and model is None: + import warnings + + warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) + model = model_id + + if not async_client: + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + api_key=api_key, + org_id=org_id, + base_url=base_url, + embedding_model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + api_key_value = openai_settings.get("api_key") + resolved_model = openai_settings.get("embedding_model") or model + + # Only create a client when we have enough configuration. + # Subclasses that manage their own client pass no args here + if api_key_value: + if not resolved_model: + raise ValueError( + "OpenAI embedding model is required. " + "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." + ) + model = resolved_model + + resolved_api_key = get_api_key(api_key_value) + + # Merge APP_INFO into the headers + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) + + client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} + if resolved_org_id := openai_settings.get("org_id"): + client_args["organization"] = resolved_org_id + if resolved_base_url := openai_settings.get("base_url"): + client_args["base_url"] = resolved_base_url + + async_client = AsyncOpenAI(**client_args) + + self.client = async_client + self.model: str | None = model.strip() if model else None + + # Store configuration for serialization + self.org_id = org_id + self.base_url = str(base_url) if base_url else None + if default_headers: + self.default_headers: dict[str, Any] | None = { + k: v for k, v in default_headers.items() if k != USER_AGENT_KEY + } + else: + self.default_headers = None + + super().__init__(**kwargs) + def service_url(self) -> str: """Get the URL of the service.""" return str(self.client.base_url) if self.client else "Unknown" @@ -78,15 +171,16 @@ class RawOpenAIEmbeddingClient( Generated embeddings with usage metadata. Raises: - ValueError: If model_id is not provided or values is empty. + ValueError: If model is not provided or values is empty. """ if not values: return GeneratedEmbeddings([], options=options) # type: ignore opts: dict[str, Any] = options or {} # type: ignore - model = opts.get("model_id") or self.model_id + # backward compat: accept model_id in options + model = opts.get("model") or opts.get("model_id") or self.model if not model: - raise ValueError("model_id is required") + raise ValueError("model is required") kwargs: dict[str, Any] = {"input": list(values), "model": model} if dimensions := opts.get("dimensions"): @@ -96,7 +190,7 @@ class RawOpenAIEmbeddingClient( if user := opts.get("user"): kwargs["user"] = user - response = await (await self._ensure_client()).embeddings.create(**kwargs) + response = await self.client.embeddings.create(**kwargs) # type: ignore[union-attr] encoding = kwargs.get("encoding_format", "float") embeddings: list[Embedding[list[float]]] = [] @@ -112,7 +206,7 @@ class RawOpenAIEmbeddingClient( Embedding( vector=vector, dimensions=len(vector), - model_id=response.model, + model=response.model, ) ) @@ -127,7 +221,6 @@ class RawOpenAIEmbeddingClient( class OpenAIEmbeddingClient( - OpenAIConfigMixin, EmbeddingTelemetryLayer[str, list[float], OpenAIEmbeddingOptionsT], RawOpenAIEmbeddingClient[OpenAIEmbeddingOptionsT], Generic[OpenAIEmbeddingOptionsT], @@ -135,8 +228,9 @@ class OpenAIEmbeddingClient( """OpenAI embedding client with telemetry support. Keyword Args: - model_id: The embedding model ID (e.g. "text-embedding-3-small"). - Can also be set via environment variable OPENAI_EMBEDDING_MODEL_ID. + model: The embedding model (e.g. "text-embedding-3-small"). + Can also be set via environment variable OPENAI_EMBEDDING_MODEL. + model_id: Deprecated alias for ``model``. api_key: OpenAI API key. Can also be set via environment variable OPENAI_API_KEY. org_id: OpenAI organization ID. @@ -154,12 +248,12 @@ class OpenAIEmbeddingClient( # Using environment variables # Set OPENAI_API_KEY=sk-... - # Set OPENAI_EMBEDDING_MODEL_ID=text-embedding-3-small + # Set OPENAI_EMBEDDING_MODEL=text-embedding-3-small client = OpenAIEmbeddingClient() # Or passing parameters directly client = OpenAIEmbeddingClient( - model_id="text-embedding-3-small", + model="text-embedding-3-small", api_key="sk-...", ) @@ -168,10 +262,12 @@ class OpenAIEmbeddingClient( print(result[0].vector) """ + OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + def __init__( self, *, - model_id: str | None = None, + model: str | None = None, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, @@ -182,38 +278,26 @@ class OpenAIEmbeddingClient( env_file_encoding: str | None = None, ) -> None: """Initialize an OpenAI embedding client.""" - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", + super().__init__( + model=model, api_key=api_key, - base_url=base_url, org_id=org_id, - embedding_model_id=model_id, + base_url=base_url, + default_headers=default_headers, + async_client=async_client, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) + if otel_provider_name is not None: + self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] - api_key_value = openai_settings.get("api_key") - if not async_client and not api_key_value: + # Validate that the client was created successfully (from explicit args or env vars) + if self.client is None: raise ValueError( "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." ) - - embedding_model_id = openai_settings.get("embedding_model_id") - if not embedding_model_id: + if not self.model: raise ValueError( - "OpenAI embedding model ID is required. " - "Set via 'model_id' parameter or 'OPENAI_EMBEDDING_MODEL_ID' environment variable." + "OpenAI embedding model is required. " + "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." ) - - base_url_value = openai_settings.get("base_url") - - super().__init__( - model_id=embedding_model_id, - api_key=self._get_api_key(api_key_value), - base_url=base_url_value if base_url_value else None, - org_id=openai_settings.get("org_id"), - default_headers=default_headers, - client=async_client, - otel_provider_name=otel_provider_name, - ) diff --git a/python/packages/core/agent_framework/openai/_exceptions.py b/python/packages/openai/agent_framework_openai/_exceptions.py similarity index 97% rename from python/packages/core/agent_framework/openai/_exceptions.py rename to python/packages/openai/agent_framework_openai/_exceptions.py index 9aa597da45..bbea701acb 100644 --- a/python/packages/core/agent_framework/openai/_exceptions.py +++ b/python/packages/openai/agent_framework_openai/_exceptions.py @@ -6,10 +6,9 @@ from dataclasses import dataclass from enum import Enum from typing import Any +from agent_framework.exceptions import ChatClientContentFilterException from openai import BadRequestError -from ..exceptions import ChatClientContentFilterException - class ContentFilterResultSeverity(Enum): """The severity of the content filter result.""" diff --git a/python/packages/core/agent_framework/openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py similarity index 60% rename from python/packages/core/agent_framework/openai/_shared.py rename to python/packages/openai/agent_framework_openai/_shared.py index 9817b7fb11..c3c280d950 100644 --- a/python/packages/core/agent_framework/openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -3,17 +3,19 @@ from __future__ import annotations import logging +import os import sys from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from copy import copy from typing import Any, ClassVar, Union, cast import openai -from openai import ( - AsyncOpenAI, - AsyncStream, - _legacy_response, # type: ignore -) +from agent_framework._serialization import SerializationMixin +from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._tools import FunctionTool +from dotenv import dotenv_values +from openai import AsyncOpenAI, AsyncStream, _legacy_response # type: ignore from openai.types import Completion from openai.types.audio import Transcription from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -22,13 +24,11 @@ from openai.types.responses.response import Response from openai.types.responses.response_stream_event import ResponseStreamEvent from packaging.version import parse -from .._serialization import SerializationMixin -from .._settings import SecretString -from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent -from .._tools import FunctionTool - logger: logging.Logger = logging.getLogger("agent_framework.openai") +DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-10-21" +DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview" + RESPONSE_TYPE = Union[ ChatCompletion, @@ -88,12 +88,10 @@ class OpenAISettings(TypedDict, total=False): Can be set via environment variable OPENAI_BASE_URL. org_id: This is usually optional unless your account belongs to multiple organizations. Can be set via environment variable OPENAI_ORG_ID. - chat_model_id: The OpenAI chat model ID to use, for example, gpt-3.5-turbo or gpt-4. - Can be set via environment variable OPENAI_CHAT_MODEL_ID. - responses_model_id: The OpenAI responses model ID to use, for example, gpt-4o or o1. - Can be set via environment variable OPENAI_RESPONSES_MODEL_ID. - embedding_model_id: The OpenAI embedding model ID to use, for example, text-embedding-3-small. - Can be set via environment variable OPENAI_EMBEDDING_MODEL_ID. + model: The OpenAI model to use, for example, gpt-4o or o1. + Can be set via environment variable OPENAI_MODEL. + embedding_model: The OpenAI embedding model to use, for example, text-embedding-3-small. + Can be set via environment variable OPENAI_EMBEDDING_MODEL. Examples: .. code-block:: python @@ -102,11 +100,11 @@ class OpenAISettings(TypedDict, total=False): # Using environment variables # Set OPENAI_API_KEY=sk-... - # Set OPENAI_CHAT_MODEL_ID=gpt-4 + # Set OPENAI_MODEL=gpt-4o settings = load_settings(OpenAISettings, env_prefix="OPENAI_") # Or passing parameters directly - settings = load_settings(OpenAISettings, env_prefix="OPENAI_", api_key="sk-...", chat_model_id="gpt-4") + settings = load_settings(OpenAISettings, env_prefix="OPENAI_", api_key="sk-...", model="gpt-4o") # Or loading from a .env file settings = load_settings(OpenAISettings, env_prefix="OPENAI_", env_file_path="path/to/.env") @@ -115,28 +113,186 @@ class OpenAISettings(TypedDict, total=False): api_key: SecretString | Callable[[], str | Awaitable[str]] | None base_url: str | None org_id: str | None - chat_model_id: str | None - responses_model_id: str | None - embedding_model_id: str | None + model: str | None + embedding_model: str | None + azure_endpoint: str | None + api_version: str | None + + +def _load_dotenv_values(*, env_file_path: str | None, env_file_encoding: str | None) -> dict[str, str]: + """Load dotenv values for non-standard environment variable aliases.""" + if env_file_path is None or not os.path.exists(env_file_path): + return {} + + raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=env_file_encoding or "utf-8") + return {key: value for key, value in raw_dotenv_values.items() if value is not None} + + +def _get_setting_from_alias( + name: str, + *, + dotenv_values_by_name: Mapping[str, str], +) -> str | None: + """Resolve a setting from an explicit env-var alias.""" + if dotenv_value := dotenv_values_by_name.get(name): + return dotenv_value + return os.getenv(name) + + +def load_openai_service_settings( + *, + model: str | None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None, + org_id: str | None, + base_url: str | None, + azure_endpoint: str | None, + api_version: str | None, + env_file_path: str | None, + env_file_encoding: str | None, + azure_model_env_vars: Sequence[str], + default_azure_api_version: str, +) -> tuple[OpenAISettings, bool]: + """Load OpenAI settings, including Azure OpenAI aliases. + + The generic OpenAI clients primarily read from ``OPENAI_*`` variables. When an + ``AZURE_OPENAI_ENDPOINT`` (or ``AZURE_OPENAI_BASE_URL``) is available and no + explicit OpenAI base URL is configured, this helper switches to Azure-specific + environment variables for endpoint, API key, model deployment, and API version. + """ + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + api_key=api_key, + org_id=org_id, + base_url=base_url, + model=model, + azure_endpoint=azure_endpoint, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + dotenv_values_by_name = _load_dotenv_values( + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + resolved_azure_endpoint = azure_endpoint + resolved_azure_base_url: str | None = None + if not openai_settings.get("base_url"): + if resolved_azure_endpoint is None: + resolved_azure_endpoint = _get_setting_from_alias( + "AZURE_OPENAI_ENDPOINT", + dotenv_values_by_name=dotenv_values_by_name, + ) + if resolved_azure_endpoint is None: + resolved_azure_base_url = _get_setting_from_alias( + "AZURE_OPENAI_BASE_URL", + dotenv_values_by_name=dotenv_values_by_name, + ) + if resolved_azure_base_url is not None: + openai_settings["base_url"] = resolved_azure_base_url + + use_azure_client = resolved_azure_endpoint is not None or resolved_azure_base_url is not None + if resolved_azure_endpoint is not None: + openai_settings["azure_endpoint"] = resolved_azure_endpoint + + if use_azure_client: + if api_key is None: + resolved_azure_api_key = _get_setting_from_alias( + "AZURE_OPENAI_API_KEY", + dotenv_values_by_name=dotenv_values_by_name, + ) + if resolved_azure_api_key is not None: + openai_settings["api_key"] = SecretString(resolved_azure_api_key) + + if model is None: + for env_var_name in azure_model_env_vars: + resolved_model = _get_setting_from_alias( + env_var_name, + dotenv_values_by_name=dotenv_values_by_name, + ) + if resolved_model is not None: + openai_settings["model"] = resolved_model + break + + if api_version is not None: + openai_settings["api_version"] = api_version + else: + resolved_api_version = _get_setting_from_alias( + "AZURE_OPENAI_API_VERSION", + dotenv_values_by_name=dotenv_values_by_name, + ) + openai_settings["api_version"] = resolved_api_version or default_azure_api_version + + return openai_settings, use_azure_client + + +def maybe_append_azure_endpoint_guidance(message: str, *, azure_endpoint: str | None) -> str: + """Append Azure endpoint guidance only when the configured endpoint shape looks suspicious.""" + if not azure_endpoint or not azure_endpoint.rstrip("/").endswith("/openai/v1"): + return message + + return ( + f"{message} If you are using Azure OpenAI key auth, pass the resource endpoint without " + "'/openai/v1' to 'azure_endpoint', or pass the full '/openai/v1' URL via 'base_url' instead." + ) + + +def get_api_key( + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None, +) -> str | Callable[[], str | Awaitable[str]] | None: + """Get the appropriate API key value for client initialization. + + Args: + api_key: The API key parameter which can be a string, SecretString, callable, or None. + + Returns: + For callable API keys: returns the callable directly. + For SecretString: returns the unwrapped secret value. + For string/None API keys: returns as-is. + """ + if isinstance(api_key, SecretString): + return api_key.get_secret_value() + + # Check version compatibility for callable API keys + if callable(api_key): + _check_openai_version_for_callable_api_key() + + return api_key # Pass callable, string, or None directly to OpenAI SDK class OpenAIBase(SerializationMixin): - """Base class for OpenAI Clients.""" + """Base class for OpenAI Clients. + + .. deprecated:: + ``OpenAIBase`` is deprecated and only used by ``OpenAIAssistantsClient`` + and ``AzureOpenAIAssistantsClient``. New clients should manage ``client`` + and ``model`` directly in their own ``__init__``. + """ INJECTABLE: ClassVar[set[str]] = {"client"} - def __init__(self, *, model_id: str | None = None, client: AsyncOpenAI | None = None, **kwargs: Any) -> None: + def __init__( + self, *, model: str | None = None, model_id: str | None = None, client: AsyncOpenAI | None = None, **kwargs: Any + ) -> None: """Initialize OpenAIBase. Keyword Args: client: The AsyncOpenAI client instance. - model_id: The AI model ID to use. + model: The AI model to use. + model_id: Deprecated alias for ``model``. **kwargs: Additional keyword arguments. """ + if model_id is not None and model is None: + import warnings + + warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) + model = model_id self.client = client - self.model_id = None - if model_id: - self.model_id = model_id.strip() + self.model: str | None = None + if model: + self.model = model.strip() # Call super().__init__() to continue MRO chain (e.g., RawChatClient) # Extract known kwargs that belong to other base classes @@ -201,13 +357,19 @@ class OpenAIBase(SerializationMixin): class OpenAIConfigMixin(OpenAIBase): - """Internal class for configuring a connection to an OpenAI service.""" + """Internal class for configuring a connection to an OpenAI service. + + .. deprecated:: + ``OpenAIConfigMixin`` is deprecated and only used by ``OpenAIAssistantsClient`` + and ``AzureOpenAIAssistantsClient``. New clients handle configuration + directly in their own ``__init__``. + """ OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] def __init__( self, - model_id: str, + model: str, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, @@ -222,7 +384,7 @@ class OpenAIConfigMixin(OpenAIBase): different types of AI model interactions, like chat or text completion. Args: - model_id: OpenAI model identifier. Must be non-empty. + model: OpenAI model identifier. Must be non-empty. Default to a preset value. api_key: OpenAI API key for authentication, or a callable that returns an API key. Must be non-empty. (Optional) @@ -269,7 +431,7 @@ class OpenAIConfigMixin(OpenAIBase): self.default_headers = None args = { - "model_id": model_id, + "model": model, "client": client, } if instruction_role: diff --git a/python/packages/openai/agent_framework_openai/py.typed b/python/packages/openai/agent_framework_openai/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml new file mode 100644 index 0000000000..6a89f5685c --- /dev/null +++ b/python/packages/openai/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "agent-framework-openai" +description = "OpenAI integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +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" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.0rc5", + "openai>=1.99.0,<3", + "packaging>=24.1,<25", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "azure: marks tests as Azure-backed OpenAI specific", + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_openai"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_openai" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_openai --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/openai/tests/assets/sample_image.jpg b/python/packages/openai/tests/assets/sample_image.jpg new file mode 100644 index 0000000000..ea6486656f Binary files /dev/null and b/python/packages/openai/tests/assets/sample_image.jpg differ diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py new file mode 100644 index 0000000000..1ef52baf81 --- /dev/null +++ b/python/packages/openai/tests/openai/conftest.py @@ -0,0 +1,203 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from pytest import fixture + + +def _reset_env(monkeypatch, env_names: list[str]) -> None: # type: ignore + for env_name in env_names: + monkeypatch.delenv(env_name, raising=False) # type: ignore + + +# region Connector Settings fixtures +@fixture +def exclude_list(request: Any) -> list[str]: + """Fixture that returns a list of environment variables to exclude.""" + return request.param if hasattr(request, "param") else [] + + +@fixture +def override_env_param_dict(request: Any) -> dict[str, str]: + """Fixture that returns a dict of environment variables to override.""" + return request.param if hasattr(request, "param") else {} + + +@fixture() +def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore + """Fixture to set environment variables for OpenAISettings.""" + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + _reset_env( + monkeypatch, + [ + "OPENAI_API_KEY", + "OPENAI_ORG_ID", + "OPENAI_MODEL", + "OPENAI_EMBEDDING_MODEL", + "OPENAI_TEXT_MODEL_ID", + "OPENAI_TEXT_TO_IMAGE_MODEL_ID", + "OPENAI_AUDIO_TO_TEXT_MODEL_ID", + "OPENAI_TEXT_TO_AUDIO_MODEL_ID", + "OPENAI_REALTIME_MODEL_ID", + "OPENAI_API_VERSION", + "OPENAI_BASE_URL", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_DEPLOYMENT_NAME", + "AZURE_OPENAI_API_VERSION", + ], + ) + + env_vars = { + "OPENAI_API_KEY": "test-dummy-key", + "OPENAI_ORG_ID": "test_org_id", + "OPENAI_MODEL": "test_model_id", + "OPENAI_EMBEDDING_MODEL": "test_embedding_model_id", + "OPENAI_TEXT_MODEL_ID": "test_text_model_id", + "OPENAI_TEXT_TO_IMAGE_MODEL_ID": "test_text_to_image_model_id", + "OPENAI_AUDIO_TO_TEXT_MODEL_ID": "test_audio_to_text_model_id", + "OPENAI_TEXT_TO_AUDIO_MODEL_ID": "test_text_to_audio_model_id", + "OPENAI_REALTIME_MODEL_ID": "test_realtime_model_id", + } + + env_vars.update(override_env_param_dict) # type: ignore + + for key, value in env_vars.items(): + if key in exclude_list: + monkeypatch.delenv(key, raising=False) # type: ignore + continue + monkeypatch.setenv(key, value) # type: ignore + + return env_vars + + +@fixture() +def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore + """Fixture to set environment variables for Azure-backed OpenAI tests.""" + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + _reset_env( + monkeypatch, + [ + "OPENAI_API_KEY", + "OPENAI_ORG_ID", + "OPENAI_MODEL", + "OPENAI_EMBEDDING_MODEL", + "OPENAI_TEXT_MODEL_ID", + "OPENAI_TEXT_TO_IMAGE_MODEL_ID", + "OPENAI_AUDIO_TO_TEXT_MODEL_ID", + "OPENAI_TEXT_TO_AUDIO_MODEL_ID", + "OPENAI_REALTIME_MODEL_ID", + "OPENAI_API_VERSION", + "OPENAI_BASE_URL", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_DEPLOYMENT_NAME", + "AZURE_OPENAI_API_VERSION", + ], + ) + + env_vars = { + "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.openai.azure.com", + "AZURE_OPENAI_DEPLOYMENT_NAME": "test_deployment", + "AZURE_OPENAI_API_KEY": "test_api_key", + "AZURE_OPENAI_API_VERSION": "2024-12-01-preview", + } + + env_vars.update(override_env_param_dict) # type: ignore + + for key, value in env_vars.items(): + if key in exclude_list: + monkeypatch.delenv(key, raising=False) # type: ignore + continue + monkeypatch.setenv(key, value) # type: ignore + + return env_vars + + +# region Observability fixtures +@fixture +def enable_instrumentation(request: Any) -> bool: + """Fixture that returns a boolean indicating if Otel is enabled.""" + return request.param if hasattr(request, "param") else True + + +@fixture +def enable_sensitive_data(request: Any) -> bool: + """Fixture that returns a boolean indicating if sensitive data is enabled.""" + return request.param if hasattr(request, "param") else True + + +@fixture +def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_data: bool) -> Generator[SpanExporter]: + """Fixture to remove environment variables for ObservabilitySettings.""" + env_vars = [ + "ENABLE_INSTRUMENTATION", + "ENABLE_SENSITIVE_DATA", + "ENABLE_CONSOLE_EXPORTERS", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_SERVICE_NAME", + "OTEL_SERVICE_VERSION", + "OTEL_RESOURCE_ATTRIBUTES", + ] + + for key in env_vars: + monkeypatch.delenv(key, raising=False) # type: ignore + monkeypatch.setenv("ENABLE_INSTRUMENTATION", str(enable_instrumentation)) # type: ignore + if not enable_instrumentation: + enable_sensitive_data = False + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", str(enable_sensitive_data)) # type: ignore + import importlib + + import agent_framework.observability as observability + from opentelemetry import trace + + importlib.reload(observability) + + observability_settings = observability.ObservabilitySettings() + + if enable_instrumentation or enable_sensitive_data: + from opentelemetry.sdk.trace import TracerProvider + + tracer_provider = TracerProvider(resource=observability_settings._resource) + trace.set_tracer_provider(tracer_provider) + + monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore + + with ( + patch("agent_framework.observability.OBSERVABILITY_SETTINGS", observability_settings), + patch("agent_framework.observability.configure_otel_providers"), + ): + exporter = InMemorySpanExporter() + if enable_instrumentation or enable_sensitive_data: + tracer_provider = trace.get_tracer_provider() + if not hasattr(tracer_provider, "add_span_processor"): + raise RuntimeError("Tracer provider does not support adding span processors.") + + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore + + yield exporter + exporter.clear() diff --git a/python/packages/core/tests/openai/test_assistant_provider.py b/python/packages/openai/tests/openai/test_assistant_provider.py similarity index 98% rename from python/packages/core/tests/openai/test_assistant_provider.py rename to python/packages/openai/tests/openai/test_assistant_provider.py index 2aa8c89f84..c05ea950a6 100644 --- a/python/packages/core/tests/openai/test_assistant_provider.py +++ b/python/packages/openai/tests/openai/test_assistant_provider.py @@ -5,12 +5,12 @@ from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework import Agent, normalize_tools, tool from openai.types.beta.assistant import Assistant from pydantic import BaseModel, Field -from agent_framework import Agent, normalize_tools, tool -from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from agent_framework.openai._shared import from_assistant_tools, to_assistant_tools +from agent_framework_openai import OpenAIAssistantProvider, OpenAIAssistantsClient +from agent_framework_openai._shared import from_assistant_tools, to_assistant_tools # region Test Helpers @@ -130,13 +130,12 @@ class TestOpenAIAssistantProviderInit: from unittest.mock import patch # Mock load_settings to return a dict with None for api_key - with patch("agent_framework.openai._assistant_provider.load_settings") as mock_load: + with patch("agent_framework_openai._assistant_provider.load_settings") as mock_load: mock_load.return_value = { "api_key": None, "org_id": None, "base_url": None, - "chat_model_id": None, - "responses_model_id": None, + "model": None, } with pytest.raises(ValueError) as exc_info: @@ -772,7 +771,7 @@ class TestOpenAIAssistantProviderIntegration: agent = await provider.create_agent( name="IntegrationTestAgent", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful assistant. Respond briefly.", ) @@ -797,7 +796,7 @@ class TestOpenAIAssistantProviderIntegration: agent = await provider.create_agent( name="TimeAgent", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful assistant.", tools=[get_current_time], ) diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/openai/tests/openai/test_openai_assistants_client.py similarity index 76% rename from python/packages/core/tests/openai/test_openai_assistants_client.py rename to python/packages/openai/tests/openai/test_openai_assistants_client.py index 21f7173ca3..54171ca7ca 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/openai/tests/openai/test_openai_assistants_client.py @@ -2,11 +2,17 @@ import json import logging -import os from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent_framework import ( + ChatResponseUpdate, + Content, + Message, + SupportsChatGetResponse, + tool, +) from openai.types.beta.threads import ( FileCitationAnnotation, FilePathAnnotation, @@ -22,31 +28,12 @@ from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAn from openai.types.beta.threads.runs import RunStep from pydantic import Field -from agent_framework import ( - Agent, - AgentResponse, - AgentResponseUpdate, - AgentSession, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework.openai import OpenAIAssistantsClient - -skip_if_openai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), - reason="No real OPENAI_API_KEY provided; skipping integration tests.", -) - -INTEGRATION_TEST_MODEL = "gpt-4.1-nano" +from agent_framework_openai import OpenAIAssistantsClient def create_test_openai_assistants_client( mock_async_openai: MagicMock, - model_id: str | None = None, + model: str | None = None, assistant_id: str | None = None, assistant_name: str | None = None, thread_id: str | None = None, @@ -54,7 +41,7 @@ def create_test_openai_assistants_client( ) -> OpenAIAssistantsClient: """Helper function to create OpenAIAssistantsClient instances for testing.""" client = OpenAIAssistantsClient( - model_id=model_id or "gpt-4", + model=model or "gpt-4", assistant_id=assistant_id, assistant_name=assistant_name, thread_id=thread_id, @@ -120,11 +107,11 @@ def mock_async_openai() -> MagicMock: def test_init_with_client(mock_async_openai: MagicMock) -> None: """Test OpenAIAssistantsClient initialization with existing client.""" client = create_test_openai_assistants_client( - mock_async_openai, model_id="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id" + mock_async_openai, model="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id" ) assert client.client is mock_async_openai - assert client.model_id == "gpt-4" + assert client.model == "gpt-4" assert client.assistant_id == "existing-assistant-id" assert client.thread_id == "test-thread-id" assert not client._should_delete_assistant # type: ignore @@ -137,7 +124,7 @@ def test_init_auto_create_client( ) -> None: """Test OpenAIAssistantsClient initialization with auto-created client.""" client = OpenAIAssistantsClient( - model_id=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], assistant_name="TestAssistant", api_key=openai_unit_test_env["OPENAI_API_KEY"], org_id=openai_unit_test_env["OPENAI_ORG_ID"], @@ -145,7 +132,7 @@ def test_init_auto_create_client( ) assert client.client is mock_async_openai - assert client.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] + assert client.model == openai_unit_test_env["OPENAI_MODEL"] assert client.assistant_id is None assert client.assistant_name == "TestAssistant" assert not client._should_delete_assistant # type: ignore @@ -155,10 +142,10 @@ def test_init_validation_fail() -> None: """Test OpenAIAssistantsClient initialization with validation failure.""" with pytest.raises(ValueError): # Force failure by providing invalid model ID type - OpenAIAssistantsClient(model_id=123, api_key="valid-key") # type: ignore + OpenAIAssistantsClient(model=123, api_key="valid-key") # type: ignore -@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True) +@pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None: """Test OpenAIAssistantsClient initialization with missing model ID.""" with pytest.raises(ValueError): @@ -169,7 +156,7 @@ def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None: """Test OpenAIAssistantsClient initialization with missing API key.""" with pytest.raises(ValueError): - OpenAIAssistantsClient(model_id="gpt-4") + OpenAIAssistantsClient(model="gpt-4") def test_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None: @@ -177,12 +164,12 @@ def test_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None default_headers = {"X-Unit-Test": "test-guid"} client = OpenAIAssistantsClient( - model_id="gpt-4", + model="gpt-4", api_key=openai_unit_test_env["OPENAI_API_KEY"], default_headers=default_headers, ) - assert client.model_id == "gpt-4" + assert client.model == "gpt-4" assert isinstance(client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers @@ -208,7 +195,7 @@ async def test_get_assistant_id_or_create_create_new( mock_async_openai: MagicMock, ) -> None: """Test _get_assistant_id_or_create when creating a new assistant.""" - client = create_test_openai_assistants_client(mock_async_openai, model_id="gpt-4", assistant_name="TestAssistant") + client = create_test_openai_assistants_client(mock_async_openai, model="gpt-4", assistant_name="TestAssistant") assistant_id = await client._get_assistant_id_or_create() # type: ignore @@ -265,7 +252,7 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: # Test basic initialization and to_dict client = OpenAIAssistantsClient( - model_id="gpt-4", + model="gpt-4", assistant_id="test-assistant-id", assistant_name="TestAssistant", thread_id="test-thread-id", @@ -276,7 +263,7 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: dumped_settings = client.to_dict() - assert dumped_settings["model_id"] == "gpt-4" + assert dumped_settings["model"] == "gpt-4" assert dumped_settings["assistant_id"] == "test-assistant-id" assert dumped_settings["assistant_name"] == "TestAssistant" assert dumped_settings["thread_id"] == "test-thread-id" @@ -728,7 +715,7 @@ def test_parse_run_step_with_code_interpreter_tool_call(mock_async_openai: Magic """Test _parse_run_step_tool_call with code_interpreter type creates CodeInterpreterToolCallContent.""" client = create_test_openai_assistants_client( mock_async_openai, - model_id="test-model", + model="test-model", assistant_id="test-assistant", ) @@ -766,7 +753,7 @@ def test_parse_run_step_with_mcp_tool_call(mock_async_openai: MagicMock) -> None """Test _parse_run_step_tool_call with mcp type creates MCPServerToolCallContent.""" client = create_test_openai_assistants_client( mock_async_openai, - model_id="test-model", + model="test-model", assistant_id="test-assistant", ) @@ -806,7 +793,7 @@ def test_prepare_options_basic(mock_async_openai: MagicMock) -> None: # Create basic chat options as a dict options = { "max_tokens": 100, - "model_id": "gpt-4", + "model": "gpt-4", "temperature": 0.7, "top_p": 0.9, } @@ -1197,372 +1184,6 @@ def get_weather( return f"The weather in {location} is sunny with a high of 25°C." -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_get_response() -> None: - """Test OpenAI Assistants Client response.""" - async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="The weather in Seattle is currently sunny with a high of 25°C. " - "It's a beautiful day for outdoor activities.", - ) - ) - messages.append(Message(role="user", text="What's the weather like today?")) - - # Test that the client can be used to get a response - response = await openai_assistants_client.get_response(messages=messages) - - assert response is not None - assert isinstance(response, ChatResponse) - assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_get_response_tools() -> None: - """Test OpenAI Assistants Client response with tools.""" - async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like in Seattle?")) - - # Test that the client can be used to get a response - response = await openai_assistants_client.get_response( - messages=messages, - options={"tools": [get_weather], "tool_choice": "auto"}, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert any(word in response.text.lower() for word in ["sunny", "25", "weather"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_streaming() -> None: - """Test OpenAI Assistants Client streaming response.""" - async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="The weather in Seattle is currently sunny with a high of 25°C. " - "It's a beautiful day for outdoor activities.", - ) - ) - messages.append(Message(role="user", text="What's the weather like today?")) - - # Test that the client can be used to get a response - response = openai_assistants_client.get_response(stream=True, messages=messages) - - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_streaming_tools() -> None: - """Test OpenAI Assistants Client streaming response with tools.""" - async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like in Seattle?")) - - # Test that the client can be used to get a response - response = openai_assistants_client.get_response( - stream=True, - messages=messages, - options={ - "tools": [get_weather], - "tool_choice": "auto", - }, - ) - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - - assert any(word in full_message.lower() for word in ["sunny", "25", "weather"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_with_existing_assistant() -> None: - """Test OpenAI Assistants Client with existing assistant ID.""" - # First create an assistant to use in the test - async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as temp_client: - # Get the assistant ID by triggering assistant creation - messages = [Message(role="user", text="Hello")] - await temp_client.get_response(messages=messages) - assistant_id = temp_client.assistant_id - - # Now test using the existing assistant - async with OpenAIAssistantsClient( - model_id=INTEGRATION_TEST_MODEL, assistant_id=assistant_id - ) as openai_assistants_client: - assert isinstance(openai_assistants_client, SupportsChatGetResponse) - assert openai_assistants_client.assistant_id == assistant_id - - messages = [Message(role="user", text="What can you do?")] - - # Test that the client can be used to get a response - response = await openai_assistants_client.get_response(messages=messages) - - assert response is not None - assert isinstance(response, ChatResponse) - assert len(response.text) > 0 - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -@pytest.mark.skip(reason="OpenAI file search functionality is currently broken - tracked in GitHub issue") -async def test_file_search() -> None: - """Test OpenAI Assistants Client response.""" - async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like today?")) - - file_id, vector_store = await create_vector_store(openai_assistants_client) - response = await openai_assistants_client.get_response( - messages=messages, - options={ - "tools": [OpenAIAssistantsClient.get_file_search_tool()], - "tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}}, - }, - ) - await delete_vector_store(openai_assistants_client, file_id, vector_store.vector_store_id) - - assert response is not None - assert isinstance(response, ChatResponse) - assert any(word in response.text.lower() for word in ["sunny", "25", "weather"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -@pytest.mark.skip(reason="OpenAI file search functionality is currently broken - tracked in GitHub issue") -async def test_file_search_streaming() -> None: - """Test OpenAI Assistants Client response.""" - async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client: - assert isinstance(openai_assistants_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="What's the weather like today?")) - - file_id, vector_store = await create_vector_store(openai_assistants_client) - response = openai_assistants_client.get_response( - stream=True, - messages=messages, - options={ - "tools": [OpenAIAssistantsClient.get_file_search_tool()], - "tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}}, - }, - ) - - assert response is not None - full_message: str = "" - async for chunk in response: - assert chunk is not None - assert isinstance(chunk, ChatResponseUpdate) - for content in chunk.contents: - if content.type == "text" and content.text: - full_message += content.text - await delete_vector_store(openai_assistants_client, file_id, vector_store.vector_store_id) - - assert any(word in full_message.lower() for word in ["sunny", "25", "weather"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_openai_assistants_agent_basic_run(): - """Test Agent basic run functionality with OpenAIAssistantsClient.""" - async with Agent( - client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), - ) as agent: - # Run a simple query - response = await agent.run("Hello! Please respond with 'Hello World' exactly.") - - # Validate response - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - assert "Hello World" in response.text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_openai_assistants_agent_basic_run_streaming(): - """Test Agent basic streaming functionality with OpenAIAssistantsClient.""" - async with Agent( - client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), - ) as agent: - # Run streaming query - full_message: str = "" - async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True): - assert chunk is not None - assert isinstance(chunk, AgentResponseUpdate) - if chunk.text: - full_message += chunk.text - - # Validate streaming response - assert len(full_message) > 0 - assert "streaming response test" in full_message.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_openai_assistants_agent_session_persistence(): - """Test Agent session persistence across runs with OpenAIAssistantsClient.""" - async with Agent( - client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), - instructions="You are a helpful assistant with good memory.", - ) as agent: - # Create a new session that will be reused - session = agent.create_session() - - # First message - establish context - first_response = await agent.run( - "Remember this number: 42. What number did I just tell you to remember?", session=session - ) - assert isinstance(first_response, AgentResponse) - assert "42" in first_response.text - - # Second message - test conversation memory - second_response = await agent.run( - "What number did I tell you to remember in my previous message?", session=session - ) - assert isinstance(second_response, AgentResponse) - assert "42" in second_response.text - - # Verify session has been populated with conversation ID - assert session.service_session_id is not None - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_openai_assistants_agent_existing_session_id(): - """Test Agent with existing session ID to continue conversations across agent instances.""" - # First, create a conversation and capture the session ID - existing_session_id = None - - async with Agent( - client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) as agent: - # Start a conversation and get the session ID - session = agent.create_session() - response1 = await agent.run("What's the weather in Paris?", session=session) - - # Validate first response - assert isinstance(response1, AgentResponse) - assert response1.text is not None - assert any(word in response1.text.lower() for word in ["weather", "paris"]) - - # The session ID is set after the first response - existing_session_id = session.service_session_id - assert existing_session_id is not None - - # Now continue with the same session ID in a new agent instance - - async with Agent( - client=OpenAIAssistantsClient(thread_id=existing_session_id), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) as agent: - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) - - # Ask about the previous conversation - response2 = await agent.run("What was the last city I asked about?", session=session) - - # Validate that the agent remembers the previous conversation - assert isinstance(response2, AgentResponse) - assert response2.text is not None - # Should reference Paris from the previous conversation - assert "paris" in response2.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_openai_assistants_agent_code_interpreter(): - """Test Agent with code interpreter through OpenAIAssistantsClient.""" - - async with Agent( - client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), - instructions="You are a helpful assistant that can write and execute Python code.", - tools=[OpenAIAssistantsClient.get_code_interpreter_tool()], - ) as agent: - # Request code execution - response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.") - - # Validate response - assert isinstance(response, AgentResponse) - assert response.text is not None - # Factorial of 5 is 120 - assert "120" in response.text or "factorial" in response.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -async def test_agent_level_tool_persistence(): - """Test that agent-level tools persist across multiple runs with OpenAI Assistants Client.""" - - async with Agent( - client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL), - instructions="You are a helpful assistant that uses available tools.", - tools=[get_weather], # Agent-level tool - ) as agent: - # First run - agent-level tool should be available - first_response = await agent.run("What's the weather like in Chicago?") - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - # Should use the agent-level weather tool - assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) - - # Second run - agent-level tool should still be available (persistence test) - second_response = await agent.run("What's the weather in Miami?") - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - # Should use the agent-level weather tool again - assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"]) - - # Callable API Key Tests def test_with_callable_api_key() -> None: """Test OpenAIAssistantsClient initialization with callable API key.""" @@ -1570,10 +1191,10 @@ def test_with_callable_api_key() -> None: async def get_api_key() -> str: return "test-api-key-123" - client = OpenAIAssistantsClient(model_id="gpt-4o", api_key=get_api_key) + client = OpenAIAssistantsClient(model="gpt-4o", api_key=get_api_key) # Verify client was created successfully - assert client.model_id == "gpt-4o" + assert client.model == "gpt-4o" # OpenAI SDK now manages callable API keys internally assert client.client is not None diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py similarity index 89% rename from python/packages/core/tests/openai/test_openai_responses_client.py rename to python/packages/openai/tests/openai/test_openai_chat_client.py index 6a2c9f5173..897fe5f913 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -9,24 +9,6 @@ from typing import Annotated, Any from unittest.mock import MagicMock, patch import pytest -from openai import BadRequestError -from openai.types.responses.response_reasoning_item import Summary -from openai.types.responses.response_reasoning_summary_text_delta_event import ( - ResponseReasoningSummaryTextDeltaEvent, -) -from openai.types.responses.response_reasoning_summary_text_done_event import ( - ResponseReasoningSummaryTextDoneEvent, -) -from openai.types.responses.response_reasoning_text_delta_event import ( - ResponseReasoningTextDeltaEvent, -) -from openai.types.responses.response_reasoning_text_done_event import ( - ResponseReasoningTextDoneEvent, -) -from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent -from pydantic import BaseModel -from pytest import param - from agent_framework import ( Agent, ChatOptions, @@ -47,9 +29,27 @@ from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidRequestException, ) -from agent_framework.openai import OpenAIResponsesClient -from agent_framework.openai._exceptions import OpenAIContentFilterException -from agent_framework.openai._responses_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY +from openai import BadRequestError +from openai.types.responses.response_reasoning_item import Summary +from openai.types.responses.response_reasoning_summary_text_delta_event import ( + ResponseReasoningSummaryTextDeltaEvent, +) +from openai.types.responses.response_reasoning_summary_text_done_event import ( + ResponseReasoningSummaryTextDoneEvent, +) +from openai.types.responses.response_reasoning_text_delta_event import ( + ResponseReasoningTextDeltaEvent, +) +from openai.types.responses.response_reasoning_text_done_event import ( + ResponseReasoningTextDoneEvent, +) +from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent +from pydantic import BaseModel +from pytest import param + +from agent_framework_openai import OpenAIChatClient +from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY +from agent_framework_openai._exceptions import OpenAIContentFilterException skip_if_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), @@ -65,7 +65,7 @@ class OutputStruct(BaseModel): async def create_vector_store( - client: OpenAIResponsesClient, + client: OpenAIChatClient, ) -> tuple[str, Content]: """Create a vector store with sample documents for testing.""" file = await client.client.files.create( @@ -87,7 +87,7 @@ async def create_vector_store( return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) -async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None: +async def delete_vector_store(client: OpenAIChatClient, file_id: str, vector_store_id: str) -> None: """Delete the vector store after tests.""" await client.client.vector_stores.delete(vector_store_id=vector_store_id) @@ -103,24 +103,24 @@ async def get_weather(location: Annotated[str, "The location as a city name"]) - def test_init(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization - openai_responses_client = OpenAIResponsesClient() + openai_responses_client = OpenAIChatClient() - assert openai_responses_client.model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"] + assert openai_responses_client.model == openai_unit_test_env["OPENAI_MODEL"] assert isinstance(openai_responses_client, SupportsChatGetResponse) def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): - OpenAIResponsesClient(api_key="34523", model_id={"test": "dict"}) # type: ignore + OpenAIChatClient(api_key="34523", model={"test": "dict"}) # type: ignore def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization model_id = "test_model_id" - openai_responses_client = OpenAIResponsesClient(model_id=model_id) + openai_responses_client = OpenAIChatClient(model=model_id) - assert openai_responses_client.model_id == model_id + assert openai_responses_client.model == model_id assert isinstance(openai_responses_client, SupportsChatGetResponse) @@ -128,11 +128,11 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} # Test successful initialization - openai_responses_client = OpenAIResponsesClient( + openai_responses_client = OpenAIChatClient( default_headers=default_headers, ) - assert openai_responses_client.model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"] + assert openai_responses_client.model == openai_unit_test_env["OPENAI_MODEL"] assert isinstance(openai_responses_client, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers @@ -141,10 +141,10 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: assert openai_responses_client.client.default_headers[key] == value -@pytest.mark.parametrize("exclude_list", [["OPENAI_RESPONSES_MODEL_ID"]], indirect=True) +@pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ValueError): - OpenAIResponsesClient() + OpenAIChatClient() @pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) @@ -152,8 +152,8 @@ def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" with pytest.raises(ValueError): - OpenAIResponsesClient( - model_id=model_id, + OpenAIChatClient( + model=model_id, ) @@ -161,14 +161,14 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} settings = { - "model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"], + "model": openai_unit_test_env["OPENAI_MODEL"], "api_key": openai_unit_test_env["OPENAI_API_KEY"], "default_headers": default_headers, } - openai_responses_client = OpenAIResponsesClient.from_dict(settings) + openai_responses_client = OpenAIChatClient.from_dict(settings) dumped_settings = openai_responses_client.to_dict() - assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"] + assert dumped_settings["model"] == openai_unit_test_env["OPENAI_MODEL"] # Assert that the default header we added is present in the dumped_settings default headers for key, value in default_headers.items(): assert key in dumped_settings["default_headers"] @@ -179,14 +179,14 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None: settings = { - "model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"], + "model": openai_unit_test_env["OPENAI_MODEL"], "api_key": openai_unit_test_env["OPENAI_API_KEY"], "org_id": openai_unit_test_env["OPENAI_ORG_ID"], } - openai_responses_client = OpenAIResponsesClient.from_dict(settings) + openai_responses_client = OpenAIChatClient.from_dict(settings) dumped_settings = openai_responses_client.to_dict() - assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"] + assert dumped_settings["model"] == openai_unit_test_env["OPENAI_MODEL"] assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"] # Assert that the 'User-Agent' header is not present in the dumped_settings default headers assert "User-Agent" not in dumped_settings.get("default_headers", {}) @@ -195,7 +195,7 @@ def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None: async def test_get_response_with_invalid_input() -> None: """Test get_response with invalid inputs to trigger exception handling.""" - client = OpenAIResponsesClient(model_id="invalid-model", api_key="test-key") + client = OpenAIChatClient(model="invalid-model", api_key="test-key") # Test with empty messages which should trigger ChatClientInvalidRequestException with pytest.raises(ChatClientInvalidRequestException, match="Messages are required"): @@ -204,7 +204,7 @@ async def test_get_response_with_invalid_input() -> None: async def test_get_response_with_all_parameters() -> None: """Test get_response with all possible parameters to cover parameter handling logic.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test with comprehensive parameter set - should fail due to invalid API key with pytest.raises(ChatClientException): await client.get_response( @@ -214,7 +214,7 @@ async def test_get_response_with_all_parameters() -> None: "instructions": "You are a helpful assistant", "max_tokens": 100, "parallel_tool_calls": True, - "model_id": "gpt-4", + "model": "gpt-4", "previous_response_id": "prev-123", "reasoning": {"chain_of_thought": "enabled"}, "service_tier": "auto", @@ -236,10 +236,10 @@ async def test_get_response_with_all_parameters() -> None: @pytest.mark.asyncio async def test_web_search_tool_with_location() -> None: """Test web search tool with location parameters.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test web search tool with location using static method - web_search_tool = OpenAIResponsesClient.get_web_search_tool( + web_search_tool = OpenAIChatClient.get_web_search_tool( user_location={ "city": "Seattle", "country": "US", @@ -258,10 +258,10 @@ async def test_web_search_tool_with_location() -> None: async def test_code_interpreter_tool_variations() -> None: """Test HostedCodeInterpreterTool with and without file inputs.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test code interpreter using static method - code_tool = OpenAIResponsesClient.get_code_interpreter_tool() + code_tool = OpenAIChatClient.get_code_interpreter_tool() with pytest.raises(ChatClientException): await client.get_response( @@ -270,7 +270,7 @@ async def test_code_interpreter_tool_variations() -> None: ) # Test code interpreter with files using static method - code_tool_with_files = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) + code_tool_with_files = OpenAIChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) with pytest.raises(ChatClientException): await client.get_response( @@ -281,7 +281,7 @@ async def test_code_interpreter_tool_variations() -> None: async def test_content_filter_exception() -> None: """Test that content filter errors in get_response are properly handled.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Mock a BadRequestError with content_filter code mock_error = BadRequestError( @@ -302,10 +302,10 @@ async def test_content_filter_exception() -> None: async def test_hosted_file_search_tool_validation() -> None: """Test get_response HostedFileSearchTool validation.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test file search tool with vector store IDs - file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=["vs_123"]) + file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=["vs_123"]) # Test using file search tool - may raise various exceptions depending on API response with pytest.raises((ValueError, ChatClientInvalidRequestException, ChatClientException)): @@ -317,7 +317,7 @@ async def test_hosted_file_search_tool_validation() -> None: async def test_chat_message_parsing_with_function_calls() -> None: """Test get_response message preparation with function call and result content types in conversation flow.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create messages with function call and result content function_call = Content.from_function_call( @@ -342,7 +342,7 @@ async def test_chat_message_parsing_with_function_calls() -> None: async def test_response_format_parse_path() -> None: """Test get_response response_format parsing path.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Mock successful parse response mock_parsed_response = MagicMock() @@ -363,12 +363,12 @@ async def test_response_format_parse_path() -> None: ) assert response.response_id == "parsed_response_123" assert response.conversation_id == "parsed_response_123" - assert response.model_id == "test-model" + assert response.model == "test-model" async def test_response_format_parse_path_with_conversation_id() -> None: """Test get_response response_format parsing path with set conversation ID.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Mock successful parse response mock_parsed_response = MagicMock() @@ -390,12 +390,12 @@ async def test_response_format_parse_path_with_conversation_id() -> None: ) assert response.response_id == "parsed_response_123" assert response.conversation_id == "conversation_456" - assert response.model_id == "test-model" + assert response.model == "test-model" async def test_bad_request_error_non_content_filter() -> None: """Test get_response BadRequestError without content_filter.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Mock a BadRequestError without content_filter code mock_error = BadRequestError( @@ -417,7 +417,7 @@ async def test_bad_request_error_non_content_filter() -> None: async def test_streaming_content_filter_exception_handling() -> None: """Test that content filter errors in get_response(..., stream=True) are properly handled.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Mock the OpenAI client to raise a BadRequestError with content_filter code with patch.object(client.client.responses, "create") as mock_create: @@ -436,7 +436,7 @@ async def test_streaming_content_filter_exception_handling() -> None: def test_response_content_creation_with_annotations() -> None: """Test _parse_response_from_openai with different annotation types.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with annotated text content mock_response = MagicMock() @@ -476,7 +476,7 @@ def test_response_content_creation_with_annotations() -> None: def test_response_content_creation_with_refusal() -> None: """Test _parse_response_from_openai with refusal content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with refusal content mock_response = MagicMock() @@ -506,7 +506,7 @@ def test_response_content_creation_with_refusal() -> None: def test_response_content_creation_with_reasoning() -> None: """Test _parse_response_from_openai with reasoning content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with reasoning content mock_response = MagicMock() @@ -536,7 +536,7 @@ def test_response_content_creation_with_reasoning() -> None: def test_response_content_keeps_reasoning_and_function_calls_in_one_message() -> None: """Reasoning + function calls should parse into one assistant message.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -589,7 +589,7 @@ def test_response_content_keeps_reasoning_and_function_calls_in_one_message() -> def test_response_content_creation_with_code_interpreter() -> None: """Test _parse_response_from_openai with code interpreter outputs.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with code interpreter outputs mock_response = MagicMock() @@ -630,7 +630,7 @@ def test_response_content_creation_with_code_interpreter() -> None: def test_get_shell_tool_basic() -> None: """Test get_shell_tool returns hosted shell config with default auto environment.""" - tool = OpenAIResponsesClient.get_shell_tool() + tool = OpenAIChatClient.get_shell_tool() assert tool.type == "shell" assert tool.environment.type == "container_auto" @@ -638,7 +638,7 @@ def test_get_shell_tool_basic() -> None: def test_get_shell_tool_rejects_local_without_func() -> None: """Local environment requires a local function executor.""" with pytest.raises(ValueError, match="Local shell requires func"): - OpenAIResponsesClient.get_shell_tool(environment={"type": "local"}) + OpenAIChatClient.get_shell_tool(environment={"type": "local"}) def test_get_shell_tool_rejects_environment_config_with_func() -> None: @@ -648,7 +648,7 @@ def test_get_shell_tool_rejects_environment_config_with_func() -> None: return command with pytest.raises(ValueError, match="environment config is not supported"): - OpenAIResponsesClient.get_shell_tool( + OpenAIChatClient.get_shell_tool( func=local_exec, environment={"type": "container_auto"}, ) @@ -656,12 +656,12 @@ def test_get_shell_tool_rejects_environment_config_with_func() -> None: def test_get_shell_tool_local_executor_maps_to_shell_tool() -> None: """Test local shell FunctionTool maps to OpenAI shell tool declaration.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") def local_exec(command: str) -> str: return command - local_shell_tool = OpenAIResponsesClient.get_shell_tool( + local_shell_tool = OpenAIChatClient.get_shell_tool( func=local_exec, approval_mode="never_require", ) @@ -680,7 +680,7 @@ def test_get_shell_tool_reuses_function_tool_instance() -> None: def run_shell(command: str) -> str: return command - shell_tool = OpenAIResponsesClient.get_shell_tool( + shell_tool = OpenAIChatClient.get_shell_tool( func=run_shell, description="Run local shell command", approval_mode="always_require", @@ -695,12 +695,12 @@ def test_get_shell_tool_reuses_function_tool_instance() -> None: def test_response_content_creation_with_local_shell_call_maps_to_function_call() -> None: """Test local_shell_call is translated into function_call for invocation loop.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") def local_exec(command: str) -> str: return command - local_shell_tool = OpenAIResponsesClient.get_shell_tool(func=local_exec) + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec) mock_response = MagicMock() mock_response.output_parsed = None @@ -738,14 +738,14 @@ def test_response_content_creation_with_local_shell_call_maps_to_function_call() @pytest.mark.asyncio async def test_local_shell_tool_is_invoked_in_function_loop() -> None: """Test local shell call executes executor and sends local_shell_call_output.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") executed_commands: list[str] = [] def local_exec(command: str) -> str: executed_commands.append(command) return "Python 3.13.0" - local_shell_tool = OpenAIResponsesClient.get_shell_tool( + local_shell_tool = OpenAIChatClient.get_shell_tool( func=local_exec, approval_mode="never_require", ) @@ -810,14 +810,14 @@ async def test_local_shell_tool_is_invoked_in_function_loop() -> None: @pytest.mark.asyncio async def test_shell_call_is_invoked_as_local_shell_function_loop() -> None: """Test shell_call maps to local function invocation and returns shell_call_output.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") executed_commands: list[str] = [] def local_exec(command: str) -> str: executed_commands.append(command) return "Python 3.13.0" - local_shell_tool = OpenAIResponsesClient.get_shell_tool( + local_shell_tool = OpenAIChatClient.get_shell_tool( func=local_exec, approval_mode="never_require", ) @@ -885,7 +885,7 @@ async def test_shell_call_is_invoked_as_local_shell_function_loop() -> None: def test_response_content_creation_with_shell_call() -> None: """Test _parse_response_from_openai with shell_call output.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -924,7 +924,7 @@ def test_response_content_creation_with_shell_call() -> None: def test_response_content_creation_with_shell_call_output() -> None: """Test _parse_response_from_openai with shell_call_output output.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -970,7 +970,7 @@ def test_response_content_creation_with_shell_call_output() -> None: def test_response_content_creation_with_shell_call_timeout() -> None: """Test _parse_response_from_openai with shell_call_output that timed out.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -1010,7 +1010,7 @@ def test_response_content_creation_with_shell_call_timeout() -> None: def test_response_content_creation_with_function_call() -> None: """Test _parse_response_from_openai with function call content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with function call mock_response = MagicMock() @@ -1042,7 +1042,7 @@ def test_response_content_creation_with_function_call() -> None: def test_prepare_content_for_opentool_approval_response() -> None: """Test _prepare_content_for_openai with function approval response content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test approved response function_call = Content.from_function_call( @@ -1065,7 +1065,7 @@ def test_prepare_content_for_opentool_approval_response() -> None: def test_prepare_content_for_openai_error_content() -> None: """Test _prepare_content_for_openai with error content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") error_content = Content.from_error( message="Operation failed", @@ -1081,7 +1081,7 @@ def test_prepare_content_for_openai_error_content() -> None: def test_prepare_content_for_openai_usage_content() -> None: """Test _prepare_content_for_openai with usage content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") usage_content = Content.from_usage( usage_details={ @@ -1099,7 +1099,7 @@ def test_prepare_content_for_openai_usage_content() -> None: def test_prepare_content_for_openai_hosted_vector_store_content() -> None: """Test _prepare_content_for_openai with hosted vector store content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") vector_store_content = Content.from_hosted_vector_store( vector_store_id="vs_123", @@ -1113,7 +1113,7 @@ def test_prepare_content_for_openai_hosted_vector_store_content() -> None: def test_prepare_content_for_openai_text_uses_role_specific_type() -> None: """Text content should use input_text for user and output_text for assistant.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") text_content = Content.from_text(text="hello") @@ -1129,7 +1129,7 @@ def test_prepare_content_for_openai_text_uses_role_specific_type() -> None: def test_prepare_messages_for_openai_assistant_history_uses_output_text_with_annotations() -> None: """Assistant history should be output_text and include required annotations.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [ Message(role="user", text="What is async/await?"), @@ -1147,7 +1147,7 @@ def test_prepare_messages_for_openai_assistant_history_uses_output_text_with_ann def test_parse_response_from_openai_with_mcp_server_tool_result() -> None: """Test _parse_response_from_openai with MCP server tool result.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -1184,11 +1184,13 @@ def test_parse_response_from_openai_with_mcp_server_tool_result() -> None: assert result_content.output is not None -def test_parse_chunk_from_openai_with_mcp_call_result() -> None: - """Test _parse_chunk_from_openai with MCP call output.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") +def test_parse_chunk_from_openai_with_mcp_call_added_defers_result() -> None: + """Test that response.output_item.added for mcp_call emits only the call, not the result. + + The result is deferred to response.output_item.done. + """ + client = OpenAIChatClient(model="test-model", api_key="test-key") - # Mock event with MCP call that has output mock_event = MagicMock() mock_event.type = "response.output_item.added" @@ -1199,8 +1201,9 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None: mock_item.name = "fetch_resource" mock_item.server_label = "ResourceServer" mock_item.arguments = {"resource_id": "123"} - # Use proper content structure that _parse_content can handle - mock_item.result = [{"type": "text", "text": "test result"}] + mock_item.result = None + mock_item.output = None + mock_item.outputs = None mock_event.item = mock_item mock_event.output_index = 0 @@ -1209,23 +1212,129 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None: update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) - # Should have both call and result in contents - assert len(update.contents) == 2 - call_content, result_content = update.contents + # Should have only the call content — result is deferred + assert len(update.contents) == 1 + call_content = update.contents[0] assert call_content.type == "mcp_server_tool_call" assert call_content.call_id in ["mcp_call_456", "call_456"] assert call_content.tool_name == "fetch_resource" + # No result should be emitted at this point + result_contents = [c for c in update.contents if c.type == "mcp_server_tool_result"] + assert len(result_contents) == 0 + + +def test_parse_chunk_from_openai_with_mcp_output_item_done() -> None: + """Test that response.output_item.done for mcp_call emits mcp_server_tool_result with output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "mcp_call" + mock_item.id = "mcp_call_456" + mock_item.output = "The weather in Seattle is 72F and sunny." + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + assert result_content.type == "mcp_server_tool_result" - assert result_content.call_id in ["mcp_call_456", "call_456"] - # Verify the output was parsed + assert result_content.call_id == "mcp_call_456" assert result_content.output is not None + assert len(result_content.output) == 1 + assert result_content.output[0].text == "The weather in Seattle is 72F and sunny." + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_no_output() -> None: + """Test that response.output_item.done for mcp_call with no output emits result with None output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "mcp_call" + mock_item.id = "mcp_call_789" + mock_item.output = None + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "mcp_call_789" + assert result_content.output is None + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_call_id_fallback() -> None: + """Test that response.output_item.done for mcp_call falls back to call_id when id is missing.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock(spec=[]) + mock_item.type = "mcp_call" + mock_item.call_id = "mcp_fallback_123" + mock_item.output = "fallback result" + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "mcp_fallback_123" + assert result_content.output is not None + assert result_content.output[0].text == "fallback result" + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_no_id_fallback() -> None: + """Test that response.output_item.done for mcp_call falls back to empty string when neither id nor call_id exist.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock(spec=[]) + mock_item.type = "mcp_call" + mock_item.output = "some result" + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "" + assert result_content.output is not None + assert result_content.output[0].text == "some result" + assert result_content.raw_representation is mock_item def test_prepare_message_for_openai_with_function_approval_response() -> None: """Test _prepare_message_for_openai with function approval response content in messages.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") function_call = Content.from_function_call( call_id="call_789", @@ -1258,7 +1367,7 @@ def test_prepare_message_for_openai_includes_reasoning_with_function_call() -> N function_call items are included. Stripping reasoning causes a 400 error: "function_call was provided without its required reasoning item". """ - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") reasoning = Content.from_text_reasoning( id="rs_abc123", @@ -1292,7 +1401,7 @@ def test_prepare_messages_for_openai_full_conversation_with_reasoning() -> None: This simulates the conversation history passed between agents in a workflow. The API requires reasoning items alongside function_calls. """ - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [ Message(role="user", contents=[Content.from_text(text="search for hotels")]), @@ -1351,7 +1460,7 @@ def test_prepare_messages_for_openai_full_conversation_with_reasoning() -> None: def test_prepare_message_for_openai_filters_error_content() -> None: """Test that error content in messages is handled properly.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") error_content = Content.from_error( message="Test error", @@ -1368,7 +1477,7 @@ def test_prepare_message_for_openai_filters_error_content() -> None: def test_chat_message_with_usage_content() -> None: """Test that usage content in messages is handled properly.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") usage_content = Content.from_usage( usage_details={ @@ -1388,7 +1497,7 @@ def test_chat_message_with_usage_content() -> None: def test_hosted_file_content_preparation() -> None: """Test _prepare_content_for_openai with hosted file content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") hosted_file = Content.from_hosted_file( file_id="file_abc123", @@ -1403,7 +1512,7 @@ def test_hosted_file_content_preparation() -> None: def test_function_approval_response_with_mcp_tool_call() -> None: """Test function approval response content with MCP server tool call content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mcp_call = Content.from_mcp_server_tool_call( call_id="mcp_call_999", @@ -1427,7 +1536,7 @@ def test_function_approval_response_with_mcp_tool_call() -> None: def test_response_format_with_conflicting_definitions() -> None: """Test that conflicting response_format definitions raise an error.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Mock response_format and text_config that conflict response_format = { @@ -1445,7 +1554,7 @@ def test_response_format_with_conflicting_definitions() -> None: def test_response_format_json_object_type() -> None: """Test response_format with json_object type.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = {"type": "json_object"} @@ -1457,7 +1566,7 @@ def test_response_format_json_object_type() -> None: def test_response_format_text_type() -> None: """Test response_format with text type.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = {"type": "text"} @@ -1469,7 +1578,7 @@ def test_response_format_text_type() -> None: def test_response_format_with_format_key() -> None: """Test response_format that already has a format key.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = { "format": { @@ -1488,7 +1597,7 @@ def test_response_format_with_format_key() -> None: def test_response_format_json_schema_no_name_uses_title() -> None: """Test json_schema response_format without name uses title from schema.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = { "type": "json_schema", @@ -1503,7 +1612,7 @@ def test_response_format_json_schema_no_name_uses_title() -> None: def test_response_format_json_schema_with_strict() -> None: """Test json_schema response_format with strict mode.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = { "type": "json_schema", @@ -1522,7 +1631,7 @@ def test_response_format_json_schema_with_strict() -> None: def test_response_format_json_schema_with_description() -> None: """Test json_schema response_format with description.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = { "type": "json_schema", @@ -1541,7 +1650,7 @@ def test_response_format_json_schema_with_description() -> None: def test_response_format_json_schema_missing_schema() -> None: """Test json_schema response_format without schema raises error.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = {"type": "json_schema", "json_schema": {"name": "NoSchema"}} @@ -1554,7 +1663,7 @@ def test_response_format_json_schema_missing_schema() -> None: def test_response_format_unsupported_type() -> None: """Test unsupported response_format type raises error.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = {"type": "unsupported_format"} @@ -1564,7 +1673,7 @@ def test_response_format_unsupported_type() -> None: def test_response_format_invalid_type() -> None: """Test invalid response_format type raises error.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") response_format = "invalid" # Not a Pydantic model or mapping @@ -1577,7 +1686,7 @@ def test_response_format_invalid_type() -> None: def test_parse_response_with_store_false() -> None: """Test _get_conversation_id returns None when store is False.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.id = "resp_123" @@ -1591,7 +1700,7 @@ def test_parse_response_with_store_false() -> None: def test_parse_response_uses_response_id_when_no_conversation() -> None: """Test _get_conversation_id returns response ID when no conversation exists.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.id = "resp_789" @@ -1604,7 +1713,7 @@ def test_parse_response_uses_response_id_when_no_conversation() -> None: def test_streaming_chunk_with_usage_only() -> None: """Test streaming chunk that only contains usage info.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -1631,10 +1740,10 @@ def test_streaming_chunk_with_usage_only() -> None: def test_prepare_tools_for_openai_with_mcp() -> None: """Test that MCP tool dict is converted to the correct response tool dict.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Use static method to create MCP tool - tool = OpenAIResponsesClient.get_mcp_tool( + tool = OpenAIChatClient.get_mcp_tool( name="My_MCP", url="https://mcp.example", allowed_tools=["tool_a", "tool_b"], @@ -1659,7 +1768,7 @@ def test_prepare_tools_for_openai_with_mcp() -> None: def test_prepare_tools_for_openai_single_function_tool() -> None: """Test that a single FunctionTool (not wrapped in a list) is handled correctly.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") @tool def hello(name: str) -> str: @@ -1684,9 +1793,9 @@ def test_prepare_tools_for_openai_single_function_tool() -> None: def test_prepare_tools_for_openai_single_dict_tool() -> None: """Test that a single dict tool (not wrapped in a list) is handled correctly.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") - web_tool = OpenAIResponsesClient.get_web_search_tool(search_context_size="low") + web_tool = OpenAIChatClient.get_web_search_tool(search_context_size="low") resp_tools = client._prepare_tools_for_openai(web_tool) assert isinstance(resp_tools, list) assert len(resp_tools) == 1 @@ -1696,7 +1805,7 @@ def test_prepare_tools_for_openai_single_dict_tool() -> None: def test_prepare_tools_for_openai_none() -> None: """Test that passing None returns an empty list.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") resp_tools = client._prepare_tools_for_openai(None) assert isinstance(resp_tools, list) @@ -1705,7 +1814,7 @@ def test_prepare_tools_for_openai_none() -> None: def test_parse_response_from_openai_with_mcp_approval_request() -> None: """Test that a non-streaming mcp_approval_request is parsed into FunctionApprovalRequestContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -1742,7 +1851,7 @@ def test_responses_client_created_at_uses_utc( This is a regression test for the issue where created_at was using local time but labeling it as UTC (with 'Z' suffix). """ - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC) utc_timestamp = 1733011890 @@ -1783,7 +1892,7 @@ def test_responses_client_created_at_uses_utc( def test_prepare_tools_for_openai_with_raw_image_generation() -> None: """Test that raw image_generation tool dict is handled correctly with parameter mapping.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test with raw tool dict using OpenAI parameters directly tool = { @@ -1809,7 +1918,7 @@ def test_prepare_tools_for_openai_with_raw_image_generation() -> None: def test_prepare_tools_for_openai_with_raw_image_generation_openai_responses_params() -> None: """Test raw image_generation tool with OpenAI-specific parameters.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test with OpenAI-specific parameters tool = { @@ -1841,7 +1950,7 @@ def test_prepare_tools_for_openai_with_raw_image_generation_openai_responses_par def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None: """Test raw image_generation tool with minimal configuration.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test with minimal parameters (just type) tool = {"type": "image_generation"} @@ -1859,10 +1968,10 @@ def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None: def test_prepare_tools_for_openai_with_image_generation_options() -> None: """Test image generation tool conversion with options.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Use static method to create image generation tool - tool = OpenAIResponsesClient.get_image_generation_tool( + tool = OpenAIChatClient.get_image_generation_tool( output_format="png", size="512x512", quality="high", @@ -1879,9 +1988,9 @@ def test_prepare_tools_for_openai_with_image_generation_options() -> None: def test_prepare_tools_for_openai_with_custom_image_generation_model() -> None: """Test image generation tool conversion with a custom model string.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") - tool = OpenAIResponsesClient.get_image_generation_tool(model="custom-image-model") + tool = OpenAIChatClient.get_image_generation_tool(model="custom-image-model") resp_tools = client._prepare_tools_for_openai([tool]) assert len(resp_tools) == 1 @@ -1892,7 +2001,7 @@ def test_prepare_tools_for_openai_with_custom_image_generation_model() -> None: def test_parse_chunk_from_openai_with_mcp_approval_request() -> None: """Test that a streaming mcp_approval_request event is parsed into FunctionApprovalRequestContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -1919,7 +2028,7 @@ async def test_end_to_end_mcp_approval_flow(span_exporter) -> None: """End-to-end mocked test: model issues an mcp_approval_request, user approves, client sends mcp_approval_response. """ - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # First mocked response: model issues an mcp_approval_request mock_response1 = MagicMock() @@ -1973,7 +2082,7 @@ async def test_end_to_end_mcp_approval_flow(span_exporter) -> None: def test_usage_details_basic() -> None: """Test _parse_usage_from_openai without cached or reasoning tokens.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_usage = MagicMock() mock_usage.input_tokens = 100 @@ -1991,7 +2100,7 @@ def test_usage_details_basic() -> None: def test_usage_details_with_cached_tokens() -> None: """Test _parse_usage_from_openai with cached input tokens.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_usage = MagicMock() mock_usage.input_tokens = 200 @@ -2009,7 +2118,7 @@ def test_usage_details_with_cached_tokens() -> None: def test_usage_details_with_reasoning_tokens() -> None: """Test _parse_usage_from_openai with reasoning tokens.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_usage = MagicMock() mock_usage.input_tokens = 150 @@ -2027,7 +2136,7 @@ def test_usage_details_with_reasoning_tokens() -> None: def test_get_metadata_from_response() -> None: """Test the _get_metadata_from_response method.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test with logprobs mock_output_with_logprobs = MagicMock() @@ -2047,7 +2156,7 @@ def test_get_metadata_from_response() -> None: def test_streaming_response_basic_structure() -> None: """Test that _parse_chunk_from_openai returns proper structure.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions(store=True) function_call_ids: dict[int, tuple[str, str]] = {} @@ -2059,14 +2168,14 @@ def test_streaming_response_basic_structure() -> None: # Should get a valid ChatResponseUpdate structure assert isinstance(response, ChatResponseUpdate) assert response.role == "assistant" - assert response.model_id == "test-model" + assert response.model == "test-model" assert isinstance(response.contents, list) assert response.raw_representation is mock_event def test_streaming_response_created_type() -> None: """Test streaming response with created type""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2085,7 +2194,7 @@ def test_streaming_response_created_type() -> None: def test_streaming_response_in_progress_type() -> None: """Test streaming response with in_progress type""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2104,7 +2213,7 @@ def test_streaming_response_in_progress_type() -> None: def test_streaming_annotation_added_with_file_path() -> None: """Test streaming annotation added event with file_path type extracts HostedFileContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2130,7 +2239,7 @@ def test_streaming_annotation_added_with_file_path() -> None: def test_streaming_annotation_added_with_file_citation() -> None: """Test streaming annotation added event with file_citation type extracts HostedFileContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2157,7 +2266,7 @@ def test_streaming_annotation_added_with_file_citation() -> None: def test_streaming_annotation_added_with_container_file_citation() -> None: """Test streaming annotation added event with container_file_citation type.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2188,7 +2297,7 @@ def test_streaming_annotation_added_with_container_file_citation() -> None: def test_streaming_annotation_added_with_unknown_type() -> None: """Test streaming annotation added event with unknown type is ignored.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2208,7 +2317,7 @@ def test_streaming_annotation_added_with_unknown_type() -> None: async def test_service_response_exception_includes_original_error_details() -> None: """Test that ChatClientException messages include original error details in the new format.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [Message(role="user", text="test message")] mock_response = MagicMock() @@ -2233,7 +2342,7 @@ async def test_service_response_exception_includes_original_error_details() -> N async def test_get_response_streaming_with_response_format() -> None: """Test get_response streaming with response_format.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [Message(role="user", text="Test streaming with format")] # It will fail due to invalid API key, but exercises the code path @@ -2252,7 +2361,7 @@ async def test_get_response_streaming_with_response_format() -> None: def test_prepare_content_for_openai_image_content() -> None: """Test _prepare_content_for_openai with image content variations.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test image content with detail parameter and file_id image_content_with_detail = Content.from_uri( @@ -2276,7 +2385,7 @@ def test_prepare_content_for_openai_image_content() -> None: def test_prepare_content_for_openai_audio_content() -> None: """Test _prepare_content_for_openai with audio content variations.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test WAV audio content wav_content = Content.from_uri(uri="data:audio/wav;base64,abc123", media_type="audio/wav") @@ -2294,7 +2403,7 @@ def test_prepare_content_for_openai_audio_content() -> None: def test_prepare_content_for_openai_unsupported_content() -> None: """Test _prepare_content_for_openai with unsupported content types.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test unsupported audio format unsupported_audio = Content.from_uri(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg") @@ -2309,7 +2418,7 @@ def test_prepare_content_for_openai_unsupported_content() -> None: def test_prepare_content_for_openai_function_result_with_rich_items() -> None: """Test _prepare_content_for_openai with function_result containing rich items.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") image_content = Content.from_data(data=b"image_bytes", media_type="image/png") content = Content.from_function_result( @@ -2332,7 +2441,7 @@ def test_prepare_content_for_openai_function_result_with_rich_items() -> None: def test_prepare_content_for_openai_function_result_without_items() -> None: """Test _prepare_content_for_openai with plain string function_result.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") content = Content.from_function_result( call_id="call_plain", @@ -2348,7 +2457,7 @@ def test_prepare_content_for_openai_function_result_without_items() -> None: def test_parse_chunk_from_openai_code_interpreter() -> None: """Test _parse_chunk_from_openai with code_interpreter_call.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2372,7 +2481,7 @@ def test_parse_chunk_from_openai_code_interpreter() -> None: def test_parse_chunk_from_openai_code_interpreter_delta() -> None: """Test _parse_chunk_from_openai with code_interpreter_call_code delta events.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2401,7 +2510,7 @@ def test_parse_chunk_from_openai_code_interpreter_delta() -> None: def test_parse_chunk_from_openai_code_interpreter_done() -> None: """Test _parse_chunk_from_openai with code_interpreter_call_code done event.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2430,7 +2539,7 @@ def test_parse_chunk_from_openai_code_interpreter_done() -> None: def test_parse_chunk_from_openai_reasoning() -> None: """Test _parse_chunk_from_openai with reasoning content.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2454,7 +2563,7 @@ def test_parse_chunk_from_openai_reasoning() -> None: def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None: """Test _prepare_content_for_openai with TextReasoningContent all additional properties.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test TextReasoningContent with all additional properties comprehensive_reasoning = Content.from_text_reasoning( @@ -2478,7 +2587,7 @@ def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None: def test_streaming_reasoning_text_delta_event() -> None: """Test reasoning text delta event creates TextReasoningContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2504,7 +2613,7 @@ def test_streaming_reasoning_text_delta_event() -> None: def test_streaming_reasoning_text_done_event() -> None: """Test reasoning text done event creates TextReasoningContent with complete text.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2530,7 +2639,7 @@ def test_streaming_reasoning_text_done_event() -> None: def test_streaming_reasoning_summary_text_delta_event() -> None: """Test reasoning summary text delta event creates TextReasoningContent.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2555,7 +2664,7 @@ def test_streaming_reasoning_summary_text_delta_event() -> None: def test_streaming_reasoning_summary_text_done_event() -> None: """Test reasoning summary text done event creates TextReasoningContent with complete text.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2581,7 +2690,7 @@ def test_streaming_reasoning_summary_text_done_event() -> None: def test_streaming_reasoning_events_preserve_metadata() -> None: """Test that reasoning events preserve metadata like regular text events.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} @@ -2619,7 +2728,7 @@ def test_streaming_reasoning_events_preserve_metadata() -> None: def test_parse_response_from_openai_image_generation_raw_base64(): """Test image generation response parsing with raw base64 string.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with raw base64 image data (PNG signature) mock_response = MagicMock() @@ -2657,7 +2766,7 @@ def test_parse_response_from_openai_image_generation_raw_base64(): def test_parse_response_from_openai_image_generation_existing_data_uri(): """Test image generation response parsing with existing data URI.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with existing data URI mock_response = MagicMock() @@ -2694,7 +2803,7 @@ def test_parse_response_from_openai_image_generation_existing_data_uri(): def test_parse_response_from_openai_image_generation_format_detection(): """Test different image format detection from base64 data.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Test JPEG detection jpeg_signature = b"\xff\xd8\xff" @@ -2749,7 +2858,7 @@ def test_parse_response_from_openai_image_generation_format_detection(): def test_parse_response_from_openai_image_generation_fallback(): """Test image generation with invalid base64 falls back to PNG.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a mock response with invalid base64 mock_response = MagicMock() @@ -2783,7 +2892,7 @@ def test_parse_response_from_openai_image_generation_fallback(): async def test_prepare_options_store_parameter_handling() -> None: - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [Message(role="user", text="Test message")] test_conversation_id = "test-conversation-123" @@ -2809,7 +2918,7 @@ async def test_prepare_options_store_parameter_handling() -> None: async def test_conversation_id_precedence_kwargs_over_options() -> None: """When both kwargs and options contain conversation_id, kwargs wins.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [Message(role="user", text="Hello")] # options has a stale response id, kwargs carries the freshest one @@ -2845,7 +2954,7 @@ def _create_mock_responses_text_response(*, response_id: str) -> MagicMock: async def test_instructions_sent_first_turn_then_skipped_for_continuation() -> None: - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = _create_mock_responses_text_response(response_id="resp_123") with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create: @@ -2878,7 +2987,7 @@ async def test_instructions_sent_first_turn_then_skipped_for_continuation() -> N async def test_instructions_not_repeated_for_continuation_ids( conversation_id: str, ) -> None: - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = _create_mock_responses_text_response(response_id="resp_456") with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create: @@ -2894,7 +3003,7 @@ async def test_instructions_not_repeated_for_continuation_ids( async def test_instructions_included_without_conversation_id() -> None: - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = _create_mock_responses_text_response(response_id="resp_new") with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create: @@ -2911,15 +3020,15 @@ async def test_instructions_included_without_conversation_id() -> None: def test_with_callable_api_key() -> None: - """Test OpenAIResponsesClient initialization with callable API key.""" + """Test OpenAIChatClient initialization with callable API key.""" async def get_api_key() -> str: return "test-api-key-123" - client = OpenAIResponsesClient(model_id="gpt-4o", api_key=get_api_key) + client = OpenAIChatClient(model="gpt-4o", api_key=get_api_key) # Verify client was created successfully - assert client.model_id == "gpt-4o" + assert client.model == "gpt-4o" # OpenAI SDK now manages callable API keys internally assert client.client is not None @@ -2945,7 +3054,7 @@ def test_with_callable_api_key() -> None: param("stop", ["END"], False, id="stop"), param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"), param("tool_choice", "none", True, id="tool_choice_none"), - # OpenAIResponsesOptions - just verify they don't fail + # OpenAIChatOptions - just verify they don't fail param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), param("top_logprobs", 5, False, id="top_logprobs"), @@ -2998,13 +3107,13 @@ async def test_integration_options( option_value: Any, needs_validation: bool, ) -> None: - """Parametrized test covering all ChatOptions and OpenAIResponsesOptions. + """Parametrized test covering all ChatOptions and OpenAIChatOptions. Tests both streaming and non-streaming modes for each option to ensure they don't cause failures. Options marked with needs_validation also check that the feature actually works correctly. """ - openai_responses_client = OpenAIResponsesClient() + openai_responses_client = OpenAIChatClient() # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response openai_responses_client.function_invocation_configuration["max_iterations"] = 2 @@ -3075,11 +3184,11 @@ async def test_integration_options( @pytest.mark.integration @skip_if_openai_integration_tests_disabled async def test_integration_web_search() -> None: - client = OpenAIResponsesClient(model_id="gpt-5") + client = OpenAIChatClient(model="gpt-5") for streaming in [False, True]: # Use static method for web search tool - web_search_tool = OpenAIResponsesClient.get_web_search_tool() + web_search_tool = OpenAIChatClient.get_web_search_tool() content = { "messages": [ Message( @@ -3104,7 +3213,7 @@ async def test_integration_web_search() -> None: assert "Zoey" in response.text # Test that the client will use the web search tool with location - web_search_tool_with_location = OpenAIResponsesClient.get_web_search_tool( + web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( user_location={"country": "US", "city": "Seattle"}, ) content = { @@ -3134,13 +3243,13 @@ async def test_integration_web_search() -> None: @pytest.mark.integration @skip_if_openai_integration_tests_disabled async def test_integration_file_search() -> None: - openai_responses_client = OpenAIResponsesClient() + openai_responses_client = OpenAIChatClient() assert isinstance(openai_responses_client, SupportsChatGetResponse) file_id, vector_store = await create_vector_store(openai_responses_client) # Use static method for file search tool - file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) + file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) # Test that the client will use the file search tool response = await openai_responses_client.get_response( messages=[ @@ -3168,13 +3277,13 @@ async def test_integration_file_search() -> None: @pytest.mark.integration @skip_if_openai_integration_tests_disabled async def test_integration_streaming_file_search() -> None: - openai_responses_client = OpenAIResponsesClient() + openai_responses_client = OpenAIChatClient() assert isinstance(openai_responses_client, SupportsChatGetResponse) file_id, vector_store = await create_vector_store(openai_responses_client) # Use static method for file search tool - file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) + file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) # Test that the client will use the web search tool response = openai_responses_client.get_streaming_response( messages=[ @@ -3217,7 +3326,7 @@ async def test_integration_tool_rich_content_image() -> None: """Return a test image for analysis.""" return Content.from_data(data=image_bytes, media_type="image/jpeg") - client = OpenAIResponsesClient() + client = OpenAIChatClient() client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: @@ -3254,7 +3363,7 @@ async def test_integration_agent_replays_local_tool_history_without_stale_fc_id( async def search_hotels(city: Annotated[str, "The city to search for hotels in"]) -> str: return f"The only hotel option in {city} is {hotel_code}." - client = OpenAIResponsesClient() + client = OpenAIChatClient() client.function_invocation_configuration["max_iterations"] = 2 agent = Agent( @@ -3291,7 +3400,7 @@ async def test_integration_agent_replays_local_tool_history_without_stale_fc_id( def test_continuation_token_json_serializable() -> None: """Test that OpenAIContinuationToken is a plain dict and JSON-serializable.""" - from agent_framework.openai import OpenAIContinuationToken + from agent_framework_openai import OpenAIContinuationToken token = OpenAIContinuationToken(response_id="resp_abc123") assert token["response_id"] == "resp_abc123" @@ -3304,7 +3413,7 @@ def test_continuation_token_json_serializable() -> None: def test_chat_response_with_continuation_token() -> None: """Test that ChatResponse accepts and stores continuation_token.""" - from agent_framework.openai import OpenAIContinuationToken + from agent_framework_openai import OpenAIContinuationToken token = OpenAIContinuationToken(response_id="resp_123") response = ChatResponse( @@ -3326,7 +3435,7 @@ def test_chat_response_without_continuation_token() -> None: def test_chat_response_update_with_continuation_token() -> None: """Test that ChatResponseUpdate accepts and stores continuation_token.""" - from agent_framework.openai import OpenAIContinuationToken + from agent_framework_openai import OpenAIContinuationToken token = OpenAIContinuationToken(response_id="resp_456") update = ChatResponseUpdate( @@ -3341,7 +3450,8 @@ def test_chat_response_update_with_continuation_token() -> None: def test_agent_response_with_continuation_token() -> None: """Test that AgentResponse accepts and stores continuation_token.""" from agent_framework import AgentResponse - from agent_framework.openai import OpenAIContinuationToken + + from agent_framework_openai import OpenAIContinuationToken token = OpenAIContinuationToken(response_id="resp_789") response = AgentResponse( @@ -3355,7 +3465,8 @@ def test_agent_response_with_continuation_token() -> None: def test_agent_response_update_with_continuation_token() -> None: """Test that AgentResponseUpdate accepts and stores continuation_token.""" from agent_framework import AgentResponseUpdate - from agent_framework.openai import OpenAIContinuationToken + + from agent_framework_openai import OpenAIContinuationToken token = OpenAIContinuationToken(response_id="resp_012") update = AgentResponseUpdate( @@ -3369,7 +3480,7 @@ def test_agent_response_update_with_continuation_token() -> None: def test_parse_response_from_openai_with_background_in_progress() -> None: """Test that _parse_response_from_openai sets continuation_token when status is in_progress.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -3394,7 +3505,7 @@ def test_parse_response_from_openai_with_background_in_progress() -> None: def test_parse_response_from_openai_with_background_queued() -> None: """Test that _parse_response_from_openai sets continuation_token when status is queued.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -3419,7 +3530,7 @@ def test_parse_response_from_openai_with_background_queued() -> None: def test_parse_response_from_openai_with_background_completed() -> None: """Test that _parse_response_from_openai does NOT set continuation_token when status is completed.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") mock_response = MagicMock() mock_response.output_parsed = None @@ -3449,7 +3560,7 @@ def test_parse_response_from_openai_with_background_completed() -> None: def test_streaming_response_in_progress_sets_continuation_token() -> None: """Test that _parse_chunk_from_openai sets continuation_token for in_progress events.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options: dict[str, Any] = {} function_call_ids: dict[int, tuple[str, str]] = {} @@ -3469,7 +3580,7 @@ def test_streaming_response_in_progress_sets_continuation_token() -> None: def test_streaming_response_created_with_in_progress_status_sets_continuation_token() -> None: """Test that response.created with in_progress status sets continuation_token.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options: dict[str, Any] = {} function_call_ids: dict[int, tuple[str, str]] = {} @@ -3489,7 +3600,7 @@ def test_streaming_response_created_with_in_progress_status_sets_continuation_to def test_streaming_response_completed_no_continuation_token() -> None: """Test that response.completed does NOT set continuation_token.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options: dict[str, Any] = {} function_call_ids: dict[int, tuple[str, str]] = {} @@ -3527,11 +3638,11 @@ def test_map_chat_to_agent_update_preserves_continuation_token() -> None: async def test_prepare_options_excludes_continuation_token() -> None: """Test that _prepare_options does not pass continuation_token to OpenAI API.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] options: dict[str, Any] = { - "model_id": "test-model", + "model": "test-model", "continuation_token": {"response_id": "resp_123"}, "background": True, } @@ -3553,7 +3664,7 @@ def test_parse_response_from_openai_function_call_includes_status() -> None: """Test _parse_response_from_openai includes status in function call additional_properties.""" from openai.types.responses import ResponseFunctionToolCall - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") # Create a real ResponseFunctionToolCall object mock_function_call_item = ResponseFunctionToolCall( @@ -3592,7 +3703,7 @@ def test_parse_response_from_openai_function_call_includes_status() -> None: async def test_prepare_messages_for_openai_does_not_replay_fc_id_when_loaded_from_history() -> None: """Loaded history must not replay provider-ephemeral Responses function call IDs.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") provider = InMemoryHistoryProvider() session = AgentSession(session_id="thread-1") @@ -3663,7 +3774,7 @@ async def test_prepare_messages_for_openai_does_not_replay_fc_id_when_loaded_fro def test_prepare_messages_for_openai_keeps_live_fc_id_separate_from_replayed_history() -> None: """Replayed history must not borrow a live Responses function call ID with the same call_id.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") history_message = Message( role="assistant", @@ -3697,7 +3808,7 @@ def test_prepare_messages_for_openai_keeps_live_fc_id_separate_from_replayed_his def test_prepare_messages_for_openai_filters_empty_fc_id() -> None: """Test _prepare_messages_for_openai correctly filters empty fc_id values from call_id_to_id mapping.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [ Message(role="user", contents=[Content.from_text(text="check hotels")]), @@ -3745,7 +3856,7 @@ def test_prepare_messages_for_openai_filters_empty_fc_id() -> None: def test_prepare_messages_for_openai_filters_none_fc_id() -> None: """Test _prepare_messages_for_openai correctly filters None fc_id values.""" - client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + client = OpenAIChatClient(model="test-model", api_key="test-key") messages = [ Message( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py new file mode 100644 index 0000000000..8646f2d959 --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -0,0 +1,453 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import pytest +from agent_framework import Agent, AgentResponse, ChatResponse, Content, Message, SupportsChatGetResponse, tool +from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from openai import AsyncAzureOpenAI +from pydantic import BaseModel +from pytest import param + +from agent_framework_openai import OpenAIChatClient + +pytestmark = pytest.mark.azure + +skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( + os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") + or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "", + reason="No real Azure OpenAI endpoint or responses deployment provided; skipping integration tests.", +) + + +class OutputStruct(BaseModel): + """A structured output for testing purposes.""" + + location: str + weather: str | None = None + + +def _create_azure_openai_chat_client( + *, + api_key: Any = None, +) -> OpenAIChatClient: + return OpenAIChatClient( + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + ) + + +async def create_vector_store(client: OpenAIChatClient) -> tuple[str, Content]: + """Create a vector store with sample documents for testing.""" + file = await client.client.files.create( + file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), + purpose="assistants", + ) + vector_store = await client.client.vector_stores.create( + name="knowledge_base", + expires_after={"anchor": "last_active_at", "days": 1}, + ) + result = await client.client.vector_stores.files.create_and_poll( + vector_store_id=vector_store.id, + file_id=file.id, + poll_interval_ms=1000, + ) + if result.last_error is not None: + raise RuntimeError(f"Vector store file processing failed with status: {result.last_error.message}") + + return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) + + +async def delete_vector_store(client: OpenAIChatClient, file_id: str, vector_store_id: str) -> None: + """Delete the vector store after tests.""" + + await client.client.vector_stores.delete(vector_store_id=vector_store_id) + await client.client.files.delete(file_id=file_id) + + +@tool(approval_mode="never_require") +async def get_weather(location: str) -> str: + """Get the current weather in a given location.""" + return f"The current weather in {location} is sunny." + + +def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + client = _create_azure_openai_chat_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert isinstance(client, SupportsChatGetResponse) + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + + +def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIChatClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) +def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: + client = _create_azure_openai_chat_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.api_version == "preview" + + +def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1") + + client = OpenAIChatClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@pytest.mark.parametrize( + "option_name,option_value,needs_validation", + [ + param("temperature", 0.7, False, id="temperature"), + param("top_p", 0.9, False, id="top_p"), + param("max_tokens", 500, False, id="max_tokens"), + param("seed", 123, False, id="seed"), + param("user", "test-user-id", False, id="user"), + param("metadata", {"test_key": "test_value"}, False, id="metadata"), + param("frequency_penalty", 0.5, False, id="frequency_penalty"), + param("presence_penalty", 0.3, False, id="presence_penalty"), + param("stop", ["END"], False, id="stop"), + param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"), + param("tool_choice", "none", True, id="tool_choice_none"), + param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), + param("truncation", "auto", False, id="truncation"), + param("top_logprobs", 5, False, id="top_logprobs"), + param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), + param("max_tool_calls", 3, False, id="max_tool_calls"), + param("tools", [get_weather], True, id="tools_function"), + param("tool_choice", "auto", True, id="tool_choice_auto"), + param( + "tool_choice", + {"mode": "required", "required_function_name": "get_weather"}, + True, + id="tool_choice_required", + ), + param("response_format", OutputStruct, True, id="response_format_pydantic"), + param( + "response_format", + { + "type": "json_schema", + "json_schema": { + "name": "WeatherDigest", + "strict": True, + "schema": { + "title": "WeatherDigest", + "type": "object", + "properties": { + "location": {"type": "string"}, + "conditions": {"type": "string"}, + "temperature_c": {"type": "number"}, + "advisory": {"type": "string"}, + }, + "required": ["location", "conditions", "temperature_c", "advisory"], + "additionalProperties": False, + }, + }, + }, + True, + id="response_format_runtime_json_schema", + ), + ], +) +async def test_integration_options( + option_name: str, + option_value: Any, + needs_validation: bool, +) -> None: + async with AzureCliCredential() as credential: + client = _create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + client.function_invocation_configuration["max_iterations"] = 2 + + for streaming in [False, True]: + if option_name in {"tools", "tool_choice"}: + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name == "response_format": + messages = [ + Message(role="user", text="The weather in Seattle is sunny"), + Message(role="user", text="What is the weather in Seattle?"), + ] + else: + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + options: dict[str, Any] = {option_name: option_value} + if option_name == "tool_choice": + options["tools"] = [get_weather] + + if streaming: + response = await client.get_response( + messages=messages, + stream=True, + options=options, + ).get_final_response() + else: + response = await client.get_response(messages=messages, options=options) + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + + if needs_validation: + if option_name in {"tools", "tool_choice"}: + text = response.text.lower() + assert "sunny" in text or "seattle" in text + elif option_name == "response_format": + if option_value == OutputStruct: + assert response.value is not None + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + assert response.value is None + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + assert "seattle" in response_value["location"].lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_web_search() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + + for streaming in [False, True]: + content = { + "messages": [ + Message( + role="user", + text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + ) + ], + "options": { + "tool_choice": "auto", + "tools": [OpenAIChatClient.get_web_search_tool()], + }, + "stream": streaming, + } + if streaming: + response = await client.get_response(**content).get_final_response() + else: + response = await client.get_response(**content) + + assert isinstance(response, ChatResponse) + assert "Rumi" in response.text + assert "Mira" in response.text + assert "Zoey" in response.text + + content = { + "messages": [ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + "options": { + "tool_choice": "auto", + "tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], + }, + "stream": streaming, + } + if streaming: + response = await client.get_response(**content).get_final_response() + else: + response = await client.get_response(**content) + assert response.text is not None + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_client_file_search() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + file_id, vector_store = await create_vector_store(client) + try: + response = await client.get_response( + messages=[Message(role="user", text="What is the weather today? Do a file search to find the answer.")], + options={ + "tools": [OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])], + "tool_choice": "auto", + }, + ) + + assert "sunny" in response.text.lower() + assert "75" in response.text + finally: + await delete_vector_store(client, file_id, vector_store.vector_store_id) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_client_file_search_streaming() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + file_id, vector_store = await create_vector_store(client) + try: + response_stream = client.get_response( + messages=[Message(role="user", text="What is the weather today? Do a file search to find the answer.")], + stream=True, + options={ + "tools": [OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])], + "tool_choice": "auto", + }, + ) + + full_response = await response_stream.get_final_response() + assert "sunny" in full_response.text.lower() + assert "75" in full_response.text + finally: + await delete_vector_store(client, file_id, vector_store.vector_store_id) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_client_agent_hosted_mcp_tool() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + response = await client.get_response( + messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], + options={ + "max_tokens": 5000, + "tools": OpenAIChatClient.get_mcp_tool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + ), + }, + ) + + assert isinstance(response, ChatResponse) + if not response.text: + pytest.skip("MCP server returned empty response - service-side issue") + assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + + response = await client.get_response( + messages=[Message(role="user", text="Calculate the sum of numbers from 1 to 10 using Python code.")], + options={"tools": [OpenAIChatClient.get_code_interpreter_tool()]}, + ) + + contains_relevant_content = any( + term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"] + ) + assert contains_relevant_content or len(response.text.strip()) > 10 + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_integration_client_agent_existing_session() -> None: + async with AzureCliCredential() as credential: + preserved_session = None + + async with Agent( + client=_create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + instructions="You are a helpful assistant with good memory.", + ) as first_agent: + session = first_agent.create_session() + first_response = await first_agent.run( + "My hobby is photography. Remember this.", + session=session, + store=True, + ) + + assert isinstance(first_response, AgentResponse) + preserved_session = session + + if preserved_session: + async with Agent( + client=_create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + instructions="You are a helpful assistant with good memory.", + ) as second_agent: + second_response = await second_agent.run("What is my hobby?", session=preserved_session) + + assert isinstance(second_response, AgentResponse) + assert second_response.text is not None + assert "photography" in second_response.text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_client_tool_rich_content_image() -> None: + image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" + image_bytes = image_path.read_bytes() + + @tool(approval_mode="never_require") + def get_test_image() -> Content: + """Return a test image for analysis.""" + return Content.from_data(data=image_bytes, media_type="image/jpeg") + + async with AzureCliCredential() as credential: + client = _create_azure_openai_chat_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + client.function_invocation_configuration["max_iterations"] = 2 + + for streaming in [False, True]: + messages = [Message(role="user", text="Call the get_test_image tool and describe what you see.")] + options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} + + if streaming: + response = await client.get_response( + messages=messages, + stream=True, + options=options, + ).get_final_response() + else: + response = await client.get_response(messages=messages, options=options) + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert "house" in response.text.lower(), ( + f"Model did not describe the house image. Response: {response.text}" + ) diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py similarity index 88% rename from python/packages/core/tests/openai/test_openai_chat_client.py rename to python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 3dc4c23c6d..391432958f 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -6,12 +6,6 @@ from typing import Any from unittest.mock import MagicMock, patch import pytest -from openai import BadRequestError -from openai.types.chat.chat_completion import ChatCompletion, Choice -from openai.types.chat.chat_completion_message import ChatCompletionMessage -from pydantic import BaseModel -from pytest import param - from agent_framework import ( ChatResponse, Content, @@ -20,8 +14,14 @@ from agent_framework import ( tool, ) from agent_framework.exceptions import ChatClientException -from agent_framework.openai import OpenAIChatClient -from agent_framework.openai._exceptions import OpenAIContentFilterException +from openai import BadRequestError +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from pydantic import BaseModel +from pytest import param + +from agent_framework_openai import OpenAIChatCompletionClient +from agent_framework_openai._exceptions import OpenAIContentFilterException skip_if_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), @@ -31,24 +31,24 @@ skip_if_openai_integration_tests_disabled = pytest.mark.skipif( def test_init(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization - open_ai_chat_completion = OpenAIChatClient() + open_ai_chat_completion = OpenAIChatCompletionClient() - assert open_ai_chat_completion.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] + assert open_ai_chat_completion.model == openai_unit_test_env["OPENAI_MODEL"] assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): - OpenAIChatClient(api_key="34523", model_id={"test": "dict"}) # type: ignore + OpenAIChatCompletionClient(api_key="34523", model={"test": "dict"}) # type: ignore def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization model_id = "test_model_id" - open_ai_chat_completion = OpenAIChatClient(model_id=model_id) + open_ai_chat_completion = OpenAIChatCompletionClient(model=model_id) - assert open_ai_chat_completion.model_id == model_id + assert open_ai_chat_completion.model == model_id assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) @@ -56,11 +56,11 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} # Test successful initialization - open_ai_chat_completion = OpenAIChatClient( + open_ai_chat_completion = OpenAIChatCompletionClient( default_headers=default_headers, ) - assert open_ai_chat_completion.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] + assert open_ai_chat_completion.model == openai_unit_test_env["OPENAI_MODEL"] assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) # Assert that the default header we added is present in the client's default headers @@ -71,7 +71,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: def test_init_base_url(openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization - open_ai_chat_completion = OpenAIChatClient(base_url="http://localhost:1234/v1") + open_ai_chat_completion = OpenAIChatCompletionClient(base_url="http://localhost:1234/v1") assert str(open_ai_chat_completion.client.base_url) == "http://localhost:1234/v1/" @@ -82,19 +82,19 @@ def test_init_base_url_from_settings_env() -> None: os.environ, { "OPENAI_API_KEY": "dummy", - "OPENAI_CHAT_MODEL_ID": "gpt-5", + "OPENAI_MODEL": "gpt-5", "OPENAI_BASE_URL": "https://custom-openai-endpoint.com/v1", }, ): - client = OpenAIChatClient() - assert client.model_id == "gpt-5" + client = OpenAIChatCompletionClient() + assert client.model == "gpt-5" assert str(client.client.base_url) == "https://custom-openai-endpoint.com/v1/" -@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True) +@pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ValueError): - OpenAIChatClient() + OpenAIChatCompletionClient() @pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) @@ -102,8 +102,8 @@ def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" with pytest.raises(ValueError): - OpenAIChatClient( - model_id=model_id, + OpenAIChatCompletionClient( + model=model_id, ) @@ -111,14 +111,14 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} settings = { - "model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + "model": openai_unit_test_env["OPENAI_MODEL"], "api_key": openai_unit_test_env["OPENAI_API_KEY"], "default_headers": default_headers, } - open_ai_chat_completion = OpenAIChatClient.from_dict(settings) + open_ai_chat_completion = OpenAIChatCompletionClient.from_dict(settings) dumped_settings = open_ai_chat_completion.to_dict() - assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] + assert dumped_settings["model"] == openai_unit_test_env["OPENAI_MODEL"] # Assert that the default header we added is present in the dumped_settings default headers for key, value in default_headers.items(): assert key in dumped_settings["default_headers"] @@ -129,14 +129,14 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None: def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None: settings = { - "model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + "model": openai_unit_test_env["OPENAI_MODEL"], "api_key": openai_unit_test_env["OPENAI_API_KEY"], "org_id": openai_unit_test_env["OPENAI_ORG_ID"], } - open_ai_chat_completion = OpenAIChatClient.from_dict(settings) + open_ai_chat_completion = OpenAIChatCompletionClient.from_dict(settings) dumped_settings = open_ai_chat_completion.to_dict() - assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"] + assert dumped_settings["model"] == openai_unit_test_env["OPENAI_MODEL"] assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"] # Assert that the 'User-Agent' header is not present in the dumped_settings default headers assert "User-Agent" not in dumped_settings.get("default_headers", {}) @@ -146,7 +146,7 @@ async def test_content_filter_exception_handling( openai_unit_test_env: dict[str, str], ) -> None: """Test that content filter errors are properly handled.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() messages = [Message(role="user", text="test message")] # Create a mock BadRequestError with content_filter code @@ -168,7 +168,7 @@ async def test_content_filter_exception_handling( def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None: """Test that unsupported tool types are passed through unchanged.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Create a random object that's not a FunctionTool, dict, or callable # This simulates an unsupported tool type that gets passed through @@ -192,7 +192,7 @@ def test_prepare_tools_with_single_function_tool( openai_unit_test_env: dict[str, str], ) -> None: """Test that a single FunctionTool is accepted for tool preparation.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() @tool(approval_mode="never_require") def test_function(query: str) -> str: @@ -224,7 +224,7 @@ def get_weather(location: str) -> str: async def test_exception_message_includes_original_error_details() -> None: """Test that exception messages include original error details in the new format.""" - client = OpenAIChatClient(model_id="test-model", api_key="test-key") + client = OpenAIChatCompletionClient(model="test-model", api_key="test-key") messages = [Message(role="user", text="test message")] mock_response = MagicMock() @@ -284,7 +284,7 @@ def test_chat_response_content_order_text_before_tool_calls( ], ) - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() response = client._parse_response_from_openai(mock_response, {}) # Verify we have both text and tool call content @@ -305,7 +305,7 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s Note: In practice, FunctionTool.invoke() always returns a pre-parsed string. These tests verify that the OpenAI client correctly passes through string results. """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Test with empty list serialized as JSON string (pre-serialized result passed to from_function_result) message_with_empty_list = Message( @@ -343,7 +343,7 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str] Feel free to remove this test in case there's another new behavior. """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Test with exception (no result) test_exception = ValueError("Test error message") @@ -369,7 +369,7 @@ def test_function_result_with_rich_items_warns_and_omits( ) -> None: """Test that function_result with items logs a warning and omits rich items.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() image_content = Content.from_data(data=b"image_bytes", media_type="image/png") message = Message( role="tool", @@ -381,7 +381,7 @@ def test_function_result_with_rich_items_warns_and_omits( ], ) - with patch("agent_framework.openai._chat_client.logger") as mock_logger: + with patch("agent_framework_openai._chat_completion_client.logger") as mock_logger: openai_messages = client._prepare_message_for_openai(message) # Warning should be logged @@ -409,7 +409,7 @@ def test_prepare_content_for_openai_data_content_image( openai_unit_test_env: dict[str, str], ) -> None: """Test _prepare_content_for_openai converts DataContent with image media type to OpenAI format.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Test DataContent with image media type image_data_content = Content.from_uri( @@ -462,11 +462,104 @@ 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 = OpenAIChatCompletionClient() + + # 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: """Test _prepare_content_for_openai converts document files (PDF, DOCX, etc.) to OpenAI file format.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Test PDF without filename - should omit filename in OpenAI payload pdf_data_content = Content.from_uri( @@ -575,7 +668,7 @@ def test_parse_text_reasoning_content_from_response( ) -> None: """Test that TextReasoningContent is correctly parsed from OpenAI response with reasoning_details.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Mock response with reasoning_details mock_reasoning_details = { @@ -628,7 +721,7 @@ def test_parse_text_reasoning_content_from_streaming_chunk( from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Mock streaming chunk with reasoning_details mock_reasoning_details = { @@ -674,7 +767,7 @@ def test_prepare_message_with_text_reasoning_content( openai_unit_test_env: dict[str, str], ) -> None: """Test that TextReasoningContent with protected_data is correctly prepared for OpenAI.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Create message with text_reasoning content that has protected_data # text_reasoning is meant to be added to an existing message, so include text content first @@ -713,7 +806,7 @@ def test_prepare_message_with_only_text_reasoning_content( Reasoning models (e.g. gpt-5-mini) may produce reasoning_details without text content, which previously caused an IndexError when preparing messages. """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() mock_reasoning_data = { "effort": "high", @@ -747,7 +840,7 @@ def test_prepare_message_with_text_reasoning_before_text( Regression test for https://github.com/microsoft/agent-framework/issues/4384 """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() mock_reasoning_data = { "effort": "medium", @@ -783,7 +876,7 @@ def test_prepare_message_with_text_reasoning_before_function_call( Regression test for https://github.com/microsoft/agent-framework/issues/4384 """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() mock_reasoning_data = { "effort": "medium", @@ -818,7 +911,7 @@ def test_function_approval_content_is_skipped_in_preparation( openai_unit_test_env: dict[str, str], ) -> None: """Test that function approval request and response content are skipped.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Create approval request function_call = Content.from_function_call( @@ -869,7 +962,7 @@ def test_usage_content_in_streaming_response( from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from openai.types.completion_usage import CompletionUsage - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Mock streaming chunk with usage data (typically last chunk) mock_usage = CompletionUsage( @@ -915,7 +1008,7 @@ def test_streaming_chunk_with_usage_and_text( ) from openai.types.completion_usage import CompletionUsage - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() mock_chunk = ChatCompletionChunk( id="test-chunk", @@ -948,7 +1041,7 @@ def test_parse_text_with_refusal(openai_unit_test_env: dict[str, str]) -> None: from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Mock response with refusal mock_response = ChatCompletion( @@ -981,12 +1074,12 @@ def test_parse_text_with_refusal(openai_unit_test_env: dict[str, str]) -> None: def test_prepare_options_without_model_id(openai_unit_test_env: dict[str, str]) -> None: """Test that prepare_options raises error when model_id is not set.""" - client = OpenAIChatClient() - client.model_id = None # Remove model_id + client = OpenAIChatCompletionClient() + client.model = None # Remove model_id messages = [Message(role="user", text="test")] - with pytest.raises(ValueError, match="model_id must be a non-empty string"): + with pytest.raises(ValueError, match="model must be a non-empty string"): client._prepare_options(messages, {}) @@ -994,7 +1087,7 @@ def test_prepare_options_without_messages(openai_unit_test_env: dict[str, str]) """Test that prepare_options raises error when messages are missing.""" from agent_framework.exceptions import ChatClientInvalidRequestException - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() with pytest.raises(ChatClientInvalidRequestException, match="Messages are required"): client._prepare_options([], {}) @@ -1004,10 +1097,10 @@ def test_prepare_tools_with_web_search_no_location( openai_unit_test_env: dict[str, str], ) -> None: """Test preparing web search tool without user location.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Web search tool using static method - web_search_tool = OpenAIChatClient.get_web_search_tool() + web_search_tool = OpenAIChatCompletionClient.get_web_search_tool() result = client._prepare_tools_for_openai([web_search_tool]) @@ -1020,7 +1113,7 @@ def test_prepare_options_with_instructions( openai_unit_test_env: dict[str, str], ) -> None: """Test that instructions are prepended as system message.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() messages = [Message(role="user", text="Hello")] options = {"instructions": "You are a helpful assistant."} @@ -1036,7 +1129,7 @@ def test_prepare_options_with_instructions( def test_prepare_message_with_author_name(openai_unit_test_env: dict[str, str]) -> None: """Test that author_name is included in prepared message.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() message = Message( role="user", @@ -1054,7 +1147,7 @@ def test_prepare_message_with_tool_result_author_name( openai_unit_test_env: dict[str, str], ) -> None: """Test that author_name is not included for TOOL role messages.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Tool messages should not have 'name' field (it's for function name instead) message = Message( @@ -1078,7 +1171,7 @@ def test_prepare_system_message_content_is_string( Some OpenAI-compatible endpoints (e.g. NVIDIA NIM) reject system messages with list content. See https://github.com/microsoft/agent-framework/issues/1407 """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() message = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]) @@ -1094,7 +1187,7 @@ def test_prepare_developer_message_content_is_string( openai_unit_test_env: dict[str, str], ) -> None: """Test that developer message content is a plain string, not a list.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() message = Message(role="developer", contents=[Content.from_text(text="Follow these rules.")]) @@ -1110,7 +1203,7 @@ def test_prepare_system_message_multiple_text_contents_joined( openai_unit_test_env: dict[str, str], ) -> None: """Test that system messages with multiple text contents are joined into a single string.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() message = Message( role="system", @@ -1136,7 +1229,7 @@ def test_prepare_user_message_text_content_is_string( Some OpenAI-compatible endpoints (e.g. Foundry Local) cannot deserialize the list format. See https://github.com/microsoft/agent-framework/issues/4084 """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() message = Message(role="user", contents=[Content.from_text(text="Hello")]) @@ -1152,7 +1245,7 @@ def test_prepare_user_message_multimodal_content_remains_list( openai_unit_test_env: dict[str, str], ) -> None: """Test that multimodal user message content remains a list.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() message = Message( role="user", @@ -1173,7 +1266,7 @@ def test_prepare_assistant_message_text_content_is_string( openai_unit_test_env: dict[str, str], ) -> None: """Test that text-only assistant message content is flattened to a plain string.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() message = Message(role="assistant", contents=[Content.from_text(text="Sure, I can help.")]) @@ -1189,7 +1282,7 @@ def test_tool_choice_required_with_function_name( openai_unit_test_env: dict[str, str], ) -> None: """Test that tool_choice with required mode and function name is correctly prepared.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() messages = [Message(role="user", text="test")] options = { @@ -1207,7 +1300,7 @@ def test_tool_choice_required_with_function_name( def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) -> None: """Test that response_format as dict is passed through directly.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() messages = [Message(role="user", text="test")] custom_format = { @@ -1226,7 +1319,7 @@ def test_multiple_function_calls_in_single_message( openai_unit_test_env: dict[str, str], ) -> None: """Test that multiple function calls in a message are correctly prepared.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Create message with multiple function calls message = Message( @@ -1251,7 +1344,7 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools( openai_unit_test_env: dict[str, str], ) -> None: """Test that parallel_tool_calls is removed when no tools are present.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() messages = [Message(role="user", text="test")] options = {"allow_multiple_tool_calls": True} @@ -1264,7 +1357,7 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools( def test_prepare_options_excludes_conversation_id(openai_unit_test_env: dict[str, str]) -> None: """Test that conversation_id is excluded from prepared options for chat completions.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() messages = [Message(role="user", text="test")] options = {"conversation_id": "12345", "temperature": 0.7} @@ -1281,7 +1374,7 @@ async def test_streaming_exception_handling( openai_unit_test_env: dict[str, str], ) -> None: """Test that streaming errors are properly handled.""" - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() messages = [Message(role="user", text="test")] # Create a mock error during streaming @@ -1321,7 +1414,7 @@ class OutputStruct(BaseModel): param("presence_penalty", 0.3, False, id="presence_penalty"), param("stop", ["END"], False, id="stop"), param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"), - # OpenAIChatOptions - just verify they don't fail + # OpenAIChatCompletionOptions - just verify they don't fail param("logit_bias", {"50256": -1}, False, id="logit_bias"), param( "prediction", @@ -1377,13 +1470,13 @@ async def test_integration_options( option_value: Any, needs_validation: bool, ) -> None: - """Parametrized test covering all ChatOptions and OpenAIChatOptions. + """Parametrized test covering all ChatOptions and OpenAIChatCompletionOptions. Tests both streaming and non-streaming modes for each option to ensure they don't cause failures. Options marked with needs_validation also check that the feature actually works correctly. """ - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response client.function_invocation_configuration["max_iterations"] = 2 @@ -1458,11 +1551,11 @@ async def test_integration_options( @pytest.mark.integration @skip_if_openai_integration_tests_disabled async def test_integration_web_search() -> None: - client = OpenAIChatClient(model_id="gpt-4o-search-preview") + client = OpenAIChatCompletionClient(model="gpt-4o-search-preview") for streaming in [False, True]: # Use static method for web search tool - web_search_tool = OpenAIChatClient.get_web_search_tool() + web_search_tool = OpenAIChatCompletionClient.get_web_search_tool() content = { "messages": [ Message( @@ -1487,7 +1580,7 @@ async def test_integration_web_search() -> None: assert "Zoey" in response.text # Test that the client will use the web search tool with location - web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( + web_search_tool_with_location = OpenAIChatCompletionClient.get_web_search_tool( web_search_options={ "user_location": { "type": "approximate", diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py new file mode 100644 index 0000000000..148fcb68b9 --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py @@ -0,0 +1,336 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +from collections.abc import Awaitable, Callable + +import pytest +from agent_framework import ( + Agent, + AgentResponse, + AgentResponseUpdate, + ChatResponse, + ChatResponseUpdate, + Message, + SupportsChatGetResponse, + tool, +) +from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from openai import AsyncAzureOpenAI + +from agent_framework_openai import OpenAIChatCompletionClient + +pytestmark = pytest.mark.azure + +skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( + os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") + or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "", + reason="No real Azure OpenAI endpoint or chat deployment provided; skipping integration tests.", +) + + +def _create_azure_chat_completion_client( + *, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, +) -> OpenAIChatCompletionClient: + return OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + ) + + +@tool(approval_mode="never_require") +def get_story_text() -> str: + """Returns a story about Emily and David.""" + return ( + "Emily and David, two passionate scientists, met during a research expedition to Antarctica. " + "Bonded by their love for the natural world and shared curiosity, they uncovered a " + "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " + "of climate change." + ) + + +@tool(approval_mode="never_require") +async def get_weather(location: str) -> str: + """Get the current weather in a given location.""" + return f"The current weather in {location} is sunny, 72F." + + +def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + client = _create_azure_chat_completion_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert isinstance(client, SupportsChatGetResponse) + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + + +def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIChatCompletionClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) +def test_init_uses_default_azure_api_version(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_VERSION", "preview") + client = _create_azure_chat_completion_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.api_version == "2024-10-21" + + +def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1") + + client = OpenAIChatCompletionClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_response() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + assert isinstance(client, SupportsChatGetResponse) + + messages = [ + Message( + role="user", + text=( + "Emily and David, two passionate scientists, met during a research expedition to Antarctica. " + "Bonded by their love for the natural world and shared curiosity, they uncovered a " + "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " + "of climate change." + ), + ), + Message(role="user", text="who are Emily and David?"), + ] + + response = await client.get_response(messages=messages) + + assert response is not None + assert isinstance(response, ChatResponse) + assert any( + word in response.text.lower() for word in ["scientists", "research", "antarctica", "glaciology", "climate"] + ) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_response_tools() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + + response = await client.get_response( + messages=[Message(role="user", text="who are Emily and David?")], + options={"tools": [get_story_text], "tool_choice": "auto"}, + ) + + assert response is not None + assert isinstance(response, ChatResponse) + assert "Emily" in response.text or "David" in response.text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_streaming() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + + response = client.get_response( + messages=[ + Message( + role="user", + text=( + "Emily and David, two passionate scientists, met during a research expedition to Antarctica. " + "Bonded by their love for the natural world and shared curiosity, they uncovered a " + "groundbreaking phenomenon in glaciology that could potentially reshape our understanding " + "of climate change." + ), + ), + Message(role="user", text="who are Emily and David?"), + ], + stream=True, + ) + + full_message = "" + async for chunk in response: + assert isinstance(chunk, ChatResponseUpdate) + assert chunk.message_id is not None + assert chunk.response_id is not None + for content in chunk.contents: + if content.type == "text" and content.text: + full_message += content.text + + assert "Emily" in full_message or "David" in full_message + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_streaming_tools() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ) + + response = client.get_response( + messages=[Message(role="user", text="who are Emily and David?")], + stream=True, + options={"tools": [get_story_text], "tool_choice": "auto"}, + ) + + full_message = "" + async for chunk in response: + assert isinstance(chunk, ChatResponseUpdate) + for content in chunk.contents: + if content.type == "text" and content.text: + full_message += content.text + + assert "Emily" in full_message or "David" in full_message + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_agent_basic_run() -> None: + async with ( + AzureCliCredential() as credential, + Agent( + client=_create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + ) as agent, + ): + response = await agent.run("Please respond with exactly: 'This is a response test.'") + + assert isinstance(response, AgentResponse) + assert response.text is not None + assert "response test" in response.text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() -> None: + async with ( + AzureCliCredential() as credential, + Agent( + client=_create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + ) as agent, + ): + full_text = "" + async for chunk in agent.run( + "Please respond with exactly: 'This is a streaming response test.'", + stream=True, + ): + assert isinstance(chunk, AgentResponseUpdate) + if chunk.text: + full_text += chunk.text + + assert "streaming response test" in full_text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_agent_session_persistence() -> None: + async with ( + AzureCliCredential() as credential, + Agent( + client=_create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + instructions="You are a helpful assistant with good memory.", + ) as agent, + ): + session = agent.create_session() + response1 = await agent.run("My name is Alice. Remember this.", session=session) + response2 = await agent.run("What is my name?", session=session) + + assert isinstance(response1, AgentResponse) + assert isinstance(response2, AgentResponse) + assert response2.text is not None + assert "alice" in response2.text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_openai_chat_completion_client_agent_existing_session() -> None: + async with AzureCliCredential() as credential: + preserved_session = None + + async with Agent( + client=_create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + instructions="You are a helpful assistant with good memory.", + ) as first_agent: + session = first_agent.create_session() + first_response = await first_agent.run("My name is Alice. Remember this.", session=session) + + assert isinstance(first_response, AgentResponse) + preserved_session = session + + if preserved_session: + async with Agent( + client=_create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + instructions="You are a helpful assistant with good memory.", + ) as second_agent: + second_response = await second_agent.run("What is my name?", session=preserved_session) + + assert isinstance(second_response, AgentResponse) + assert second_response.text is not None + assert "alice" in second_response.text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +async def test_azure_chat_completion_client_agent_level_tool_persistence() -> None: + async with ( + AzureCliCredential() as credential, + Agent( + client=_create_azure_chat_completion_client( + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") + ), + instructions="You are a helpful assistant that uses available tools.", + tools=[get_weather], + ) as agent, + ): + first_response = await agent.run("What's the weather like in Chicago?") + second_response = await agent.run("What's the weather in Miami?") + + assert isinstance(first_response, AgentResponse) + assert isinstance(second_response, AgentResponse) + assert first_response.text is not None + assert second_response.text is not None + assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) + assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"]) diff --git a/python/packages/core/tests/openai/test_openai_chat_client_base.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py similarity index 92% rename from python/packages/core/tests/openai/test_openai_chat_client_base.py rename to python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py index 82838120d7..3f5cbcddfb 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client_base.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py @@ -5,6 +5,8 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent_framework import ChatResponseUpdate, Message +from agent_framework.exceptions import ChatClientException from openai import AsyncStream from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -14,9 +16,7 @@ from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDe from openai.types.chat.chat_completion_message import ChatCompletionMessage from pydantic import BaseModel -from agent_framework import ChatResponseUpdate, Message -from agent_framework.exceptions import ChatClientException -from agent_framework.openai import OpenAIChatClient +from agent_framework_openai import OpenAIChatCompletionClient async def mock_async_process_chat_stream_response(_): @@ -69,10 +69,10 @@ async def test_cmc( mock_create.return_value = mock_chat_completion_response chat_history.append(Message(role="user", text="hello world")) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response(messages=chat_history) mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=False, messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore ) @@ -88,12 +88,12 @@ async def test_cmc_chat_options( mock_create.return_value = mock_chat_completion_response chat_history.append(Message(role="user", text="hello world")) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response( messages=chat_history, ) mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=False, messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore ) @@ -110,12 +110,12 @@ async def test_cmc_no_fcc_in_response( chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response( messages=chat_history, ) mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=False, messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore ) @@ -135,7 +135,7 @@ async def test_cmc_structured_output_no_fcc( class Test(BaseModel): name: str - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response( messages=chat_history, response_format=Test, @@ -153,7 +153,7 @@ async def test_scmc_chat_options( mock_create.return_value = mock_streaming_chat_completion_response chat_history.append(Message(role="user", text="hello world")) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() async for msg in openai_chat_completion.get_response( stream=True, messages=chat_history, @@ -162,7 +162,7 @@ async def test_scmc_chat_options( assert msg.message_id is not None assert msg.response_id is not None mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=True, stream_options={"include_usage": True}, messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore @@ -179,7 +179,7 @@ async def test_cmc_general_exception( mock_create.return_value = mock_chat_completion_response chat_history.append(Message(role="user", text="hello world")) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() with pytest.raises(ChatClientException): await openai_chat_completion.get_response( messages=chat_history, @@ -196,10 +196,10 @@ async def test_cmc_additional_properties( mock_create.return_value = mock_chat_completion_response chat_history.append(Message(role="user", text="hello world")) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response(messages=chat_history, options={"reasoning_effort": "low"}) mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=False, messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore reasoning_effort="low", @@ -235,14 +235,14 @@ async def test_get_streaming( chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() async for msg in openai_chat_completion.get_response( stream=True, messages=chat_history, ): assert isinstance(msg, ChatResponseUpdate) mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=True, stream_options={"include_usage": True}, messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore @@ -275,14 +275,14 @@ async def test_get_streaming_singular( chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() async for msg in openai_chat_completion.get_response( stream=True, messages=chat_history, ): assert isinstance(msg, ChatResponseUpdate) mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=True, stream_options={"include_usage": True}, messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore @@ -318,7 +318,7 @@ async def test_get_streaming_structured_output_no_fcc( class Test(BaseModel): name: str - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() async for msg in openai_chat_completion.get_response( stream=True, messages=chat_history, @@ -339,7 +339,7 @@ async def test_get_streaming_no_fcc_in_response( chat_history.append(Message(role="user", text="hello world")) orig_chat_history = deepcopy(chat_history) - openai_chat_completion = OpenAIChatClient() + openai_chat_completion = OpenAIChatCompletionClient() [ msg async for msg in openai_chat_completion.get_response( @@ -348,7 +348,7 @@ async def test_get_streaming_no_fcc_in_response( ) ] mock_create.assert_awaited_once_with( - model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + model=openai_unit_test_env["OPENAI_MODEL"], stream=True, stream_options={"include_usage": True}, messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore @@ -378,7 +378,7 @@ def test_chat_response_created_at_uses_utc(openai_unit_test_env: dict[str, str]) object="chat.completion", ) - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() response = client._parse_response_from_openai(mock_response, {}) # Verify that created_at is correctly formatted as UTC @@ -410,7 +410,7 @@ def test_chat_response_update_created_at_uses_utc(openai_unit_test_env: dict[str object="chat.completion.chunk", ) - client = OpenAIChatClient() + client = OpenAIChatCompletionClient() response_update = client._parse_response_update_from_openai(mock_chunk) # Verify that created_at is correctly formatted as UTC diff --git a/python/packages/core/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py similarity index 89% rename from python/packages/core/tests/openai/test_openai_embedding_client.py rename to python/packages/openai/tests/openai/test_openai_embedding_client.py index 72c7e4121d..7117040ffc 100644 --- a/python/packages/core/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -10,7 +10,7 @@ from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage -from agent_framework.openai import ( +from agent_framework_openai import ( OpenAIEmbeddingClient, OpenAIEmbeddingOptions, ) @@ -36,7 +36,7 @@ def _make_openai_response( def openai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: """Set up environment variables for OpenAI embedding client.""" monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") - monkeypatch.setenv("OPENAI_EMBEDDING_MODEL_ID", "text-embedding-3-small") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") # --- OpenAI unit tests --- @@ -44,26 +44,26 @@ def openai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: def test_openai_construction_with_explicit_params() -> None: client = OpenAIEmbeddingClient( - model_id="text-embedding-3-small", + model="text-embedding-3-small", api_key="test-key", ) - assert client.model_id == "text-embedding-3-small" + assert client.model == "text-embedding-3-small" def test_openai_construction_from_env(openai_unit_test_env: None) -> None: client = OpenAIEmbeddingClient() - assert client.model_id == "text-embedding-3-small" + assert client.model == "text-embedding-3-small" def test_openai_construction_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("OPENAI_API_KEY", raising=False) with pytest.raises(ValueError, match="API key is required"): - OpenAIEmbeddingClient(model_id="text-embedding-3-small") + OpenAIEmbeddingClient(model="text-embedding-3-small") def test_openai_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OPENAI_EMBEDDING_MODEL_ID", raising=False) - with pytest.raises(ValueError, match="model ID is required"): + monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) + with pytest.raises(ValueError, match="embedding model is required"): OpenAIEmbeddingClient(api_key="test-key") @@ -81,7 +81,7 @@ async def test_openai_get_embeddings(openai_unit_test_env: None) -> None: assert len(result) == 2 assert result[0].vector == [0.1, 0.2, 0.3] assert result[1].vector == [0.4, 0.5, 0.6] - assert result[0].model_id == "text-embedding-3-small" + assert result[0].model == "text-embedding-3-small" assert result[0].dimensions == 3 @@ -167,12 +167,12 @@ async def test_openai_base64_decoding(openai_unit_test_env: None) -> None: async def test_openai_error_when_no_model_id() -> None: client = OpenAIEmbeddingClient.__new__(OpenAIEmbeddingClient) - client.model_id = None + client.model = None client.client = MagicMock() client.additional_properties = {} client.otel_provider_name = "openai" - with pytest.raises(ValueError, match="model_id is required"): + with pytest.raises(ValueError, match="model is required"): await client.get_embeddings(["test"]) @@ -202,7 +202,7 @@ skip_if_openai_integration_tests_disabled = pytest.mark.skipif( @pytest.mark.integration async def test_integration_openai_get_embeddings() -> None: """End-to-end test of OpenAI embedding generation.""" - client = OpenAIEmbeddingClient(model_id="text-embedding-3-small") + client = OpenAIEmbeddingClient(model="text-embedding-3-small") result = await client.get_embeddings(["hello world"]) @@ -210,7 +210,7 @@ async def test_integration_openai_get_embeddings() -> None: assert isinstance(result[0].vector, list) assert len(result[0].vector) > 0 assert all(isinstance(v, float) for v in result[0].vector) - assert result[0].model_id is not None + assert result[0].model is not None assert result.usage is not None assert result.usage["input_token_count"] > 0 @@ -220,7 +220,7 @@ async def test_integration_openai_get_embeddings() -> None: @pytest.mark.integration async def test_integration_openai_get_embeddings_multiple() -> None: """Test embedding generation for multiple inputs.""" - client = OpenAIEmbeddingClient(model_id="text-embedding-3-small") + client = OpenAIEmbeddingClient(model="text-embedding-3-small") result = await client.get_embeddings(["hello", "world", "test"]) @@ -234,7 +234,7 @@ async def test_integration_openai_get_embeddings_multiple() -> None: @pytest.mark.integration async def test_integration_openai_get_embeddings_with_dimensions() -> None: """Test embedding generation with custom dimensions.""" - client = OpenAIEmbeddingClient(model_id="text-embedding-3-small") + client = OpenAIEmbeddingClient(model="text-embedding-3-small") options: OpenAIEmbeddingOptions = {"dimensions": 256} result = await client.get_embeddings(["hello world"], options=options) 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..bda7f194ab 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -11,7 +11,8 @@ workflow where: - The workflow finishes with the final context produced by the last participant Typical wiring: - input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> ... -> participantN -> _EndWithConversation + input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> + ... -> participantN -> _EndWithConversation Notes: - Participants can mix SupportsAgentRun and Executor objects @@ -34,11 +35,11 @@ These adapters are first-class executors by design so they are type-checked at e observable (ExecutorInvoke/Completed events), and easily testable/reusable. Their IDs are deterministic and self-describing (for example, "to-conversation:writer") to reduce event-log confusion and to mirror how the concurrent builder uses explicit dispatcher/aggregator nodes. -""" # noqa: E501 +""" 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 +144,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 +152,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 +231,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 +244,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/pyproject.toml b/python/packages/orchestrations/pyproject.toml index bd7d1c8ac5..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] @@ -83,9 +83,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_orchestrations" -test = 'pytest -m "not integration" --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_orchestrations" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] 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/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/packages/purview/agent_framework_purview/_client.py b/python/packages/purview/agent_framework_purview/_client.py index e592f34da5..af5c3f8224 100644 --- a/python/packages/purview/agent_framework_purview/_client.py +++ b/python/packages/purview/agent_framework_purview/_client.py @@ -6,12 +6,12 @@ import base64 import inspect import json import logging -from typing import Any, Literal, TypeVar, overload +from collections.abc import Awaitable, Callable +from typing import Any, Literal, TypeVar, Union, overload from uuid import uuid4 import httpx from agent_framework import AGENT_FRAMEWORK_USER_AGENT -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider from agent_framework.observability import get_tracer from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -34,6 +34,9 @@ from ._models import ( ) from ._settings import PurviewSettings, get_purview_scopes +AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential] +AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] + logger = logging.getLogger("agent_framework.purview") ResponseT = TypeVar("ResponseT") diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py index c0e89a04a5..0b7442b47d 100644 --- a/python/packages/purview/agent_framework_purview/_middleware.py +++ b/python/packages/purview/agent_framework_purview/_middleware.py @@ -2,9 +2,11 @@ import logging from collections.abc import Awaitable, Callable +from typing import Union from agent_framework import AgentContext, AgentMiddleware, ChatContext, ChatMiddleware, MiddlewareTermination -from agent_framework.azure._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from ._cache import CacheProvider from ._client import PurviewClient @@ -13,6 +15,9 @@ from ._models import Activity from ._processor import ScopedContentProcessor from ._settings import PurviewSettings +AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential] +AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] + logger = logging.getLogger("agent_framework.purview") diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 05f3f4b828..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", ] @@ -84,9 +84,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_purview" -test = 'pytest -m "not integration" --cov=agent_framework_purview --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_purview" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_purview --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.9,<4.0"] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index e1bb352696..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" @@ -87,9 +87,13 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" -[tool.poe.tasks] -mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_redis" -test = 'pytest -m "not integration" --cov=agent_framework_redis --cov-report=term-missing:skip-covered tests' +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_redis" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_redis --cov-report=term-missing:skip-covered tests' [build-system] requires = ["flit-core >= 3.11,<4.0"] diff --git a/python/pyproject.toml b/python/pyproject.toml index 0e53e072d1..c19a6024d0 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] @@ -39,6 +39,8 @@ dev = [ "pytest-retry==1.7.0", "mypy==1.19.1", "pyright==1.1.408", + "mcp[ws]>=1.24.0,<2", + "opentelemetry-sdk>=1.39.0,<2", #tasks "poethepoet==0.42.1", "rich==13.7.1", @@ -49,6 +51,8 @@ dev = [ [tool.uv] package = false prerelease = "if-necessary-or-explicit" +# Keep transitive litellm below the compromised 1.82.7/1.82.8 releases. +constraint-dependencies = ["litellm<1.82.7"] environments = [ "sys_platform == 'darwin'", "sys_platform == 'linux'", @@ -74,15 +78,18 @@ agent-framework-copilotstudio = { workspace = true } agent-framework-declarative = { workspace = true } agent-framework-devui = { workspace = true } agent-framework-durabletask = { workspace = true } +agent-framework-foundry = { workspace = true } agent-framework-foundry-local = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } agent-framework-ollama = { workspace = true } +agent-framework-openai = { workspace = true } agent-framework-purview = { workspace = true } agent-framework-redis = { workspace = true } agent-framework-github-copilot = { workspace = true } agent-framework-claude = { workspace = true } agent-framework-orchestrations = { workspace = true } +litellm = { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" } [tool.ruff] line-length = 120 @@ -191,6 +198,7 @@ executionEnvironments = [ { root = "packages/declarative/tests", reportPrivateUsage = "none" }, { root = "packages/devui/tests", reportPrivateUsage = "none" }, { root = "packages/durabletask/tests", reportPrivateUsage = "none" }, + { root = "packages/foundry/tests", reportPrivateUsage = "none" }, { root = "packages/foundry_local/tests", reportPrivateUsage = "none" }, { root = "packages/github_copilot/tests", reportPrivateUsage = "none" }, { root = "packages/lab/gaia/tests", reportPrivateUsage = "none" }, @@ -225,94 +233,137 @@ exclude_dirs = ["tests", "scripts", "samples"] [tool.poe] executor.type = "uv" -[tool.poe.tasks] -markdown-code-lint = "uv run python scripts/check_md_code_blocks.py 'README.md' './packages/**/README.md' './samples/**/*.md' --exclude cookiecutter-agent-framework-lab --exclude tau2 --exclude 'packages/devui/frontend' --exclude context_providers/azure_ai_search" -prek-install = "prek install --overwrite" -install = "uv sync --all-packages --all-extras --dev --frozen --prerelease=if-necessary-or-explicit" -test = "python scripts/run_tasks_in_packages_if_exists.py test" -fmt = "python scripts/run_tasks_in_packages_if_exists.py fmt" -format.ref = "fmt" -lint = "python scripts/run_tasks_in_packages_if_exists.py lint" -samples-lint = "ruff check samples --fix --exclude samples/autogen-migration,samples/semantic-kernel-migration --ignore E501,ASYNC,B901,TD002" -pyright = "python scripts/run_tasks_in_packages_if_exists.py pyright" -mypy = "python scripts/run_tasks_in_packages_if_exists.py mypy" -typing = "python scripts/run_tasks_in_packages_if_exists.py mypy pyright" -samples-syntax.shell = "pyright -p $(python -c \"import sys; print('pyrightconfig.samples.py310.json' if sys.version_info < (3,11) else 'pyrightconfig.samples.json')\") --warnings" -samples-syntax.interpreter = "posix" -# cleaning -clean-dist-packages = "python scripts/run_tasks_in_packages_if_exists.py clean-dist" -clean-dist-meta = "rm -rf dist" -clean-dist = ["clean-dist-packages", "clean-dist-meta"] -# build and publish -build-packages = "python scripts/run_tasks_in_packages_if_exists.py build" -build-meta = "python -m flit build" -build = ["build-packages", "build-meta"] -publish = "uv publish" -# combined checks -check-packages = "python scripts/run_tasks_in_packages_if_exists.py fmt lint pyright" -check = ["check-packages", "samples-lint", "samples-syntax", "test", "markdown-code-lint"] - -[tool.poe.tasks.all-tests-cov] -cmd = """ -pytest --import-mode=importlib --m "not integration" ---cov=agent_framework ---cov=agent_framework_core ---cov=agent_framework_a2a ---cov=agent_framework_ag_ui ---cov=agent_framework_anthropic ---cov=agent_framework_azure_ai ---cov=agent_framework_azure_ai_search ---cov=agent_framework_azurefunctions ---cov=agent_framework_chatkit ---cov=agent_framework_copilotstudio ---cov=agent_framework_mem0 ---cov=agent_framework_purview ---cov=agent_framework_redis ---cov=agent_framework_orchestrations ---cov=agent_framework_declarative ---cov-config=pyproject.toml ---cov-report=term-missing:skip-covered ---ignore-glob=packages/lab/** ---ignore-glob=packages/devui/** --rs --n logical --dist worksteal - packages/**/tests -""" - -[tool.poe.tasks.all-tests] -cmd = """ -pytest --import-mode=importlib --m "not integration" ---ignore-glob=packages/lab/** ---ignore-glob=packages/devui/** --rs --n logical --dist worksteal - packages/**/tests -""" - -[tool.poe.tasks.venv] -cmd = "uv venv --clear --python $python" -args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }] +# Workspace setup +[tool.poe.tasks.install] +help = "Install all workspace packages, extras, and dev dependencies from the lockfile." +cmd = "uv sync --all-packages --all-extras --dev --frozen --prerelease=if-necessary-or-explicit" [tool.poe.tasks.setup] +help = "Create the workspace virtual environment for -P/--python, install dependencies, and install prek hooks." sequence = [ { ref = "venv --python $python"}, { ref = "install" }, { ref = "prek-install" } ] -args = [{ name = "python", default = "3.13", options = ['-p', '--python'] }] +args = [{ name = "python", default = "3.13", options = ['-P', '-p', '--python'] }] +[tool.poe.tasks.venv] +help = "Create or recreate the workspace virtual environment for -P/--python." +cmd = "uv venv --clear --python $python" +args = [{ name = "python", default = "3.13", options = ['-P', '-p', '--python'] }] + +[tool.poe.tasks.prek-install] +help = "Install or refresh the prek git hooks." +cmd = "prek install --overwrite" + +# Syntax, typing, and validation +[tool.poe.tasks.syntax] +help = "Run Ruff formatting and Ruff checks for -P/--package packages, or use -S/--samples; add -F/--format or -C/--check to narrow the mode." +cmd = "python scripts/workspace_poe_tasks.py syntax" + +[tool.poe.tasks.fmt] +help = "DEPRECATED: Use `syntax --format` instead." +cmd = "python scripts/workspace_poe_tasks.py syntax --format" + +[tool.poe.tasks.format] +help = "DEPRECATED: Use `syntax --format` instead." +cmd = "python scripts/workspace_poe_tasks.py syntax --format" + +[tool.poe.tasks.lint] +help = "DEPRECATED: Use `syntax --check` instead." +cmd = "python scripts/workspace_poe_tasks.py syntax --check" + +[tool.poe.tasks.samples-lint] +help = "DEPRECATED: Use `syntax --samples --check` instead." +cmd = "python scripts/workspace_poe_tasks.py syntax --samples --check" + +[tool.poe.tasks.pyright] +help = "Run Pyright for -P/--package packages, use -A/--all for one aggregate sweep, or use -S/--samples for sample checks." +cmd = "python scripts/workspace_poe_tasks.py pyright" + +[tool.poe.tasks.mypy] +help = "Run MyPy for -P/--package packages, or use -A/--all for one aggregate sweep." +cmd = "python scripts/workspace_poe_tasks.py mypy" + +[tool.poe.tasks.typing] +help = "Run both MyPy and Pyright for -P/--package packages, or use -A/--all for aggregate mode." +cmd = "python scripts/workspace_poe_tasks.py typing" + +[tool.poe.tasks.samples-syntax] +help = "DEPRECATED: Use `pyright --samples` instead." +cmd = "python scripts/workspace_poe_tasks.py pyright --samples" + +[tool.poe.tasks.check-packages] +help = "Run `syntax` and `pyright` for -P/--package packages." +cmd = "python scripts/workspace_poe_tasks.py check-packages" + +[tool.poe.tasks.check] +help = "Run package syntax, pyright, and tests for -P/--package packages; without -P also include sample checks and markdown code lint, or use -S/--samples for sample-only checks." +cmd = "python scripts/workspace_poe_tasks.py check" + +[tool.poe.tasks.markdown-code-lint] +help = "Lint Python code blocks embedded in README and sample markdown files." +cmd = "uv run python scripts/check_md_code_blocks.py 'README.md' './packages/**/README.md' './samples/**/*.md' --exclude cookiecutter-agent-framework-lab --exclude tau2 --exclude 'packages/devui/frontend' --exclude context_providers/azure_ai_search" + +# Testing +[tool.poe.tasks.test] +help = "Run tests for -P/--package packages, or use -A/--all for one aggregate sweep; add -C/--cov for coverage." +cmd = "python scripts/workspace_poe_tasks.py test" + +[tool.poe.tasks.all-tests] +help = "DEPRECATED: Use `test --all` instead." +cmd = "python scripts/workspace_poe_tasks.py test --all" + +[tool.poe.tasks.all-tests-cov] +help = "DEPRECATED: Use `test --all --cov` instead." +cmd = "python scripts/workspace_poe_tasks.py test --all --cov" + +# Build and publishing +[tool.poe.tasks._clean-dist-packages] +cmd = "python scripts/workspace_poe_tasks.py clean-dist" + +[tool.poe.tasks._clean-dist-meta] +cmd = "rm -rf dist" + +[tool.poe.tasks.clean-dist] +help = "Remove generated dist artifacts for -P/--package packages and the root meta package." +sequence = [ + { ref = "_clean-dist-packages --package ${project}" }, + { ref = "_clean-dist-meta" }, +] +args = [{ name = "project", default = "*", options = ["-P", "--package"] }] + +[tool.poe.tasks._build-packages] +cmd = "python scripts/workspace_poe_tasks.py build" + +[tool.poe.tasks._build-meta] +cmd = "python -m flit build" + +[tool.poe.tasks.build] +help = "Build -P/--package packages and the root meta package." +sequence = [ + { ref = "_build-packages --package ${project}" }, + { ref = "_build-meta" }, +] +args = [{ name = "project", default = "*", options = ["-P", "--package"] }] + +[tool.poe.tasks.publish] +help = "Publish built distributions with uv." +cmd = "uv publish" + +# Dependency maintenance [tool.poe.tasks.upgrade-dev-dependency-pins] +help = "Repin the workspace dev dependency versions used in pyproject.toml." cmd = "python -m scripts.dependencies.upgrade_dev_dependencies" -[tool.poe.tasks.upgrade-lockfile] +[tool.poe.tasks._upgrade-lockfile] cmd = "uv lock --upgrade" [tool.poe.tasks.upgrade-dev-dependencies] +help = "Repin dev dependencies, refresh uv.lock, reinstall, and rerun validation commands." sequence = [ { ref = "upgrade-dev-dependency-pins" }, - { ref = "upgrade-lockfile" }, + { ref = "_upgrade-lockfile" }, { ref = "install" }, { ref = "check" }, { ref = "typing" }, @@ -320,17 +371,20 @@ sequence = [ ] [tool.poe.tasks.add-dependency-to-project] -cmd = "uv add --package ${project} ${dependency}" +help = "Add a dependency to a -P/--package workspace package selected by short name such as `core`." +cmd = "python -m scripts.dependencies.add_dependency_to_project --package ${project} --dependency ${dependency}" args = [ - { name = "project", options = ["-p", "--project"] }, - { name = "dependency", options = ["-d", "--dependency"] }, + { name = "project", options = ["-P", "--package"] }, + { name = "dependency", options = ["-D", "-d", "--dependency"] }, ] [tool.poe.tasks.validate-dependency-bounds-test] +help = "Run workspace dependency-bound validation in test mode, optionally scoped with -P/--package short names such as `core`." shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test --package \"$project\"" -args = [{ name = "project", default = "*", options = ["-p", "--project"] }] +args = [{ name = "project", default = "*", options = ["-P", "--package"] }] [tool.poe.tasks.validate-dependency-bounds-project] +help = "Validate lower and upper dependency bounds for a -P/--package workspace package, optionally narrowed with -M/--mode and -D/--dependency." shell = """ command=(python -m scripts.dependencies.validate_dependency_bounds --mode "${mode}" --package "${project}") if [ -n "${dependency}" ]; then @@ -340,85 +394,22 @@ fi """ interpreter = "bash" args = [ - { name = "mode", default = "both", options = ["-m", "--mode"] }, - { name = "project", default = "*", options = ["-p", "--project"] }, - { name = "dependency", default = "", options = ["-d", "--dependency"] }, + { name = "mode", default = "both", options = ["-M", "-m", "--mode"] }, + { name = "project", default = "*", options = ["-P", "--package"] }, + { name = "dependency", default = "", options = ["-D", "-d", "--dependency"] }, ] [tool.poe.tasks.add-dependency-and-validate-bounds] +help = "Add a dependency to a -P/--package workspace package selected by short name such as `core`, then validate its dependency bounds with -D/--dependency." sequence = [ - { ref = "add-dependency-to-project --project ${project} --dependency ${dependency}" }, - { ref = "validate-dependency-bounds-project --mode both --project ${project} --dependency ${dependency}" }, + { ref = "add-dependency-to-project --package ${project} --dependency ${dependency}" }, + { ref = "validate-dependency-bounds-project --mode both --package ${project} --dependency ${dependency}" }, ] args = [ - { name = "project", options = ["-p", "--project"] }, - { name = "dependency", options = ["-d", "--dependency"] }, + { name = "project", options = ["-P", "--package"] }, + { name = "dependency", options = ["-D", "-d", "--dependency"] }, ] -[tool.poe.tasks.prek-pyright] -cmd = "uv run python scripts/run_tasks_in_changed_packages.py pyright --files ${files}" -args = [{ name = "files", default = ".", positional = true, multiple = true }] - -[tool.poe.tasks.prek-check-packages] -cmd = "uv run python scripts/run_tasks_in_changed_packages.py fmt lint pyright --files ${files}" -args = [{ name = "files", default = ".", positional = true, multiple = true }] - -[tool.poe.tasks.prek-markdown-code-lint] -cmd = """uv run python scripts/check_md_code_blocks.py ${files} --no-glob - --exclude cookiecutter-agent-framework-lab --exclude tau2 - --exclude packages/devui/frontend --exclude context_providers/azure_ai_search""" -args = [{ name = "files", default = ".", positional = true, multiple = true }] - -[tool.poe.tasks.prek-samples-check] -shell = """ -HAS_SAMPLES=false -for f in ${files}; do - case "$f" in - samples/*) HAS_SAMPLES=true; break ;; - esac -done -if [ "$HAS_SAMPLES" = true ]; then - echo "Sample files changed, running samples checks..." - uv run ruff check samples --fix --exclude samples/autogen-migration,samples/semantic-kernel-migration --ignore E501,ASYNC,B901,TD002 - uv run pyright -p pyrightconfig.samples.json --warnings -else - echo "No sample files changed, skipping samples checks" -fi -""" -interpreter = "bash" -args = [{ name = "files", default = ".", positional = true, multiple = true }] - - -[tool.poe.tasks.ci-mypy] -shell = """ -# Try multiple strategies to get changed files -if [ -n "$GITHUB_BASE_REF" ]; then - # In GitHub Actions PR context - git fetch origin $GITHUB_BASE_REF --depth=1 2>/dev/null || true - CHANGED_FILES=$(git diff --name-only origin/$GITHUB_BASE_REF...HEAD -- . 2>/dev/null || \ - git diff --name-only FETCH_HEAD...HEAD -- . 2>/dev/null || \ - git diff --name-only HEAD^...HEAD -- . 2>/dev/null || \ - echo ".") -else - # Local development - CHANGED_FILES=$(git diff --name-only origin/main...HEAD -- . 2>/dev/null || \ - git diff --name-only main...HEAD -- . 2>/dev/null || \ - git diff --name-only HEAD~1 -- . 2>/dev/null || \ - echo ".") -fi -echo "Changed files: $CHANGED_FILES" -uv run python scripts/run_tasks_in_changed_packages.py mypy --files $CHANGED_FILES -""" -interpreter = "bash" - -[tool.poe.tasks.prek-check] -sequence = [ - { ref = "prek-check-packages ${files}" }, - { ref = "prek-markdown-code-lint ${files}" }, - { ref = "prek-samples-check ${files}" } -] -args = [{ name = "files", default = ".", positional = true, multiple = true }] - [tool.setuptools.packages.find] where = ["packages"] include = ["agent_framework**"] diff --git a/python/samples/01-get-started/01_hello_agent.py b/python/samples/01-get-started/01_hello_agent.py index 167aa8065c..a1d089f768 100644 --- a/python/samples/01-get-started/01_hello_agent.py +++ b/python/samples/01-get-started/01_hello_agent.py @@ -1,39 +1,31 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() """ Hello Agent — Simplest possible agent -This sample creates a minimal agent using AzureOpenAIResponsesClient via an +This sample creates a minimal agent using FoundryChatClient via an Azure AI Foundry project endpoint, and runs it in both non-streaming and streaming modes. There are XML tags in all of the get started samples, those are used to display the same code in the docs repo. - -Environment variables: - AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) """ async def main() -> None: # - credential = AzureCliCredential() - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - credential=credential, + client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), ) - agent = client.as_agent( + agent = Agent( + client=client, name="HelloAgent", instructions="You are a friendly assistant. Keep your answers brief.", ) diff --git a/python/samples/01-get-started/02_add_tools.py b/python/samples/01-get-started/02_add_tools.py index 06108bb388..7b558abeae 100644 --- a/python/samples/01-get-started/02_add_tools.py +++ b/python/samples/01-get-started/02_add_tools.py @@ -1,28 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os from random import randint from typing import Annotated -from agent_framework import tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv from pydantic import Field -# Load environment variables from .env file -load_dotenv() - """ Add Tools — Give your agent a function tool This sample shows how to define a function tool with the @tool decorator and wire it into an agent so the model can call it. - -Environment variables: - AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) """ @@ -36,22 +27,24 @@ def get_weather( """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." + + # async def main() -> None: - credential = AzureCliCredential() - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - credential=credential, + client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), ) # - agent = client.as_agent( + agent = Agent( + client=client, name="WeatherAgent", instructions="You are a helpful weather agent. Use the get_weather tool to answer questions.", - tools=get_weather, + tools=[get_weather], ) # diff --git a/python/samples/01-get-started/03_multi_turn.py b/python/samples/01-get-started/03_multi_turn.py index 16a0f0060a..9e6984dada 100644 --- a/python/samples/01-get-started/03_multi_turn.py +++ b/python/samples/01-get-started/03_multi_turn.py @@ -1,37 +1,29 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() """ Multi-Turn Conversations — Use AgentSession to maintain context This sample shows how to keep conversation history across multiple calls by reusing the same session object. - -Environment variables: - AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) """ async def main() -> None: # - credential = AzureCliCredential() - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - credential=credential, + client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), ) - agent = client.as_agent( + agent = Agent( + client=client, name="ConversationAgent", instructions="You are a friendly assistant. Keep your answers brief.", ) diff --git a/python/samples/01-get-started/04_memory.py b/python/samples/01-get-started/04_memory.py index c554be7337..763a872ca7 100644 --- a/python/samples/01-get-started/04_memory.py +++ b/python/samples/01-get-started/04_memory.py @@ -1,16 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os from typing import Any -from agent_framework import AgentSession, BaseContextProvider, SessionContext -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, AgentSession, BaseContextProvider, SessionContext +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() """ Agent Memory with Context Providers and Session State @@ -18,10 +13,6 @@ Agent Memory with Context Providers and Session State Context providers inject dynamic context into each agent call. This sample shows a provider that stores the user's name in session state and personalizes responses — the name persists across turns via the session. - -Environment variables: - AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) """ @@ -68,19 +59,21 @@ class UserMemoryProvider(BaseContextProvider): text = msg.text if hasattr(msg, "text") else "" if isinstance(text, str) and "my name is" in text.lower(): state["user_name"] = text.lower().split("my name is")[-1].strip().split()[0].capitalize() + + # async def main() -> None: # - credential = AzureCliCredential() - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - credential=credential, + client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), ) - agent = client.as_agent( + agent = Agent( + client=client, name="MemoryAgent", instructions="You are a friendly assistant.", context_providers=[UserMemoryProvider()], diff --git a/python/samples/01-get-started/05_first_workflow.py b/python/samples/01-get-started/05_first_workflow.py index 89b4f608b2..74720e529f 100644 --- a/python/samples/01-get-started/05_first_workflow.py +++ b/python/samples/01-get-started/05_first_workflow.py @@ -45,6 +45,8 @@ def create_workflow(): """Build the workflow: UpperCase → reverse_text.""" upper = UpperCase(id="upper_case") return WorkflowBuilder(start_executor=upper).add_edge(upper, reverse_text).build() + + # diff --git a/python/samples/01-get-started/06_host_your_agent.py b/python/samples/01-get-started/06_host_your_agent.py index 6bc87b48b4..12299f54de 100644 --- a/python/samples/01-get-started/06_host_your_agent.py +++ b/python/samples/01-get-started/06_host_your_agent.py @@ -4,45 +4,37 @@ # fmt: off from typing import Any -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +from agent_framework import Agent +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() """Host your agent with Azure Functions. - This sample shows the Python hosting pattern used in docs: -- Create an agent with `AzureOpenAIChatClient` +- Create an agent with `FoundryChatClient` - Register it with `AgentFunctionApp` - Run with Azure Functions Core Tools (`func start`) - Prerequisites: pip install agent-framework-azurefunctions --pre - -Environment variables: - AZURE_OPENAI_ENDPOINT - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME """ # def _create_agent() -> Any: """Create a hosted agent backed by Azure OpenAI.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), + ), name="HostedAgent", instructions="You are a helpful assistant hosted in Azure Functions.", ) - - # - # app = AgentFunctionApp(agents=[_create_agent()], enable_health_check=True, max_poll_retries=50) # - - if __name__ == "__main__": print("Start the Functions host with: func start") print("Then call: POST /api/agents/HostedAgent/run") diff --git a/python/samples/02-agents/auto_retry.py b/python/samples/02-agents/auto_retry.py index 7c985bd0c1..5edf112d5f 100644 --- a/python/samples/02-agents/auto_retry.py +++ b/python/samples/02-agents/auto_retry.py @@ -15,8 +15,8 @@ import logging from collections.abc import Awaitable, Callable from typing import Any, TypeVar, cast -from agent_framework import ChatContext, ChatMiddleware, SupportsChatGetResponse, chat_middleware -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent, ChatContext, ChatMiddleware, SupportsChatGetResponse, chat_middleware +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from openai import RateLimitError @@ -104,7 +104,7 @@ def with_rate_limit_retry(*, retry_attempts: int = RETRY_ATTEMPTS) -> Callable[[ @with_rate_limit_retry() -class RetryingAzureOpenAIChatClient(AzureOpenAIChatClient): +class RetryingFoundryChatClient(FoundryChatClient): """Azure OpenAI Chat client with class-decorator-based retry behavior.""" @@ -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( @@ -184,7 +186,8 @@ async def class_decorator_example() -> None: # For authentication, run `az login` command in terminal or replace # AzureCliCredential with your preferred authentication option. - agent = RetryingAzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=RetryingFoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", ) @@ -202,7 +205,8 @@ async def class_based_middleware_example() -> None: # For authentication, run `az login` command in terminal or replace # AzureCliCredential with your preferred authentication option. - agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", middleware=[RateLimitRetryMiddleware(max_attempts=3)], ) @@ -221,7 +225,8 @@ async def function_based_middleware_example() -> None: # For authentication, run `az login` command in terminal or replace # AzureCliCredential with your preferred authentication option. - agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", middleware=[rate_limit_retry_middleware], ) @@ -237,7 +242,7 @@ async def main() -> None: print("=== Auto-Retry Rate Limiting Sample ===") print( "Demonstrates two approaches for automatic retry on rate limit (429) errors.\n" - "Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME (and optionally\n" + "Set AZURE_OPENAI_ENDPOINT and FOUNDRY_MODEL (and optionally\n" "AZURE_OPENAI_API_KEY) before running, or populate a .env file." ) diff --git a/python/samples/02-agents/background_responses.py b/python/samples/02-agents/background_responses.py index 777c348b0a..002dc17a34 100644 --- a/python/samples/02-agents/background_responses.py +++ b/python/samples/02-agents/background_responses.py @@ -29,7 +29,7 @@ Prerequisites: agent = Agent( name="researcher", instructions="You are a helpful research assistant. Be concise.", - client=OpenAIResponsesClient(model_id="o3"), + client=OpenAIResponsesClient(model="o3"), ) diff --git a/python/samples/02-agents/chat_client/built_in_chat_clients.py b/python/samples/02-agents/chat_client/built_in_chat_clients.py index 8560afcf4f..b83a077bc5 100644 --- a/python/samples/02-agents/chat_client/built_in_chat_clients.py +++ b/python/samples/02-agents/chat_client/built_in_chat_clients.py @@ -5,11 +5,11 @@ import os from random import randint from typing import Annotated, Any, Literal -from agent_framework import SupportsChatGetResponse, tool +from agent_framework import Message, SupportsChatGetResponse, tool from agent_framework.azure import ( - AzureAIAgentClient, AzureOpenAIAssistantsClient, ) +from agent_framework.foundry import FoundryChatClient from agent_framework.openai import OpenAIAssistantsClient from azure.identity import AzureCliCredential from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential @@ -70,16 +70,12 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: """Create a built-in chat client from a name.""" from agent_framework.amazon import BedrockChatClient from agent_framework.anthropic import AnthropicClient - from agent_framework.azure import ( - AzureOpenAIChatClient, - AzureOpenAIResponsesClient, - ) from agent_framework.ollama import OllamaChatClient - from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient + from agent_framework.openai import OpenAIResponsesClient # 1. Create OpenAI clients. if client_name == "openai_chat": - return OpenAIChatClient() + return FoundryChatClient() if client_name == "openai_responses": return OpenAIResponsesClient() if client_name == "openai_assistants": @@ -93,13 +89,13 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: # 2. Create Azure OpenAI clients. if client_name == "azure_openai_chat": - return AzureOpenAIChatClient(credential=AzureCliCredential()) + return FoundryChatClient(credential=AzureCliCredential()) if client_name == "azure_openai_responses": - return AzureOpenAIResponsesClient(credential=AzureCliCredential(), api_version="preview") + return FoundryChatClient(credential=AzureCliCredential(), api_version="preview") if client_name == "azure_openai_responses_foundry": - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) if client_name == "azure_openai_assistants": @@ -107,7 +103,7 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: # 3. Create Azure AI client. if client_name == "azure_ai_agent": - return AzureAIAgentClient(credential=AsyncAzureCliCredential()) + return FoundryChatClient(credential=AsyncAzureCliCredential()) raise ValueError(f"Unsupported client name: {client_name}") @@ -117,35 +113,37 @@ async def main(client_name: ClientName = "openai_chat") -> None: client = get_client(client_name) # 1. Configure prompt and streaming mode. - message = "What's the weather in Amsterdam and in Paris?" + message = Message("user", text="What's the weather in Amsterdam and in Paris?") stream = os.getenv("STREAM", "false").lower() == "true" print(f"Client: {client_name}") - print(f"User: {message}") + print(f"User: {message.text}") # 2. Run with context-managed clients. - if isinstance(client, OpenAIAssistantsClient | AzureOpenAIAssistantsClient | AzureAIAgentClient): + if isinstance(client, OpenAIAssistantsClient | AzureOpenAIAssistantsClient | FoundryChatClient): async with client: if stream: - response_stream = client.get_response(message, stream=True, options={"tools": get_weather}) + response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) print("Assistant: ", end="") async for chunk in response_stream: if chunk.text: print(chunk.text, end="") print("") else: - print(f"Assistant: {await client.get_response(message, stream=False, options={'tools': get_weather})}") + print( + f"Assistant: {await client.get_response([message], stream=False, options={'tools': get_weather})}" + ) return # 3. Run with non-context-managed clients. if stream: - response_stream = client.get_response(message, stream=True, options={"tools": get_weather}) + response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) print("Assistant: ", end="") async for chunk in response_stream: if chunk.text: print(chunk.text, end="") print("") else: - print(f"Assistant: {await client.get_response(message, stream=False, options={'tools': get_weather})}") + print(f"Assistant: {await client.get_response([message], stream=False, options={'tools': get_weather})}") if __name__ == "__main__": diff --git a/python/samples/02-agents/chat_client/chat_response_cancellation.py b/python/samples/02-agents/chat_client/chat_response_cancellation.py index dd32379443..8fb71e7673 100644 --- a/python/samples/02-agents/chat_client/chat_response_cancellation.py +++ b/python/samples/02-agents/chat_client/chat_response_cancellation.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Message -from agent_framework.openai import OpenAIChatClient +from agent_framework.foundry import FoundryChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -23,13 +23,15 @@ async def main() -> None: Creates a task for the chat request, waits briefly, then cancels it to show proper cleanup. Configuration: - - OpenAI model ID: Use "model_id" parameter or "OPENAI_CHAT_MODEL_ID" environment variable + - OpenAI model ID: Use "model_id" parameter or "OPENAI_MODEL" environment variable - OpenAI API key: Use "api_key" parameter or "OPENAI_API_KEY" environment variable """ - client = OpenAIChatClient() + client = FoundryChatClient() try: - task = asyncio.create_task(client.get_response(messages=[Message(role="user", text="Tell me a fantasy story.")])) + task = asyncio.create_task( + client.get_response(messages=[Message(role="user", text="Tell me a fantasy story.")]) + ) await asyncio.sleep(1) task.cancel() await task 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 cb63c74597..c677c09d8b 100644 --- a/python/samples/02-agents/chat_client/custom_chat_client.py +++ b/python/samples/02-agents/chat_client/custom_chat_client.py @@ -7,6 +7,7 @@ from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence from typing import Any, ClassVar, TypeAlias, TypedDict from agent_framework import ( + Agent, BaseChatClient, ChatMiddlewareLayer, ChatResponse, @@ -29,7 +30,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. """ @@ -94,9 +98,7 @@ class EchoingChatClient(BaseChatClient[OptionsT]): response_text = f"{response_text} {suffix}" stream_delay_seconds = float(options.get("stream_delay_seconds", 0.05)) - response_message = Message( - role="assistant", contents=[Content.from_text(response_text)] - ) + response_message = Message(role="assistant", text=response_text) response = ChatResponse( messages=[response_message], @@ -126,9 +128,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.""" @@ -158,7 +160,8 @@ async def main() -> None: print(f"Direct response: {direct_response.messages[0].text}") # Create an agent using the custom chat client - echo_agent = echo_client.as_agent( + echo_agent = Agent( + client=echo_client, name="EchoAgent", instructions="You are a helpful assistant that echoes back what users say.", ) diff --git a/python/samples/02-agents/compaction/custom.py b/python/samples/02-agents/compaction/custom.py index ea9647b9ae..da6acb066b 100644 --- a/python/samples/02-agents/compaction/custom.py +++ b/python/samples/02-agents/compaction/custom.py @@ -27,15 +27,9 @@ class KeepLastUserTurnStrategy: group_annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY) group_id = group_annotation.get("id") if isinstance(group_annotation, dict) else None kind = group_annotation.get("kind") if isinstance(group_annotation, dict) else None - if ( - isinstance(group_id, str) - and isinstance(kind, str) - and group_id not in group_kinds - ): + if isinstance(group_id, str) and isinstance(kind, str) and group_id not in group_kinds: group_kinds[group_id] = kind - user_group_ids = [ - group_id for group_id in group_ids if group_kinds.get(group_id) == "user" - ] + user_group_ids = [group_id for group_id in group_ids if group_kinds.get(group_id) == "user"] if not user_group_ids: return False keep_user_group_id = user_group_ids[-1] diff --git a/python/samples/02-agents/compaction/tiktoken_tokenizer.py b/python/samples/02-agents/compaction/tiktoken_tokenizer.py index ac282db338..7e2a5a7665 100644 --- a/python/samples/02-agents/compaction/tiktoken_tokenizer.py +++ b/python/samples/02-agents/compaction/tiktoken_tokenizer.py @@ -33,9 +33,7 @@ Key components: class TiktokenTokenizer(TokenizerProtocol): """TokenizerProtocol implementation backed by tiktoken's o200k_base (gpt-4.1 and up default) encoding.""" - def __init__( - self, *, encoding_name: str = "o200k_base", model_name: str | None = None - ) -> None: + def __init__(self, *, encoding_name: str = "o200k_base", model_name: str | None = None) -> None: if model_name is not None: self._encoding = tiktoken.encoding_for_model(model_name) else: @@ -62,10 +60,7 @@ def _build_messages() -> list[Message]: ), Message( role="user", - text=( - "Now provide a detailed checklist with owners, rollback " - "gates, and validation criteria." - ), + text=("Now provide a detailed checklist with owners, rollback gates, and validation criteria."), ), Message( role="assistant", diff --git a/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py b/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py index f7662d1e2f..9c27e5857f 100644 --- a/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py +++ b/python/samples/02-agents/context_providers/azure_ai_foundry_memory.py @@ -4,7 +4,7 @@ import os from datetime import datetime, timezone from agent_framework import Agent, InMemoryHistoryProvider -from agent_framework.azure import AzureOpenAIResponsesClient, FoundryMemoryProvider +from agent_framework.foundry import FoundryChatClient, FoundryMemoryProvider from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( MemoryStoreDefaultDefinition, @@ -31,8 +31,8 @@ so that follow-up responses demonstrate the agent relying solely on Foundry Memo rather than chat history. The memory store is deleted at the end of the run. Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT environment variable -2. Set AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME for the chat/responses model +1. Set FOUNDRY_PROJECT_ENDPOINT environment variable +2. Set FOUNDRY_MODEL for the chat/responses model 3. Set AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME for the embedding model 4. Deploy both a chat model (e.g. gpt-4) and an embedding model (e.g. text-embedding-3-small) """ @@ -40,7 +40,7 @@ load_dotenv() async def main() -> None: - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] async with ( AzureCliCredential() as credential, AIProjectClient(endpoint=endpoint, credential=credential) as project_client, @@ -54,7 +54,7 @@ async def main() -> None: user_profile_details="Avoid irrelevant or sensitive data, such as age, financials, precise location, and credentials", ) memory_store_definition = MemoryStoreDefaultDefinition( - chat_model=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + chat_model=os.environ["FOUNDRY_MODEL"], embedding_model=os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"], options=options, ) @@ -75,7 +75,7 @@ async def main() -> None: print("==========================================") # Create the chat client - client = AzureOpenAIResponsesClient(project_client=project_client) + client = FoundryChatClient(project_client=project_client) # Create the Foundry Memory context provider memory_provider = FoundryMemoryProvider( project_client=project_client, diff --git a/python/samples/02-agents/context_providers/azure_ai_search/README.md b/python/samples/02-agents/context_providers/azure_ai_search/README.md index 49403d106c..6c7f7de711 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/README.md +++ b/python/samples/02-agents/context_providers/azure_ai_search/README.md @@ -8,13 +8,13 @@ This folder contains examples demonstrating how to use the Azure AI Search conte | File | Description | |------|-------------| -| [`azure_ai_with_search_context_agentic.py`](azure_ai_with_search_context_agentic.py) | **Agentic mode** (recommended for most scenarios): Uses Knowledge Bases in Azure AI Search for query planning and multi-hop reasoning. Provides more accurate results through intelligent retrieval with automatic query reformulation. Slightly slower with more token consumption for query planning. [Learn more](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720) | -| [`azure_ai_with_search_context_semantic.py`](azure_ai_with_search_context_semantic.py) | **Semantic mode** (fast queries): Fast hybrid search combining vector and keyword search with semantic ranking. Returns raw search results as context. Best for scenarios where speed is critical and simple retrieval is sufficient. | +| [`search_context_agentic.py`](search_context_agentic.py) | **Agentic mode** (recommended for most scenarios): Uses Knowledge Bases in Azure AI Search for query planning and multi-hop reasoning. Provides more accurate results through intelligent retrieval with automatic query reformulation. Slightly slower with more token consumption for query planning. [Learn more](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720) | +| [`search_context_semantic.py`](search_context_semantic.py) | **Semantic mode** (fast queries): Fast hybrid search combining vector and keyword search with semantic ranking. Returns raw search results as context. Best for scenarios where speed is critical and simple retrieval is sufficient. | ## Installation ```bash -pip install agent-framework-azure-ai-search agent-framework-azure-ai +pip install agent-framework-foundry-search agent-framework-foundry ``` ## Prerequisites diff --git a/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py similarity index 93% rename from python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py rename to python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py index d3c618d8a8..36612d3b8e 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py @@ -4,7 +4,8 @@ import asyncio import os from agent_framework import Agent -from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider +from agent_framework.azure import AzureAISearchContextProvider +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -31,8 +32,8 @@ Prerequisites: Environment variables: - AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint - - AZURE_SEARCH_API_KEY: (Optional) API key - if not provided, uses DefaultAzureCredential - - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint + - AZURE_SEARCH_API_KEY: (Optional) API key - if not provided, uses AzureCliCredential + - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") For using an existing Knowledge Base (recommended): @@ -57,7 +58,7 @@ async def main() -> None: # Get configuration from environment search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] search_key = os.environ.get("AZURE_SEARCH_API_KEY") - project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") # Agentic mode requires exactly ONE of: knowledge_base_name OR index_name @@ -99,7 +100,7 @@ async def main() -> None: credential=AzureCliCredential() if not search_key else None, mode="agentic", azure_openai_resource_url=azure_openai_resource_url, - model_deployment_name=model_deployment, + model_model=model_deployment, # Optional: Configure retrieval behavior knowledge_base_output_mode="extractive_data", # or "answer_synthesis" retrieval_reasoning_effort="minimal", # or "medium", "low" @@ -109,9 +110,9 @@ async def main() -> None: # Create agent with search context provider async with ( search_provider, - AzureAIAgentClient( + FoundryChatClient( project_endpoint=project_endpoint, - model_deployment_name=model_deployment, + model_model=model_deployment, credential=AzureCliCredential(), ) as client, Agent( diff --git a/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py similarity index 90% rename from python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py rename to python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index 2217cefd23..8cd8947ef6 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -4,7 +4,8 @@ import asyncio import os from agent_framework import Agent -from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider, AzureOpenAIEmbeddingClient +from agent_framework.azure import AzureAISearchContextProvider, AzureOpenAIEmbeddingClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -26,9 +27,9 @@ Prerequisites: 2. An Azure AI Foundry project with a model deployment 3. Set the following environment variables: - AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint - - AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses DefaultAzureCredential for Entra ID + - AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses AzureCliCredential for Entra ID - AZURE_SEARCH_INDEX_NAME: Your search index name - - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint + - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") - AZURE_OPENAI_EMBEDDING_MODEL_ID: (Optional) Your embedding model for hybrid search (e.g., "text-embedding-3-small") - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using an OpenAI embedding model for hybrid search @@ -51,7 +52,7 @@ async def main() -> None: search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] search_key = os.environ.get("AZURE_SEARCH_API_KEY") index_name = os.environ["AZURE_SEARCH_INDEX_NAME"] - project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") embedding_model = os.environ.get("AZURE_OPENAI_EMBEDDING_MODEL_ID", "text-embedding-3-small") @@ -60,7 +61,7 @@ async def main() -> None: if openai_endpoint and embedding_model: embedding_client = AzureOpenAIEmbeddingClient( endpoint=openai_endpoint, - deployment_name=embedding_model, + model=embedding_model, credential=credential, ) @@ -83,9 +84,9 @@ async def main() -> None: # Create agent with search context provider async with ( search_provider, - AzureAIAgentClient( + FoundryChatClient( project_endpoint=project_endpoint, - model_deployment_name=model_deployment, + model_model=model_deployment, credential=credential, ) as client, Agent( diff --git a/python/samples/02-agents/context_providers/mem0/mem0_basic.py b/python/samples/02-agents/context_providers/mem0/mem0_basic.py index e5bbf478cf..46b185573b 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_basic.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_basic.py @@ -3,8 +3,8 @@ import asyncio import uuid -from agent_framework import tool -from agent_framework.azure import AzureAIAgentClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.mem0 import Mem0ContextProvider from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -30,19 +30,17 @@ def retrieve_company_report(company_code: str, detailed: bool) -> str: async def main() -> None: """Example of memory usage with Mem0 context provider.""" - print("=== Mem0 Context Provider Example ===") - # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. # In this example, we associate Mem0 records with user_id. user_id = str(uuid.uuid4()) - # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. # For Mem0 authentication, set Mem0 API key via "api_key" parameter or MEM0_API_KEY environment variable. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="FriendlyAssistant", instructions="You are a friendly assistant.", tools=retrieve_company_report, @@ -56,33 +54,23 @@ async def main() -> None: print(f"User: {query}") result = await agent.run(query) print(f"Agent: {result}\n") - # Now tell the agent the company code and the report format that you want to use # and it should be able to invoke the tool and return the report. query = "I always work with CNTS and I always want a detailed report format. Please remember and retrieve it." - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - # Mem0 processes and indexes memories asynchronously. # Wait for memories to be indexed before querying in a new thread. # In production, consider implementing retry logic or using Mem0's # eventual consistency handling instead of a fixed delay. print("Waiting for memories to be processed...") await asyncio.sleep(12) # Empirically determined delay for Mem0 indexing - print("\nRequest within a new session:") # Create a new session for the agent. # The new session has no context of the previous conversation. session = agent.create_session() - # Since we have the mem0 component in the session, the agent should be able to # retrieve the company report without asking for clarification, as it will # be able to remember the user preferences from Mem0 component. - query = "Please retrieve my company report" - print(f"User: {query}") result = await agent.run(query, session=session) - print(f"Agent: {result}\n") if __name__ == "__main__": diff --git a/python/samples/02-agents/context_providers/mem0/mem0_oss.py b/python/samples/02-agents/context_providers/mem0/mem0_oss.py index ca8e907da9..9126ab4bdc 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_oss.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_oss.py @@ -3,8 +3,8 @@ import asyncio import uuid -from agent_framework import tool -from agent_framework.azure import AzureAIAgentClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.mem0 import Mem0ContextProvider from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -31,13 +31,10 @@ def retrieve_company_report(company_code: str, detailed: bool) -> str: async def main() -> None: """Example of memory usage with local Mem0 OSS context provider.""" - print("=== Mem0 Context Provider Example ===") - # Each record in Mem0 should be associated with agent_id or user_id or application_id or thread_id. # In this example, we associate Mem0 records with user_id. user_id = str(uuid.uuid4()) - # For Azure authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. # By default, local Mem0 authenticates to your OpenAI using the OPENAI_API_KEY environment variable. @@ -45,7 +42,8 @@ async def main() -> None: local_mem0_client = AsyncMemory() async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="FriendlyAssistant", instructions="You are a friendly assistant.", tools=retrieve_company_report, @@ -59,27 +57,17 @@ async def main() -> None: print(f"User: {query}") result = await agent.run(query) print(f"Agent: {result}\n") - # Now tell the agent the company code and the report format that you want to use # and it should be able to invoke the tool and return the report. query = "I always work with CNTS and I always want a detailed report format. Please remember and retrieve it." - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - print("\nRequest within a new session:") - # Create a new session for the agent. # The new session has no context of the previous conversation. session = agent.create_session() - # Since we have the mem0 component in the session, the agent should be able to # retrieve the company report without asking for clarification, as it will # be able to remember the user preferences from Mem0 component. - query = "Please retrieve my company report" - print(f"User: {query}") result = await agent.run(query, session=session) - print(f"Agent: {result}\n") if __name__ == "__main__": diff --git a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py index 8bda4a575e..80976fa8f3 100644 --- a/python/samples/02-agents/context_providers/mem0/mem0_sessions.py +++ b/python/samples/02-agents/context_providers/mem0/mem0_sessions.py @@ -3,8 +3,8 @@ import asyncio import uuid -from agent_framework import tool -from agent_framework.azure import AzureAIAgentClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.mem0 import Mem0ContextProvider from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -37,7 +37,8 @@ async def example_global_thread_scope() -> None: async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="GlobalMemoryAssistant", instructions="You are an assistant that remembers user preferences across conversations.", tools=get_user_preferences, @@ -78,7 +79,8 @@ async def example_per_operation_thread_scope() -> None: async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="ScopedMemoryAssistant", instructions="You are an assistant with thread-scoped memory.", tools=get_user_preferences, @@ -129,7 +131,8 @@ async def example_multiple_agents() -> None: async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="PersonalAssistant", instructions="You are a personal assistant that helps with personal tasks.", context_providers=[ @@ -139,7 +142,8 @@ async def example_multiple_agents() -> None: ) ], ) as personal_agent, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="WorkAssistant", instructions="You are a work assistant that helps with professional tasks.", context_providers=[ diff --git a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py index 5adbb53ef3..ee0757d907 100644 --- a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py @@ -17,23 +17,22 @@ Requirements: Environment Variables: - AZURE_REDIS_HOST: Your Azure Managed Redis host (e.g., myredis.redis.cache.windows.net) - - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: Azure OpenAI Responses deployment name + - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint + - FOUNDRY_MODEL: Azure OpenAI Responses deployment name - AZURE_USER_OBJECT_ID: Your Azure AD User Object ID for authentication """ import asyncio import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisHistoryProvider from azure.identity import AzureCliCredential from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential -from dotenv import load_dotenv from redis.credentials import CredentialProvider -# Load environment variables from .env file -load_dotenv() +# Copyright (c) Microsoft. All rights reserved. class AzureCredentialProvider(CredentialProvider): @@ -81,14 +80,15 @@ async def main() -> None: ) # 3. Create chat client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) # 4. Create agent with Azure Redis history provider - agent = client.as_agent( + agent = Agent( + client=client, name="AzureRedisAssistant", instructions="You are a helpful assistant.", context_providers=[history_provider], diff --git a/python/samples/02-agents/context_providers/redis/redis_basics.py b/python/samples/02-agents/context_providers/redis/redis_basics.py index 662ed5e971..efe433e5d4 100644 --- a/python/samples/02-agents/context_providers/redis/redis_basics.py +++ b/python/samples/02-agents/context_providers/redis/redis_basics.py @@ -30,8 +30,8 @@ Run: import asyncio import os -from agent_framework import Message, tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, Message, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisContextProvider from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -99,11 +99,11 @@ def search_flights(origin_airport_code: str, destination_airport_code: str, deta ) -def create_chat_client() -> AzureOpenAIResponsesClient: +def create_chat_client() -> FoundryChatClient: """Create an Azure OpenAI Responses client using a Foundry project endpoint.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) @@ -121,7 +121,7 @@ async def main() -> None: # Create a provider with partition scope and OpenAI embeddings # Please set OPENAI_API_KEY to use the OpenAI vectorizer. - # For chat responses, also set AZURE_AI_PROJECT_ENDPOINT and AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME. + # For chat responses, also set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL. # We attach an embedding vectorizer so the provider can perform hybrid (text + vector) # retrieval. If you prefer text-only retrieval, instantiate RedisContextProvider without the @@ -206,7 +206,8 @@ async def main() -> None: client = create_chat_client() # Create agent wired to the Redis context provider. The provider automatically # persists conversational details and surfaces relevant context on each turn. - agent = client.as_agent( + agent = Agent( + client=client, name="MemoryEnhancedAssistant", instructions=( "You are a helpful assistant. Personalize replies using provided context. " @@ -249,7 +250,8 @@ async def main() -> None: # Create agent exposing the flight search tool. Tool outputs are captured by the # provider and become retrievable context for later turns. client = create_chat_client() - agent = client.as_agent( + agent = Agent( + client=client, name="MemoryEnhancedAssistant", instructions=( "You are a helpful assistant. Personalize replies using provided context. " diff --git a/python/samples/02-agents/context_providers/redis/redis_conversation.py b/python/samples/02-agents/context_providers/redis/redis_conversation.py index 8e8199fc78..ad95b141a1 100644 --- a/python/samples/02-agents/context_providers/redis/redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/redis_conversation.py @@ -21,15 +21,15 @@ Run: import asyncio import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisContextProvider from azure.identity import AzureCliCredential -from dotenv import load_dotenv from redisvl.extensions.cache.embeddings import EmbeddingsCache from redisvl.utils.vectorize import OpenAITextVectorizer -# Load environment variables from .env file -load_dotenv() +# Copyright (c) Microsoft. All rights reserved. + # Default Redis URL for local Redis Stack. # Override via the REDIS_URL environment variable for remote or authenticated instances. @@ -64,14 +64,15 @@ async def main() -> None: ) # Create chat client for the agent - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) # Create agent wired to the Redis context provider. The provider automatically # persists conversational details and surfaces relevant context on each turn. - agent = client.as_agent( + agent = Agent( + client=client, name="MemoryEnhancedAssistant", instructions=( "You are a helpful assistant. Personalize replies using provided context. " diff --git a/python/samples/02-agents/context_providers/redis/redis_sessions.py b/python/samples/02-agents/context_providers/redis/redis_sessions.py index 48cf0e596b..92635a5326 100644 --- a/python/samples/02-agents/context_providers/redis/redis_sessions.py +++ b/python/samples/02-agents/context_providers/redis/redis_sessions.py @@ -29,15 +29,15 @@ Run: import asyncio import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from agent_framework.redis import RedisContextProvider from azure.identity import AzureCliCredential -from dotenv import load_dotenv from redisvl.extensions.cache.embeddings import EmbeddingsCache from redisvl.utils.vectorize import OpenAITextVectorizer -# Load environment variables from .env file -load_dotenv() +# Copyright (c) Microsoft. All rights reserved. + # Default Redis URL for local Redis Stack. # Override via the REDIS_URL environment variable for remote or authenticated instances. @@ -45,12 +45,12 @@ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") # Please set OPENAI_API_KEY to use the OpenAI vectorizer. -# For chat responses, also set AZURE_AI_PROJECT_ENDPOINT and AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME. -def create_chat_client() -> AzureOpenAIResponsesClient: +# For chat responses, also set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL. +def create_chat_client() -> FoundryChatClient: """Create an Azure OpenAI Responses client using a Foundry project endpoint.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) @@ -71,7 +71,8 @@ async def example_global_thread_scope() -> None: user_id="threads_demo_user", ) - agent = client.as_agent( + agent = Agent( + client=client, name="GlobalMemoryAssistant", instructions=( "You are a helpful assistant. Personalize replies using provided context. " @@ -128,7 +129,8 @@ async def example_per_operation_thread_scope() -> None: vector_distance_metric="cosine", ) - agent = client.as_agent( + agent = Agent( + client=client, name="ScopedMemoryAssistant", instructions="You are an assistant with thread-scoped memory.", context_providers=[provider], @@ -191,7 +193,8 @@ async def example_multiple_agents() -> None: vector_distance_metric="cosine", ) - personal_agent = client.as_agent( + personal_agent = Agent( + client=client, name="PersonalAssistant", instructions="You are a personal assistant that helps with personal tasks.", context_providers=[personal_provider], @@ -210,7 +213,8 @@ async def example_multiple_agents() -> None: vector_distance_metric="cosine", ) - work_agent = client.as_agent( + work_agent = Agent( + client=client, name="WorkAssistant", instructions="You are a work assistant that helps with professional tasks.", context_providers=[work_provider], diff --git a/python/samples/02-agents/context_providers/simple_context_provider.py b/python/samples/02-agents/context_providers/simple_context_provider.py index 87bf1a5a16..265a93a73e 100644 --- a/python/samples/02-agents/context_providers/simple_context_provider.py +++ b/python/samples/02-agents/context_providers/simple_context_provider.py @@ -6,7 +6,7 @@ from contextlib import suppress from typing import Any from agent_framework import Agent, AgentSession, BaseContextProvider, SessionContext, SupportsChatGetResponse -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel @@ -89,9 +89,9 @@ class UserInfoMemory(BaseContextProvider): async def main(): - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) diff --git a/python/samples/02-agents/conversations/custom_history_provider.py b/python/samples/02-agents/conversations/custom_history_provider.py index a8fc3974a0..59d63b70c0 100644 --- a/python/samples/02-agents/conversations/custom_history_provider.py +++ b/python/samples/02-agents/conversations/custom_history_provider.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Sequence from typing import Any -from agent_framework import AgentSession, BaseHistoryProvider, Message +from agent_framework import Agent, AgentSession, BaseHistoryProvider, Message from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv @@ -54,7 +54,8 @@ async def main() -> None: # OpenAI Chat Client is used as an example here, # other chat clients can be used as well. - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="CustomBot", instructions="You are a helpful assistant that remembers our conversation.", # Use custom history provider. diff --git a/python/samples/02-agents/conversations/redis_history_provider.py b/python/samples/02-agents/conversations/redis_history_provider.py index 17e1094775..6829382dc8 100644 --- a/python/samples/02-agents/conversations/redis_history_provider.py +++ b/python/samples/02-agents/conversations/redis_history_provider.py @@ -4,7 +4,7 @@ import asyncio import os from uuid import uuid4 -from agent_framework import AgentSession +from agent_framework import Agent, AgentSession from agent_framework.openai import OpenAIChatClient from agent_framework.redis import RedisHistoryProvider from dotenv import load_dotenv @@ -36,7 +36,8 @@ async def example_manual_memory_store() -> None: ) # Create agent with Redis history provider - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="RedisBot", instructions="You are a helpful assistant that remembers our conversation using Redis.", context_providers=[redis_provider], @@ -75,7 +76,8 @@ async def example_user_session_management() -> None: ) # Create agent with history provider - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="SessionBot", instructions="You are a helpful assistant. Keep track of user preferences.", context_providers=[redis_provider], @@ -114,7 +116,8 @@ async def example_conversation_persistence() -> None: redis_url=REDIS_URL, ) - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="PersistentBot", instructions="You are a helpful assistant. Remember our conversation history.", context_providers=[redis_provider], @@ -163,7 +166,8 @@ async def example_session_serialization() -> None: redis_url=REDIS_URL, ) - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="SerializationBot", instructions="You are a helpful assistant.", context_providers=[redis_provider], @@ -206,7 +210,8 @@ async def example_message_limits() -> None: max_messages=3, # Keep only 3 most recent messages ) - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="LimitBot", instructions="You are a helpful assistant with limited memory.", context_providers=[redis_provider], diff --git a/python/samples/02-agents/conversations/suspend_resume_session.py b/python/samples/02-agents/conversations/suspend_resume_session.py index 24c641d06a..a5e9c44248 100644 --- a/python/samples/02-agents/conversations/suspend_resume_session.py +++ b/python/samples/02-agents/conversations/suspend_resume_session.py @@ -2,9 +2,8 @@ import asyncio -from agent_framework import AgentSession -from agent_framework.azure import AzureAIAgentClient -from agent_framework.openai import OpenAIChatClient +from agent_framework import Agent, AgentSession +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -24,11 +23,13 @@ async def suspend_resume_service_managed_session() -> None: """Demonstrates how to suspend and resume a service-managed session.""" print("=== Suspend-Resume Service-Managed Session ===") - # AzureAIAgentClient supports service-managed sessions. + # FoundryChatClient supports service-managed sessions. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( - name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." + Agent( + client=FoundryChatClient(credential=credential), + name="MemoryBot", + instructions="You are a helpful assistant that remembers our conversation.", ) as agent, ): # Start a new session for the agent conversation. @@ -60,8 +61,10 @@ async def suspend_resume_in_memory_session() -> None: # OpenAI Chat Client is used as an example here, # other chat clients can be used as well. - agent = OpenAIChatClient().as_agent( - name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation." + agent = Agent( + client=FoundryChatClient(), + name="MemoryBot", + instructions="You are a helpful assistant that remembers our conversation.", ) # Start a new session for the agent conversation. diff --git a/python/samples/02-agents/declarative/README.md b/python/samples/02-agents/declarative/README.md index 35a75ef36b..045280218e 100644 --- a/python/samples/02-agents/declarative/README.md +++ b/python/samples/02-agents/declarative/README.md @@ -43,7 +43,7 @@ Shows how to create an agent that can search and retrieve information from Micro - Uses Azure CLI credentials for authentication - Leverages MCP to access Microsoft documentation tools -**Requirements**: `pip install agent-framework-azure-ai --pre` +**Requirements**: `pip install agent-framework-foundry --pre` **Key concepts**: Azure AI Foundry integration, MCP server usage, async patterns, resource management @@ -53,7 +53,7 @@ Shows how to create an agent using an inline YAML string rather than a file. - Uses Azure AI Foundry v2 Client with instructions. -**Requirements**: `pip install agent-framework-azure-ai --pre` +**Requirements**: `pip install agent-framework-foundry --pre` **Key concepts**: Inline YAML definition. diff --git a/python/samples/02-agents/declarative/azure_openai_responses_agent.py b/python/samples/02-agents/declarative/azure_openai_responses_agent.py index cda02a4e90..a8988e8311 100644 --- a/python/samples/02-agents/declarative/azure_openai_responses_agent.py +++ b/python/samples/02-agents/declarative/azure_openai_responses_agent.py @@ -15,11 +15,9 @@ async def main(): # get the path current_path = Path(__file__).parent yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "azure" / "AzureOpenAIResponses.yaml" - # load the yaml from the path with yaml_path.open("r") as f: yaml_str = f.read() - # create the agent from the yaml agent = AgentFactory(client_kwargs={"credential": AzureCliCredential()}).create_agent_from_yaml(yaml_str) # use the agent diff --git a/python/samples/02-agents/declarative/get_weather_agent.py b/python/samples/02-agents/declarative/get_weather_agent.py index 75d62bc1a9..30a85de83d 100644 --- a/python/samples/02-agents/declarative/get_weather_agent.py +++ b/python/samples/02-agents/declarative/get_weather_agent.py @@ -4,8 +4,8 @@ from pathlib import Path from random import randint from typing import Literal -from agent_framework.azure import AzureOpenAIResponsesClient from agent_framework.declarative import AgentFactory +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -15,7 +15,6 @@ load_dotenv() def get_weather(location: str, unit: Literal["celsius", "fahrenheit"] = "celsius") -> str: """A simple function tool to get weather information.""" - return f"The weather in {location} is {randint(-10, 30) if unit == 'celsius' else randint(30, 100)} degrees {unit}." @@ -24,14 +23,12 @@ async def main(): # get the path current_path = Path(__file__).parent yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "chatclient" / "GetWeather.yaml" - # load the yaml from the path with yaml_path.open("r") as f: yaml_str = f.read() - # create the AgentFactory with a chat client and bindings agent_factory = AgentFactory( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), bindings={"get_weather": get_weather}, ) # create the agent from the yaml diff --git a/python/samples/02-agents/declarative/inline_yaml.py b/python/samples/02-agents/declarative/inline_yaml.py index 1c7b052e4f..b6d4c9ede4 100644 --- a/python/samples/02-agents/declarative/inline_yaml.py +++ b/python/samples/02-agents/declarative/inline_yaml.py @@ -14,9 +14,9 @@ This sample shows how to create an agent using an inline YAML string rather than It uses a Azure AI Client so it needs the credential to be passed into the AgentFactory. Prerequisites: -- `pip install agent-framework-azure-ai agent-framework-declarative --pre` +- `pip install agent-framework-foundry agent-framework-declarative --pre` - Set the following environment variables in a .env file or your environment: - - AZURE_AI_PROJECT_ENDPOINT + - FOUNDRY_PROJECT_ENDPOINT - AZURE_OPENAI_MODEL """ @@ -33,7 +33,7 @@ model: id: =Env.AZURE_OPENAI_MODEL connection: kind: remote - endpoint: =Env.AZURE_AI_PROJECT_ENDPOINT + endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT """ # create the agent from the yaml async with ( diff --git a/python/samples/02-agents/declarative/mcp_tool_yaml.py b/python/samples/02-agents/declarative/mcp_tool_yaml.py index 366771b903..fd6c233034 100644 --- a/python/samples/02-agents/declarative/mcp_tool_yaml.py +++ b/python/samples/02-agents/declarative/mcp_tool_yaml.py @@ -132,9 +132,9 @@ async def run_azure_ai_example(): print("Example 2: Azure AI with Foundry Connection Reference") print("=" * 60) - from azure.identity import DefaultAzureCredential + from azure.identity import AzureCliCredential - factory = AgentFactory(client_kwargs={"credential": DefaultAzureCredential()}) + factory = AgentFactory(client_kwargs={"credential": AzureCliCredential()}) print("\nCreating agent from YAML definition...") # Use async method for provider-based agent creation diff --git a/python/samples/02-agents/declarative/microsoft_learn_agent.py b/python/samples/02-agents/declarative/microsoft_learn_agent.py index a42806eb92..fc5994da21 100644 --- a/python/samples/02-agents/declarative/microsoft_learn_agent.py +++ b/python/samples/02-agents/declarative/microsoft_learn_agent.py @@ -12,11 +12,9 @@ load_dotenv() async def main(): """Create an agent from a declarative yaml specification and run it.""" - # get the path current_path = Path(__file__).parent yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "foundry" / "MicrosoftLearnAgent.yaml" - # create the agent from the yaml async with ( AzureCliCredential() as credential, diff --git a/python/samples/02-agents/declarative/openai_responses_agent.py b/python/samples/02-agents/declarative/openai_responses_agent.py index cc4c8fb92a..1a78c8ab7a 100644 --- a/python/samples/02-agents/declarative/openai_responses_agent.py +++ b/python/samples/02-agents/declarative/openai_responses_agent.py @@ -11,15 +11,12 @@ load_dotenv() async def main(): """Create an agent from a declarative yaml specification and run it.""" - # get the path current_path = Path(__file__).parent yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "openai" / "OpenAIResponses.yaml" - # load the yaml from the path with yaml_path.open("r") as f: yaml_str = f.read() - # create the agent from the yaml agent = AgentFactory(safe_mode=False).create_agent_from_yaml(yaml_str) # use the agent diff --git a/python/samples/02-agents/devui/azure_responses_agent/agent.py b/python/samples/02-agents/devui/azure_responses_agent/agent.py index 2d952d729a..bb6bda6cf6 100644 --- a/python/samples/02-agents/devui/azure_responses_agent/agent.py +++ b/python/samples/02-agents/devui/azure_responses_agent/agent.py @@ -7,13 +7,13 @@ This agent uses the Responses API which supports: - Audio inputs - And other multimodal content -The Chat Completions API (AzureOpenAIChatClient) does NOT support PDF uploads. +The Chat Completions API (FoundryChatClient) does NOT support PDF uploads. Use this agent when you need to process documents or other file types. Required environment variables: - AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint -- AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: Deployment name for Responses API - (falls back to AZURE_OPENAI_CHAT_DEPLOYMENT_NAME if not set) +- FOUNDRY_MODEL: Deployment name for Responses API + (falls back to FOUNDRY_MODEL if not set) - AZURE_OPENAI_API_KEY: Your API key (or use Azure CLI auth) """ @@ -22,7 +22,7 @@ import os from typing import Annotated from agent_framework import Agent, tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -32,8 +32,8 @@ logger = logging.getLogger(__name__) # Get deployment name - try responses-specific env var first, fall back to chat deployment _deployment_name = os.environ.get( - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", - os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", ""), + "FOUNDRY_MODEL", + os.environ.get("FOUNDRY_MODEL", ""), ) # Get endpoint - try responses-specific env var first, fall back to default @@ -89,8 +89,8 @@ agent = Agent( For PDFs, you can read and understand the text, tables, and structure. For images, you can describe what you see and extract any text. """, - client=AzureOpenAIResponsesClient( - deployment_name=_deployment_name, + client=FoundryChatClient( + model=_deployment_name, endpoint=_endpoint, api_version="2025-03-01-preview", # Required for Responses API ), @@ -117,7 +117,7 @@ def main(): logger.info("") logger.info("Required environment variables:") logger.info(" - AZURE_OPENAI_ENDPOINT") - logger.info(" - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") + logger.info(" - FOUNDRY_MODEL") logger.info(" - AZURE_OPENAI_API_KEY (or use Azure CLI auth)") logger.info("") diff --git a/python/samples/02-agents/devui/foundry_agent/agent.py b/python/samples/02-agents/devui/foundry_agent/agent.py index 62e335646b..1eb7feb9e2 100644 --- a/python/samples/02-agents/devui/foundry_agent/agent.py +++ b/python/samples/02-agents/devui/foundry_agent/agent.py @@ -9,7 +9,7 @@ import os from typing import Annotated from agent_framework import Agent, tool -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -51,9 +51,9 @@ def get_forecast( # Agent instance following Agent Framework conventions agent = Agent( name="FoundryWeatherAgent", - client=AzureAIAgentClient( - project_endpoint=os.environ.get("AZURE_AI_PROJECT_ENDPOINT"), - model_deployment_name=os.environ.get("FOUNDRY_MODEL_DEPLOYMENT_NAME"), + client=FoundryChatClient( + project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), + model_model=os.environ.get("FOUNDRY_MODEL_DEPLOYMENT_NAME"), credential=AzureCliCredential(), ), instructions=""" diff --git a/python/samples/02-agents/devui/in_memory_mode.py b/python/samples/02-agents/devui/in_memory_mode.py index 8914bf8e8e..9aba93b3e6 100644 --- a/python/samples/02-agents/devui/in_memory_mode.py +++ b/python/samples/02-agents/devui/in_memory_mode.py @@ -18,8 +18,8 @@ from agent_framework import ( handler, tool, ) -from agent_framework.azure import AzureOpenAIChatClient from agent_framework.devui import serve +from agent_framework.foundry import FoundryChatClient from dotenv import load_dotenv from typing_extensions import Never @@ -37,9 +37,7 @@ def get_weather( """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] temperature = 53 - return ( - f"The weather in {location} is {conditions[0]} with a high of {temperature}°C." - ) + return f"The weather in {location} is {conditions[0]} with a high of {temperature}°C." @tool(approval_mode="never_require") @@ -68,9 +66,7 @@ class AddExclamation(Executor): """Add exclamation mark to text.""" @handler - async def add_exclamation( - self, text: str, ctx: WorkflowContext[Never, str] - ) -> None: + async def add_exclamation(self, text: str, ctx: WorkflowContext[Never, str]) -> None: """Add exclamation and yield as workflow output.""" result = f"{text}!" await ctx.yield_output(result) @@ -83,9 +79,9 @@ def main(): logger = logging.getLogger(__name__) # Create Azure OpenAI chat client - client = AzureOpenAIChatClient( + client = FoundryChatClient( api_key=os.environ.get("AZURE_OPENAI_API_KEY"), - deployment_name=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + model=os.environ["FOUNDRY_MODEL"], endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"), api_version=os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21"), ) diff --git a/python/samples/02-agents/devui/weather_agent_azure/agent.py b/python/samples/02-agents/devui/weather_agent_azure/agent.py index 527f32a21d..4861d7a4b8 100644 --- a/python/samples/02-agents/devui/weather_agent_azure/agent.py +++ b/python/samples/02-agents/devui/weather_agent_azure/agent.py @@ -20,7 +20,7 @@ from agent_framework import ( function_middleware, tool, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from agent_framework_devui import register_cleanup from dotenv import load_dotenv @@ -152,7 +152,7 @@ agent = Agent( and forecasts for any location. Always be helpful and provide detailed weather information when asked. """, - client=AzureOpenAIChatClient( + client=FoundryChatClient( api_key=os.environ.get("AZURE_OPENAI_API_KEY", ""), ), tools=[get_weather, get_forecast, send_email], diff --git a/python/samples/02-agents/devui/workflow_agents/workflow.py b/python/samples/02-agents/devui/workflow_agents/workflow.py index a8a6293e35..750b8ac45f 100644 --- a/python/samples/02-agents/devui/workflow_agents/workflow.py +++ b/python/samples/02-agents/devui/workflow_agents/workflow.py @@ -17,8 +17,8 @@ Both paths converge at Summarizer for final report. import os from typing import Any -from agent_framework import AgentExecutorResponse, WorkflowBuilder -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent, AgentExecutorResponse, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient from dotenv import load_dotenv from pydantic import BaseModel @@ -63,10 +63,11 @@ def is_approved(message: Any) -> bool: # Create Azure OpenAI chat client -client = AzureOpenAIChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", "")) +client = FoundryChatClient(api_key=os.environ.get("AZURE_OPENAI_API_KEY", "")) # Create Writer agent - generates content -writer = client.as_agent( +writer = Agent( + client=client, name="Writer", instructions=( "You are an excellent content writer. " @@ -76,7 +77,8 @@ writer = client.as_agent( ) # Create Reviewer agent - evaluates and provides structured feedback -reviewer = client.as_agent( +reviewer = Agent( + client=client, name="Reviewer", instructions=( "You are an expert content reviewer. " @@ -94,7 +96,8 @@ reviewer = client.as_agent( ) # Create Editor agent - improves content based on feedback -editor = client.as_agent( +editor = Agent( + client=client, name="Editor", instructions=( "You are a skilled editor. " @@ -105,7 +108,8 @@ editor = client.as_agent( ) # Create Publisher agent - formats content for publication -publisher = client.as_agent( +publisher = Agent( + client=client, name="Publisher", instructions=( "You are a publishing agent. " @@ -115,7 +119,8 @@ publisher = client.as_agent( ) # Create Summarizer agent - creates final publication report -summarizer = client.as_agent( +summarizer = Agent( + client=client, name="Summarizer", instructions=( "You are a summarizer agent. " diff --git a/python/samples/02-agents/embeddings/openai_embeddings.py b/python/samples/02-agents/embeddings/openai_embeddings.py index 62d044fd72..001b6593f5 100644 --- a/python/samples/02-agents/embeddings/openai_embeddings.py +++ b/python/samples/02-agents/embeddings/openai_embeddings.py @@ -21,7 +21,7 @@ Prerequisites: async def main() -> None: """Generate embeddings with OpenAI.""" - client = OpenAIEmbeddingClient(model_id="text-embedding-3-small") + client = OpenAIEmbeddingClient(model="text-embedding-3-small") # 1. Generate a single embedding. result = await client.get_embeddings(["Hello, world!"]) diff --git a/python/samples/02-agents/mcp/agent_as_mcp_server.py b/python/samples/02-agents/mcp/agent_as_mcp_server.py index 2769a95931..97cbcf3f75 100644 --- a/python/samples/02-agents/mcp/agent_as_mcp_server.py +++ b/python/samples/02-agents/mcp/agent_as_mcp_server.py @@ -3,7 +3,7 @@ from typing import Annotated, Any import anyio -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -27,7 +27,7 @@ with the following configuration: ], "env": { "OPENAI_API_KEY": "", - "OPENAI_RESPONSES_MODEL_ID": "", + "OPENAI_MODEL": "", } } } @@ -56,7 +56,8 @@ def get_item_price( async def run() -> None: # Define an agent # Agent's name and description provide better context for AI model - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="RestaurantAgent", description="Answer questions about the menu.", tools=[get_specials, get_item_price], diff --git a/python/samples/02-agents/mcp/mcp_github_pat.py b/python/samples/02-agents/mcp/mcp_github_pat.py index cea266f789..63d70a344d 100644 --- a/python/samples/02-agents/mcp/mcp_github_pat.py +++ b/python/samples/02-agents/mcp/mcp_github_pat.py @@ -21,7 +21,7 @@ Prerequisites: 2. Environment variables: - GITHUB_PAT: Your GitHub Personal Access Token (required) - OPENAI_API_KEY: Your OpenAI API key (required) - - OPENAI_RESPONSES_MODEL_ID: Your OpenAI model ID (required) + - OPENAI_MODEL: Your OpenAI model ID (required) """ 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..14486faaf6 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 @@ -7,13 +7,14 @@ from random import randint from typing import Annotated from agent_framework import ( + Agent, AgentContext, AgentMiddleware, AgentResponse, FunctionInvocationContext, tool, ) -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -51,10 +52,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. """ @@ -194,7 +195,8 @@ async def main() -> None: # authentication option. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=get_weather, diff --git a/python/samples/02-agents/middleware/chat_middleware.py b/python/samples/02-agents/middleware/chat_middleware.py index 48caf9369b..8234e30776 100644 --- a/python/samples/02-agents/middleware/chat_middleware.py +++ b/python/samples/02-agents/middleware/chat_middleware.py @@ -6,6 +6,7 @@ from random import randint from typing import Annotated from agent_framework import ( + Agent, ChatContext, ChatMiddleware, ChatResponse, @@ -14,7 +15,7 @@ from agent_framework import ( chat_middleware, tool, ) -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -150,7 +151,8 @@ async def class_based_chat_middleware() -> None: # authentication option. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="EnhancedChatAgent", instructions="You are a helpful AI assistant.", # Register class-based middleware at agent level (applies to all runs) @@ -172,7 +174,8 @@ async def function_based_chat_middleware() -> None: async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="FunctionMiddlewareAgent", instructions="You are a helpful AI assistant.", # Register function-based middleware at agent level @@ -202,7 +205,8 @@ async def run_level_middleware() -> None: async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="RunLevelAgent", instructions="You are a helpful AI assistant.", tools=get_weather, diff --git a/python/samples/02-agents/middleware/class_based_middleware.py b/python/samples/02-agents/middleware/class_based_middleware.py index bce31315ee..7e5f5e93fc 100644 --- a/python/samples/02-agents/middleware/class_based_middleware.py +++ b/python/samples/02-agents/middleware/class_based_middleware.py @@ -7,6 +7,7 @@ from random import randint from typing import Annotated from agent_framework import ( + Agent, AgentContext, AgentMiddleware, AgentResponse, @@ -15,7 +16,7 @@ from agent_framework import ( Message, tool, ) -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -105,7 +106,8 @@ async def main() -> None: # authentication option. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=get_weather, diff --git a/python/samples/02-agents/middleware/decorator_middleware.py b/python/samples/02-agents/middleware/decorator_middleware.py index e02e47e252..37f264512a 100644 --- a/python/samples/02-agents/middleware/decorator_middleware.py +++ b/python/samples/02-agents/middleware/decorator_middleware.py @@ -4,11 +4,12 @@ import asyncio import datetime from agent_framework import ( + Agent, agent_middleware, function_middleware, tool, ) -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -79,7 +80,8 @@ async def main() -> None: # authentication option. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="TimeAgent", instructions="You are a helpful time assistant. Call get_current_time when asked about time.", tools=get_current_time, diff --git a/python/samples/02-agents/middleware/exception_handling_with_middleware.py b/python/samples/02-agents/middleware/exception_handling_with_middleware.py index 22bd374567..a85b406204 100644 --- a/python/samples/02-agents/middleware/exception_handling_with_middleware.py +++ b/python/samples/02-agents/middleware/exception_handling_with_middleware.py @@ -4,8 +4,8 @@ import asyncio from collections.abc import Awaitable, Callable from typing import Annotated -from agent_framework import FunctionInvocationContext, tool -from agent_framework.azure import AzureAIAgentClient +from agent_framework import Agent, FunctionInvocationContext, tool +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -66,7 +66,8 @@ async def main() -> None: # authentication option. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="DataAgent", instructions="You are a helpful data assistant. Use the data service tool to fetch information for users.", tools=unstable_data_service, diff --git a/python/samples/02-agents/middleware/function_based_middleware.py b/python/samples/02-agents/middleware/function_based_middleware.py index f0ea0f2f26..f2188aff26 100644 --- a/python/samples/02-agents/middleware/function_based_middleware.py +++ b/python/samples/02-agents/middleware/function_based_middleware.py @@ -7,11 +7,12 @@ from random import randint from typing import Annotated from agent_framework import ( + Agent, AgentContext, FunctionInvocationContext, tool, ) -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -92,7 +93,8 @@ async def main() -> None: # authentication option. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=get_weather, diff --git a/python/samples/02-agents/middleware/middleware_termination.py b/python/samples/02-agents/middleware/middleware_termination.py index 89acce1b6f..a5d8e00340 100644 --- a/python/samples/02-agents/middleware/middleware_termination.py +++ b/python/samples/02-agents/middleware/middleware_termination.py @@ -6,6 +6,7 @@ from random import randint from typing import Annotated from agent_framework import ( + Agent, AgentContext, AgentMiddleware, AgentResponse, @@ -13,7 +14,7 @@ from agent_framework import ( MiddlewareTermination, tool, ) -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -118,7 +119,8 @@ async def pre_termination_middleware() -> None: print("\n--- Example 1: Pre-termination MiddlewareTypes ---") async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=get_weather, @@ -145,7 +147,8 @@ async def post_termination_middleware() -> None: print("\n--- Example 2: Post-termination MiddlewareTypes ---") async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=get_weather, diff --git a/python/samples/02-agents/middleware/override_result_with_middleware.py b/python/samples/02-agents/middleware/override_result_with_middleware.py index 5ed4d2a937..14bf42acd0 100644 --- a/python/samples/02-agents/middleware/override_result_with_middleware.py +++ b/python/samples/02-agents/middleware/override_result_with_middleware.py @@ -7,6 +7,7 @@ from random import randint from typing import Annotated from agent_framework import ( + Agent, AgentContext, AgentResponse, AgentResponseUpdate, @@ -188,9 +189,10 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = OpenAIResponsesClient( - middleware=[validate_weather_middleware, weather_override_middleware], - ).as_agent( + agent = Agent( + client=OpenAIResponsesClient( + middleware=[validate_weather_middleware, weather_override_middleware], + ), name="WeatherAgent", instructions="You are a helpful weather assistant. Use the weather tool to get current conditions.", tools=get_weather, diff --git a/python/samples/02-agents/middleware/runtime_context_delegation.py b/python/samples/02-agents/middleware/runtime_context_delegation.py index 7aa19b3437..acc2219d6f 100644 --- a/python/samples/02-agents/middleware/runtime_context_delegation.py +++ b/python/samples/02-agents/middleware/runtime_context_delegation.py @@ -4,8 +4,8 @@ import asyncio from collections.abc import Awaitable, Callable from typing import Annotated -from agent_framework import FunctionInvocationContext, function_middleware, tool -from agent_framework.openai import OpenAIChatClient +from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool +from agent_framework.foundry import FoundryChatClient from dotenv import load_dotenv from pydantic import Field @@ -149,10 +149,11 @@ async def pattern_1_single_agent_with_closure() -> None: print("Use case: Single agent with multiple tools sharing runtime context") print() - client = OpenAIChatClient(model_id="gpt-4o-mini") + client = FoundryChatClient(model="gpt-4o-mini") # Create agent with both tools and shared context via middleware - communication_agent = client.as_agent( + communication_agent = Agent( + client=client, name="communication_agent", instructions=( "You are a communication assistant that can send emails and notifications. " @@ -294,17 +295,19 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: print(f"[SMSAgent] Received runtime context: {list(context.kwargs.keys())}") await call_next() - client = OpenAIChatClient(model_id="gpt-4o-mini") + client = FoundryChatClient(model="gpt-4o-mini") # Create specialized sub-agents - email_agent = client.as_agent( + email_agent = Agent( + client=client, name="email_agent", instructions="You send emails using the send_email_v2 tool.", tools=[send_email_v2], middleware=[email_kwargs_tracker], ) - sms_agent = client.as_agent( + sms_agent = Agent( + client=client, name="sms_agent", instructions="You send SMS messages using the send_sms tool.", tools=[send_sms], @@ -312,7 +315,8 @@ async def pattern_2_hierarchical_with_kwargs_propagation() -> None: ) # Create coordinator that delegates to sub-agents - coordinator = client.as_agent( + coordinator = Agent( + client=client, name="coordinator", instructions=( "You coordinate communication tasks. " @@ -396,10 +400,11 @@ async def pattern_3_hierarchical_with_middleware() -> None: auth_middleware = AuthContextMiddleware() - client = OpenAIChatClient(model_id="gpt-4o-mini") + client = FoundryChatClient(model="gpt-4o-mini") # Sub-agent with validation middleware - protected_agent = client.as_agent( + protected_agent = Agent( + client=client, name="protected_agent", instructions="You perform protected operations that require authentication.", tools=[protected_operation], @@ -407,7 +412,8 @@ async def pattern_3_hierarchical_with_middleware() -> None: ) # Coordinator delegates to protected agent - coordinator = client.as_agent( + coordinator = Agent( + client=client, name="coordinator", instructions="You coordinate protected operations. Delegate to protected_executor.", tools=[ diff --git a/python/samples/02-agents/middleware/session_behavior_middleware.py b/python/samples/02-agents/middleware/session_behavior_middleware.py index 2efe896fae..1dbce2dea3 100644 --- a/python/samples/02-agents/middleware/session_behavior_middleware.py +++ b/python/samples/02-agents/middleware/session_behavior_middleware.py @@ -5,11 +5,12 @@ from collections.abc import Awaitable, Callable from typing import Annotated from agent_framework import ( + Agent, AgentContext, InMemoryHistoryProvider, tool, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -81,7 +82,8 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=get_weather, diff --git a/python/samples/02-agents/middleware/shared_state_middleware.py b/python/samples/02-agents/middleware/shared_state_middleware.py index fbe9f54c92..bcfa91b80b 100644 --- a/python/samples/02-agents/middleware/shared_state_middleware.py +++ b/python/samples/02-agents/middleware/shared_state_middleware.py @@ -6,10 +6,11 @@ from random import randint from typing import Annotated from agent_framework import ( + Agent, FunctionInvocationContext, tool, ) -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -103,7 +104,8 @@ async def main() -> None: # authentication option. async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + Agent( + client=FoundryChatClient(credential=credential), name="UtilityAgent", instructions="You are a helpful assistant that can provide weather information and current time.", tools=[get_weather, get_time], 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..bffa01f7d8 --- /dev/null +++ b/python/samples/02-agents/middleware/usage_tracking_middleware.py @@ -0,0 +1,184 @@ +# 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/multimodal_input/azure_chat_multimodal.py b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py index 5883f184cf..8a141fa9ce 100644 --- a/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_chat_multimodal.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Content, Message -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -21,12 +21,11 @@ def create_sample_image() -> str: async def test_image() -> None: """Test image analysis with Azure OpenAI.""" # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. Requires AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + # authentication option. Requires AZURE_OPENAI_ENDPOINT and FOUNDRY_MODEL # environment variables to be set. # Alternatively, you can pass deployment_name explicitly: - # client = AzureOpenAIChatClient(credential=AzureCliCredential(), deployment_name="your-deployment-name") - client = AzureOpenAIChatClient(credential=AzureCliCredential()) - + # client = FoundryChatClient(credential=AzureCliCredential(), model="your-deployment-name") + client = FoundryChatClient(credential=AzureCliCredential()) image_uri = create_sample_image() message = Message( role="user", @@ -35,7 +34,6 @@ async def test_image() -> None: Content.from_uri(uri=image_uri, media_type="image/png"), ], ) - response = await client.get_response([message]) print(f"Image Response: {response}") diff --git a/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py index 8c7dd76f7e..61f14749e7 100644 --- a/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py +++ b/python/samples/02-agents/multimodal_input/azure_responses_multimodal.py @@ -4,7 +4,7 @@ import asyncio from pathlib import Path from agent_framework import Content, Message -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -30,11 +30,11 @@ def create_sample_image() -> str: async def test_image() -> None: """Test image analysis with Azure OpenAI Responses API.""" # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. Requires AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME + # authentication option. Requires AZURE_OPENAI_ENDPOINT and FOUNDRY_MODEL # environment variables to be set. # Alternatively, you can pass deployment_name explicitly: - # client = AzureOpenAIResponsesClient(credential=AzureCliCredential(), deployment_name="your-deployment-name") - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + # client = FoundryChatClient(credential=AzureCliCredential(), model="your-deployment-name") + client = FoundryChatClient(credential=AzureCliCredential()) image_uri = create_sample_image() message = Message( @@ -51,7 +51,7 @@ async def test_image() -> None: async def test_pdf() -> None: """Test PDF document analysis with Azure OpenAI Responses API.""" - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + client = FoundryChatClient(credential=AzureCliCredential()) pdf_bytes = load_sample_pdf() message = Message( diff --git a/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py index 1e0849d8d6..2879588a41 100644 --- a/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py +++ b/python/samples/02-agents/multimodal_input/openai_chat_multimodal.py @@ -6,7 +6,7 @@ import struct from pathlib import Path from agent_framework import Content, Message -from agent_framework.openai import OpenAIChatClient +from agent_framework.foundry import FoundryChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -46,7 +46,7 @@ def create_sample_audio() -> str: async def test_image() -> None: """Test image analysis with OpenAI.""" - client = OpenAIChatClient(model_id="gpt-4o") + client = FoundryChatClient(model="gpt-4o") image_uri = create_sample_image() message = Message( @@ -63,7 +63,7 @@ async def test_image() -> None: async def test_audio() -> None: """Test audio analysis with OpenAI.""" - client = OpenAIChatClient(model_id="gpt-4o-audio-preview") + client = FoundryChatClient(model="gpt-4o-audio-preview") audio_uri = create_sample_audio() message = Message( @@ -80,7 +80,7 @@ async def test_audio() -> None: async def test_pdf() -> None: """Test PDF document analysis with OpenAI.""" - client = OpenAIChatClient(model_id="gpt-4o") + client = FoundryChatClient(model="gpt-4o") pdf_bytes = load_sample_pdf() message = Message( diff --git a/python/samples/02-agents/observability/README.md b/python/samples/02-agents/observability/README.md index 7d210d434a..33866ffc89 100644 --- a/python/samples/02-agents/observability/README.md +++ b/python/samples/02-agents/observability/README.md @@ -88,18 +88,20 @@ configure_azure_monitor( # This is optional if ENABLE_INSTRUMENTATION and or ENABLE_SENSITIVE_DATA are set in env vars enable_instrumentation(enable_sensitive_data=False) ``` -For Azure AI projects, use the `client.configure_azure_monitor()` method which wraps the calls to `configure_azure_monitor()` and `enable_instrumentation()`: +For Microsoft Foundry projects, use `client.configure_azure_monitor()` which retrieves the connection string from the project and configures everything: ```python -from agent_framework.azure import AzureAIClient -from azure.ai.projects.aio import AIProjectClient +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential -async with ( - AIProjectClient(...) as project_client, - AzureAIClient(project_client=project_client) as client, -): - # Automatically configures Azure Monitor with connection string from project - await client.configure_azure_monitor(enable_live_metrics=True) +client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), +) + +# Automatically configures Azure Monitor with connection string from project +await client.configure_azure_monitor(enable_sensitive_data=True) ``` Or with [Langfuse](https://langfuse.com/integrations/frameworks/microsoft-agent-framework): @@ -227,8 +229,7 @@ This folder contains different samples demonstrating how to use telemetry in var | [configure_otel_providers_with_parameters.py](./configure_otel_providers_with_parameters.py) | **Recommended starting point**: Shows how to create custom exporters with specific configuration and pass them to `configure_otel_providers()`. Useful for advanced scenarios. | | [configure_otel_providers_with_env_var.py](./configure_otel_providers_with_env_var.py) | Shows how to setup telemetry using standard OpenTelemetry environment variables (`OTEL_EXPORTER_OTLP_*`). | | [agent_observability.py](./agent_observability.py) | Shows telemetry collection for an agentic application with tool calls using environment variables. | -| [agent_with_foundry_tracing.py](./agent_with_foundry_tracing.py) | Shows Azure Monitor integration with Foundry for any chat client. | -| [azure_ai_agent_observability.py](./azure_ai_agent_observability.py) | Shows Azure Monitor integration for a AzureAIClient. | +| [foundry_tracing.py](./foundry_tracing.py) | Shows Azure Monitor integration with Foundry for any chat client. | | [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | Advanced: Shows manual setup of exporters and providers with console output. Useful for understanding how observability works under the hood. | | [advanced_zero_code.py](./advanced_zero_code.py) | Advanced: Shows zero-code telemetry setup using the `opentelemetry-enable_instrumentation` CLI tool. | | [workflow_observability.py](./workflow_observability.py) | Shows telemetry collection for a workflow with multiple executors and message passing. | @@ -347,15 +348,16 @@ setup_observability( **After (Current):** ```python -# For Azure AI projects -from agent_framework.azure import AzureAIClient -from azure.ai.projects.aio import AIProjectClient +# For Microsoft Foundry projects +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential -async with ( - AIProjectClient(...) as project_client, - AzureAIClient(project_client=project_client) as client, -): - await client.configure_azure_monitor(enable_live_metrics=True) +client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), +) +await client.configure_azure_monitor(enable_live_metrics=True) # For non-Azure AI projects from azure.monitor.opentelemetry import configure_azure_monitor 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..f326733b5a 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 @@ -6,8 +6,8 @@ from random import randint from typing import Annotated from agent_framework import Message, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.observability import enable_instrumentation -from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from opentelemetry._logs import set_logger_provider from opentelemetry.metrics import set_meter_provider @@ -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: @@ -109,7 +115,7 @@ async def run_chat_client() -> None: 2 spans with gen_ai.operation.name=execute_tool """ - client = OpenAIChatClient() + client = FoundryChatClient() message = "What's the weather in Amsterdam and in Paris?" print(f"User: {message}") print("Assistant: ", end="") diff --git a/python/samples/02-agents/observability/advanced_zero_code.py b/python/samples/02-agents/observability/advanced_zero_code.py index 477a5b4d9b..45696baa7b 100644 --- a/python/samples/02-agents/observability/advanced_zero_code.py +++ b/python/samples/02-agents/observability/advanced_zero_code.py @@ -5,8 +5,8 @@ from random import randint from typing import TYPE_CHECKING, Annotated from agent_framework import Message, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.observability import get_tracer -from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id @@ -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: @@ -101,7 +103,7 @@ async def main() -> None: with get_tracer().start_as_current_span("Zero Code", kind=SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = OpenAIResponsesClient() + client = FoundryChatClient() await run_chat_client(client, stream=True) await run_chat_client(client, stream=False) diff --git a/python/samples/02-agents/observability/agent_observability.py b/python/samples/02-agents/observability/agent_observability.py index ae4057d2f0..695ff9a43f 100644 --- a/python/samples/02-agents/observability/agent_observability.py +++ b/python/samples/02-agents/observability/agent_observability.py @@ -5,8 +5,8 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer -from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id @@ -47,7 +47,7 @@ async def main(): print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") agent = Agent( - client=OpenAIChatClient(), + client=FoundryChatClient(), tools=get_weather, name="WeatherAgent", instructions="You are a weather assistant.", diff --git a/python/samples/02-agents/observability/agent_with_foundry_tracing.py b/python/samples/02-agents/observability/agent_with_foundry_tracing.py deleted file mode 100644 index 00780e8f12..0000000000 --- a/python/samples/02-agents/observability/agent_with_foundry_tracing.py +++ /dev/null @@ -1,107 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "azure-monitor-opentelemetry", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run python/samples/02-agents/observability/agent_with_foundry_tracing.py - -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import logging -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.observability import create_resource, enable_instrumentation, get_tracer -from agent_framework.openai import OpenAIResponsesClient -from azure.ai.projects.aio import AIProjectClient -from azure.identity.aio import AzureCliCredential -from azure.monitor.opentelemetry import configure_azure_monitor -from dotenv import load_dotenv -from opentelemetry.trace import SpanKind -from opentelemetry.trace.span import format_trace_id -from pydantic import Field - -""" -This sample shows you can can setup telemetry in Microsoft Foundry for a custom agent. -First ensure you have a Foundry workspace with Application Insights enabled. -And use the Operate tab to Register an Agent. -Set the OpenTelemetry agent ID to the value used below in the Agent creation: `weather-agent` (or change both). -The sample uses the Azure Monitor OpenTelemetry exporter to send traces to Application Insights. -So ensure you have the `azure-monitor-opentelemetry` package installed. -""" - -# For loading the `AZURE_AI_PROJECT_ENDPOINT` environment variable -load_dotenv() - -logger = logging.getLogger(__name__) - - -# 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") -async def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def main(): - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ): - # This will enable tracing and configure the application to send telemetry data to the - # Application Insights instance attached to the Azure AI project. - # This will override any existing configuration. - try: - conn_string = await project_client.telemetry.get_application_insights_connection_string() - except Exception: - logger.warning( - "No Application Insights connection string found for the Azure AI Project. " - "Please ensure Application Insights is configured in your Azure AI project, " - "or call configure_otel_providers() manually with custom exporters." - ) - return - configure_azure_monitor( - connection_string=conn_string, - enable_live_metrics=True, - resource=create_resource(), - enable_performance_counters=False, - ) - # This call is not necessary if you have the environment variable ENABLE_INSTRUMENTATION=true set - # If not or set to false, or if you want to enable or disable sensitive data collection, call this function. - enable_instrumentation(enable_sensitive_data=True) - print("Observability is set up. Starting Weather Agent...") - - questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] - - with get_tracer().start_as_current_span("Weather Agent Chat", kind=SpanKind.CLIENT) as current_span: - print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - - agent = Agent( - client=OpenAIResponsesClient(), - tools=get_weather, - name="WeatherAgent", - instructions="You are a weather assistant.", - id="weather-agent", - ) - session = agent.create_session() - for question in questions: - print(f"\nUser: {question}") - print(f"{agent.name}: ", end="") - async for update in agent.run(question, session=session, stream=True): - if update.text: - print(update.text, end="") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/observability/azure_ai_agent_observability.py b/python/samples/02-agents/observability/azure_ai_agent_observability.py deleted file mode 100644 index 286dba33ec..0000000000 --- a/python/samples/02-agents/observability/azure_ai_agent_observability.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.azure import AzureAIClient -from agent_framework.observability import get_tracer -from azure.ai.projects.aio import AIProjectClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from opentelemetry.trace import SpanKind -from opentelemetry.trace.span import format_trace_id -from pydantic import Field - -""" -This sample shows you can setup telemetry for an Azure AI agent. -It uses the Azure AI client to setup the telemetry, this calls out to -Azure AI for the connection string of the attached Application Insights -instance. - -You must add an Application Insights instance to your Azure AI project -for this sample to work. -""" - -# For loading the `AZURE_AI_PROJECT_ENDPOINT` environment variable -load_dotenv() - - -# 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") -async def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def main(): - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - AzureAIClient(project_client=project_client) as client, - ): - # This will enable tracing and configure the application to send telemetry data to the - # Application Insights instance attached to the Azure AI project. - # This will override any existing configuration. - await client.configure_azure_monitor(enable_live_metrics=True) - - questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] - - with get_tracer().start_as_current_span("Single Agent Chat", kind=SpanKind.CLIENT) as current_span: - print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - - agent = Agent( - client=client, - tools=get_weather, - name="WeatherAgent", - instructions="You are a weather assistant.", - id="edvan-weather-agent", - ) - session = agent.create_session() - for question in questions: - print(f"\nUser: {question}") - print(f"{agent.name}: ", end="") - async for update in agent.run(question, session=session, stream=True): - if update.text: - print(update.text, end="") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py index 0e20c0a6f5..1400c8d839 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py @@ -7,8 +7,8 @@ from random import randint from typing import TYPE_CHECKING, Annotated, Literal from agent_framework import Message, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer -from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from opentelemetry import trace from opentelemetry.trace.span import format_trace_id @@ -114,7 +114,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = OpenAIResponsesClient() + client = FoundryChatClient() # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. if scenario == "tool" or scenario == "all": diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py index c811fd55ae..6bf7a2fcd6 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py @@ -8,8 +8,8 @@ from random import randint from typing import TYPE_CHECKING, Annotated, Literal from agent_framework import Message, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.observability import configure_otel_providers, get_tracer -from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from opentelemetry import trace from opentelemetry.trace.span import format_trace_id @@ -153,7 +153,7 @@ async def main(scenario: Literal["client", "client_stream", "tool", "all"] = "al with get_tracer().start_as_current_span("Sample Scenarios", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - client = OpenAIResponsesClient() + client = FoundryChatClient() # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. if scenario == "tool" or scenario == "all": diff --git a/python/samples/02-agents/observability/foundry_tracing.py b/python/samples/02-agents/observability/foundry_tracing.py new file mode 100644 index 0000000000..f2a254688f --- /dev/null +++ b/python/samples/02-agents/observability/foundry_tracing.py @@ -0,0 +1,94 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "azure-monitor-opentelemetry", +# ] +# /// +# Run with any PEP 723 compatible runner, e.g.: +# uv run python/samples/02-agents/observability/foundry_tracing.py + +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import logging +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework.observability import get_tracer +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id +from pydantic import Field + +""" +This sample shows how to setup telemetry in Microsoft Foundry for a custom agent +using ``FoundryChatClient.configure_azure_monitor()``. + +First ensure you have a Foundry workspace with Application Insights enabled. +And use the Operate tab to Register an Agent. +Set the OpenTelemetry agent ID to the value used below in the Agent creation: ``weather-agent`` +(or change both). + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4o) +""" + +load_dotenv() + +logger = logging.getLogger(__name__) + + +# NOTE: approval_mode="never_require" is for sample brevity. +@tool(approval_mode="never_require") +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + # configure_azure_monitor() retrieves the Application Insights connection string + # from the project client and sets up tracing automatically. + await client.configure_azure_monitor( + enable_sensitive_data=True, + enable_live_metrics=True, + ) + print("Observability is set up. Starting Weather Agent...") + + questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] + + with get_tracer().start_as_current_span("Weather Agent Chat", kind=SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + + agent = Agent( + client=client, + tools=[get_weather], + name="WeatherAgent", + instructions="You are a weather assistant.", + id="weather-agent", + ) + session = agent.create_session() + for question in questions: + print(f"\nUser: {question}") + print(f"{agent.name}: ", end="") + async for update in agent.run(question, session=session, stream=True): + if update.text: + print(update.text, end="") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/README.md b/python/samples/02-agents/providers/README.md index 20a598b08e..6ab5fa9d76 100644 --- a/python/samples/02-agents/providers/README.md +++ b/python/samples/02-agents/providers/README.md @@ -6,14 +6,12 @@ This directory groups provider-specific samples for Agent Framework. | --- | --- | | [`anthropic/`](anthropic/) | Anthropic Claude samples using both `AnthropicClient` and `ClaudeAgent`, including tools, MCP, sessions, and Foundry Anthropic integration. | | [`amazon/`](amazon/) | AWS Bedrock samples using `BedrockChatClient`, including tool-enabled agent usage. | -| [`azure_ai/`](azure_ai/) | Azure AI Foundry V2 (`azure-ai-projects`) samples with `AzureAIClient`, from basic setup to advanced patterns like search, memory, A2A, MCP, and provider methods. | -| [`azure_ai_agent/`](azure_ai_agent/) | Azure AI Foundry V1 (`azure-ai-agents`) samples with `AzureAIAgentsProvider`, including provider methods and common hosted tool integrations. | -| [`azure_openai/`](azure_openai/) | Azure OpenAI samples for Assistants, Chat, and Responses clients, with examples for sessions, tools, MCP, file search, and code interpreter. | +| [`azure/`](azure/) | Azure OpenAI chat completion samples using `OpenAIChatCompletionClient`, including basic usage, explicit configuration, tools, and sessions. | | [`copilotstudio/`](copilotstudio/) | Microsoft Copilot Studio agent samples, including required environment/app registration setup and explicit authentication patterns. | | [`custom/`](custom/) | Framework extensibility samples for building custom `BaseAgent` and `BaseChatClient` implementations, including layer-composition guidance. | -| [`foundry_local/`](foundry_local/) | Foundry Local samples using `FoundryLocalClient` for local model inference with streaming, non-streaming, and tool-calling patterns. | +| [`foundry/`](foundry/) | Microsoft Foundry and Foundry Local samples using `FoundryChatClient`, `FoundryAgent`, `RawFoundryAgentChatClient`, and `FoundryLocalClient` for hosted agents, Responses API, local inference, tools, MCP, and sessions. | | [`github_copilot/`](github_copilot/) | `GitHubCopilotAgent` samples showing basic usage, session handling, permission-scoped shell/file/url access, and MCP integration. | | [`ollama/`](ollama/) | Local Ollama samples using `OllamaChatClient` (recommended) plus OpenAI-compatible Ollama setup, including reasoning and multimodal examples. | -| [`openai/`](openai/) | OpenAI provider samples for Assistants, Chat, and Responses clients, including tools, structured output, sessions, MCP, web search, and multimodal tasks. | +| [`openai/`](openai/) | OpenAI provider samples for Chat and Chat Completion clients, including tools, structured output, sessions, MCP, web search, and multimodal tasks. | Each folder has its own README with setup requirements and file-by-file details. diff --git a/python/samples/02-agents/providers/anthropic/anthropic_advanced.py b/python/samples/02-agents/providers/anthropic/anthropic_advanced.py index 9fac03a645..b9647f104e 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_advanced.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_advanced.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. - import asyncio +from agent_framework import Agent from agent_framework.anthropic import AnthropicChatOptions, AnthropicClient from dotenv import load_dotenv @@ -31,7 +31,8 @@ async def main() -> None: # Create web search tool configuration using instance method web_search_tool = client.get_web_search_tool() - agent = client.as_agent( + agent = Agent( + client=client, name="DocsAgent", instructions="You are a helpful agent for both Microsoft docs questions and general questions.", tools=[mcp_tool, web_search_tool], diff --git a/python/samples/02-agents/providers/anthropic/anthropic_basic.py b/python/samples/02-agents/providers/anthropic/anthropic_basic.py index 4a97571456..5c62aa82c8 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_basic.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_basic.py @@ -4,7 +4,7 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.anthropic import AnthropicClient from dotenv import load_dotenv @@ -34,7 +34,8 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = AnthropicClient().as_agent( + agent = Agent( + client=AnthropicClient(), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, @@ -50,7 +51,8 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = AnthropicClient().as_agent( + agent = Agent( + client=AnthropicClient(), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, diff --git a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py index 481f3bb6b4..fdbe59d90f 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. - import asyncio +from agent_framework import Agent from agent_framework.anthropic import AnthropicClient from anthropic import AsyncAnthropicFoundry from dotenv import load_dotenv @@ -42,7 +42,8 @@ async def main() -> None: # Create web search tool configuration using instance method web_search_tool = client.get_web_search_tool() - agent = client.as_agent( + agent = Agent( + client=client, name="DocsAgent", instructions="You are a helpful agent for both Microsoft docs questions and general questions.", tools=[mcp_tool, web_search_tool], diff --git a/python/samples/02-agents/providers/anthropic/anthropic_skills.py b/python/samples/02-agents/providers/anthropic/anthropic_skills.py index 9296b1d9a2..a93874c269 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_skills.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_skills.py @@ -4,7 +4,7 @@ import asyncio import logging from pathlib import Path -from agent_framework import Content +from agent_framework import Agent, Content from agent_framework.anthropic import AnthropicChatOptions, AnthropicClient from dotenv import load_dotenv @@ -35,7 +35,8 @@ async def main() -> None: # Create a agent with the pptx skill enabled # Skills also need the code interpreter tool to function - agent = client.as_agent( + agent = Agent( + client=client, name="DocsAgent", instructions="You are a helpful agent for creating powerpoint presentations.", tools=client.get_code_interpreter_tool(), diff --git a/python/samples/02-agents/providers/azure/README.md b/python/samples/02-agents/providers/azure/README.md new file mode 100644 index 0000000000..1e06bda482 --- /dev/null +++ b/python/samples/02-agents/providers/azure/README.md @@ -0,0 +1,12 @@ +# Azure Provider Samples + +This folder contains Azure OpenAI chat completion samples for Agent Framework. + +## Azure OpenAI ChatCompletionClient Samples + +| File | Description | +|------|-------------| +| [`openai_chat_completion_client_azure_basic.py`](openai_chat_completion_client_azure_basic.py) | Azure OpenAI Chat Client Basic Example | +| [`openai_chat_completion_client_azure_with_explicit_settings.py`](openai_chat_completion_client_azure_with_explicit_settings.py) | Azure OpenAI Chat Client with Explicit Settings Example | +| [`openai_chat_completion_client_azure_with_function_tools.py`](openai_chat_completion_client_azure_with_function_tools.py) | Azure OpenAI Chat Client with Function Tools Example | +| [`openai_chat_completion_client_azure_with_session.py`](openai_chat_completion_client_azure_with_session.py) | Azure OpenAI Chat Client with Session Management Example | diff --git a/python/samples/02-agents/providers/azure_openai/azure_chat_client_basic.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py similarity index 86% rename from python/samples/02-agents/providers/azure_openai/azure_chat_client_basic.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py index 6d9a4d8e01..db5740cfc3 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_chat_client_basic.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py @@ -4,8 +4,8 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import tool -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -16,7 +16,7 @@ load_dotenv() """ Azure OpenAI Chat Client Basic Example -This sample demonstrates basic usage of AzureOpenAIChatClient for direct chat-based +This sample demonstrates basic usage of OpenAIChatCompletionClient for direct chat-based interactions, showing both streaming and non-streaming responses. """ @@ -40,7 +40,8 @@ async def non_streaming_example() -> None: # Create agent with Azure Chat Client # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -58,7 +59,8 @@ async def streaming_example() -> None: # Create agent with Azure Chat Client # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_explicit_settings.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py similarity index 85% rename from python/samples/02-agents/providers/azure_openai/azure_chat_client_with_explicit_settings.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py index ea00a80d6d..16ecc8a091 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py @@ -5,8 +5,8 @@ import os from random import randint from typing import Annotated -from agent_framework import tool -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -39,13 +39,15 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = AzureOpenAIChatClient( - deployment_name=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + _client = OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=AzureCliCredential(), - ).as_agent( + ) + agent = Agent( + client=_client, instructions="You are a helpful weather agent.", - tools=get_weather, + tools=[get_weather], ) result = await agent.run("What's the weather like in New York?") diff --git a/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_function_tools.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py similarity index 94% rename from python/samples/02-agents/providers/azure_openai/azure_chat_client_with_function_tools.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py index 7458aea2e6..6d6ec53b3e 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_function_tools.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py @@ -6,7 +6,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -50,7 +50,7 @@ async def tools_on_agent_level() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -82,7 +82,7 @@ async def tools_on_run_level() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", # No tools defined here ) @@ -114,7 +114,7 @@ async def mixed_tools_example() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) diff --git a/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_session.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py similarity index 94% rename from python/samples/02-agents/providers/azure_openai/azure_chat_client_with_session.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py index 0592aba2c7..79dc79c050 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_chat_client_with_session.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py @@ -5,7 +5,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, AgentSession, InMemoryHistoryProvider, tool -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -40,7 +40,7 @@ async def example_with_automatic_session_creation() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -67,7 +67,7 @@ async def example_with_session_persistence() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -102,7 +102,7 @@ async def example_with_existing_session_messages() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -125,7 +125,7 @@ async def example_with_existing_session_messages() -> None: # Create a new agent instance but use the existing session with its message history new_agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/azure_ai/README.md b/python/samples/02-agents/providers/azure_ai/README.md deleted file mode 100644 index 3a73350f24..0000000000 --- a/python/samples/02-agents/providers/azure_ai/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# Azure AI Agent Examples - -This folder contains examples demonstrating different ways to create and use agents with the Azure AI client from the `agent_framework.azure` package. These examples use the `AzureAIClient` with the `azure-ai-projects` 2.x (V2) API surface (see [changelog](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/CHANGELOG.md#200b1-2025-11-11)). For V1 (`azure-ai-agents` 1.x) samples using `AzureAIAgentClient`, see the [Azure AI V1 examples folder](../azure_ai_agent/). When using preview-only agent creation features on GA SDK versions, create `AIProjectClient` with `allow_preview=True`. - -## Examples - -| File | Description | -|------|-------------| -| [`azure_ai_basic.py`](azure_ai_basic.py) | The simplest way to create an agent using `AzureAIProjectAgentProvider`. Demonstrates both streaming and non-streaming responses with function tools. Shows automatic agent creation and basic weather functionality. | -| [`azure_ai_provider_methods.py`](azure_ai_provider_methods.py) | Comprehensive guide to `AzureAIProjectAgentProvider` methods: `create_agent()` for creating new agents, `get_agent()` for retrieving existing agents (by name, reference, or details), and `as_agent()` for wrapping SDK objects without HTTP calls. | -| [`azure_ai_use_latest_version.py`](azure_ai_use_latest_version.py) | Demonstrates how to reuse the latest version of an existing agent instead of creating a new agent version on each instantiation by using `provider.get_agent()` to retrieve the latest version. | -| [`azure_ai_with_agent_as_tool.py`](azure_ai_with_agent_as_tool.py) | Shows how to use the agent-as-tool pattern with Azure AI agents, where one agent delegates work to specialized sub-agents wrapped as tools using `as_tool()`. Demonstrates hierarchical agent architectures. | -| [`azure_ai_with_agent_to_agent.py`](azure_ai_with_agent_to_agent.py) | Shows how to use Agent-to-Agent (A2A) capabilities with Azure AI agents to enable communication with other agents using the A2A protocol. Requires an A2A connection configured in your Azure AI project. | -| [`azure_ai_with_azure_ai_search.py`](azure_ai_with_azure_ai_search.py) | Shows how to use Azure AI Search with Azure AI agents to search through indexed data and answer user questions with proper citations. Requires an Azure AI Search connection and index configured in your Azure AI project. | -| [`azure_ai_with_bing_grounding.py`](azure_ai_with_bing_grounding.py) | Shows how to use Bing Grounding search with Azure AI agents to search the web for current information and provide grounded responses with citations. Requires a Bing connection configured in your Azure AI project. | -| [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to search custom search instances and provide responses with relevant results. Requires a Bing Custom Search connection and instance configured in your Azure AI project. | -| [`azure_ai_with_browser_automation.py`](azure_ai_with_browser_automation.py) | Shows how to use Browser Automation with Azure AI agents to perform automated web browsing tasks and provide responses based on web interactions. Requires a Browser Automation connection configured in your Azure AI project. | -| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use `AzureAIClient.get_code_interpreter_tool()` with Azure AI agents to write and execute Python code for mathematical problem solving and data analysis. | -| [`azure_ai_with_code_interpreter_file_generation.py`](azure_ai_with_code_interpreter_file_generation.py) | Shows how to retrieve file IDs from code interpreter generated files using both streaming and non-streaming approaches. | -| [`azure_ai_with_code_interpreter_file_download.py`](azure_ai_with_code_interpreter_file_download.py) | Shows how to download files generated by code interpreter using the OpenAI containers API. | -| [`azure_ai_with_content_filtering.py`](azure_ai_with_content_filtering.py) | Shows how to enable content filtering (RAI policy) on Azure AI agents using `RaiConfig`. Requires creating an RAI policy in Azure AI Foundry portal first. | -| [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent name and version to the Azure AI client. Demonstrates agent reuse patterns for production scenarios. | -| [`azure_ai_with_existing_conversation.py`](azure_ai_with_existing_conversation.py) | Demonstrates how to use an existing conversation created on the service side with Azure AI agents. Shows two approaches: specifying conversation ID at the client level and using AgentSession with an existing conversation ID. | -| [`azure_ai_with_application_endpoint.py`](azure_ai_with_application_endpoint.py) | Demonstrates calling the Azure AI application-scoped endpoint. | -| [`azure_ai_with_explicit_settings.py`](azure_ai_with_explicit_settings.py) | Shows how to create an agent with explicitly configured `AzureAIClient` settings, including project endpoint, model deployment, and credentials rather than relying on environment variable defaults. | -| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Shows how to use `AzureAIClient.get_file_search_tool()` with Azure AI agents to upload files, create vector stores, and enable agents to search through uploaded documents to answer user questions. | -| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to integrate hosted Model Context Protocol (MCP) tools with Azure AI Agent using `AzureAIClient.get_mcp_tool()`. | -| [`azure_ai_with_local_mcp.py`](azure_ai_with_local_mcp.py) | Shows how to integrate local Model Context Protocol (MCP) tools with Azure AI agents. | -| [`azure_ai_with_response_format.py`](azure_ai_with_response_format.py) | Shows how to use structured outputs (response format) with Azure AI agents using Pydantic models to enforce specific response schemas. | -| [`azure_ai_with_runtime_json_schema.py`](azure_ai_with_runtime_json_schema.py) | Shows how to use structured outputs (response format) with Azure AI agents using a JSON schema to enforce specific response schemas. | -| [`azure_ai_with_search_context_agentic.py`](../../context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py) | Shows how to use AzureAISearchContextProvider with agentic mode. Uses Knowledge Bases for multi-hop reasoning across documents with query planning. Recommended for most scenarios - slightly slower with more token consumption for query planning, but more accurate results. | -| [`azure_ai_with_search_context_semantic.py`](../../context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py) | Shows how to use AzureAISearchContextProvider with semantic mode. Fast hybrid search with vector + keyword search and semantic ranking for RAG. Best for simple queries where speed is critical. | -| [`azure_ai_with_sharepoint.py`](azure_ai_with_sharepoint.py) | Shows how to use SharePoint grounding with Azure AI agents to search through SharePoint content and answer user questions with proper citations. Requires a SharePoint connection configured in your Azure AI project. | -| [`azure_ai_with_session.py`](azure_ai_with_session.py) | Demonstrates session management with Azure AI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`azure_ai_with_image_generation.py`](azure_ai_with_image_generation.py) | Shows how to use `AzureAIClient.get_image_generation_tool()` with Azure AI agents to generate images based on text prompts. | -| [`azure_ai_with_memory_search.py`](azure_ai_with_memory_search.py) | Shows how to use memory search functionality with Azure AI agents for conversation persistence. Demonstrates creating memory stores and enabling agents to search through conversation history. | -| [`azure_ai_with_microsoft_fabric.py`](azure_ai_with_microsoft_fabric.py) | Shows how to use Microsoft Fabric with Azure AI agents to query Fabric data sources and provide responses based on data analysis. Requires a Microsoft Fabric connection configured in your Azure AI project. | -| [`azure_ai_with_openapi.py`](azure_ai_with_openapi.py) | Shows how to integrate OpenAPI specifications with Azure AI agents using dictionary-based tool configuration. Demonstrates using external REST APIs for dynamic data lookup. | -| [`azure_ai_with_reasoning.py`](azure_ai_with_reasoning.py) | Shows how to enable reasoning for a model that supports it. | -| [`azure_ai_with_web_search.py`](azure_ai_with_web_search.py) | Shows how to use `AzureAIClient.get_web_search_tool()` with Azure AI agents to perform web searches and retrieve up-to-date information from the internet. | - -## Environment Variables - -Before running the examples, you need to set up your environment variables. You can do this in one of two ways: - -### Option 1: Using a .env file (Recommended) - -1. Copy the `.env.example` file from the `python` directory to create a `.env` file: - - ```bash - cp ../../../../.env.example ../../../../.env - ``` - -2. Edit the `.env` file and add your values: - - ```env - AZURE_AI_PROJECT_ENDPOINT="your-project-endpoint" - AZURE_AI_MODEL_DEPLOYMENT_NAME="your-model-deployment-name" - ``` - -### Option 2: Using environment variables directly - -Set the environment variables in your shell: - -```bash -export AZURE_AI_PROJECT_ENDPOINT="your-project-endpoint" -export AZURE_AI_MODEL_DEPLOYMENT_NAME="your-model-deployment-name" -``` - -### Required Variables - -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint (required for all examples) -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment (required for all examples) - -## Authentication - -All examples use `AzureCliCredential` for authentication by default. Before running the examples: - -1. Install the Azure CLI -2. Run `az login` to authenticate with your Azure account -3. Ensure you have appropriate permissions to the Azure AI project - -Alternatively, you can replace `AzureCliCredential` with other authentication options like `DefaultAzureCredential` or environment-based credentials. - -## Running the Examples - -Each example can be run independently. Navigate to this directory and run any example: - -```bash -python azure_ai_basic.py -python azure_ai_with_code_interpreter.py -# ... etc -``` - -The examples demonstrate various patterns for working with Azure AI agents, from basic usage to advanced scenarios like session management and structured outputs. diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_basic.py b/python/samples/02-agents/providers/azure_ai/azure_ai_basic.py deleted file mode 100644 index 2f4df77f17..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_basic.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Basic Example - -This sample demonstrates basic usage of AzureAIProjectAgentProvider. -Shows both streaming and non-streaming responses with function tools. -""" - - -# 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." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Tokyo?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - - -async def main() -> None: - print("=== Basic Azure AI Chat Client Agent Example ===") - - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_provider_methods.py b/python/samples/02-agents/providers/azure_ai/azure_ai_provider_methods.py deleted file mode 100644 index 9efa5592c7..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_provider_methods.py +++ /dev/null @@ -1,258 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import PromptAgentDefinition -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Project Agent Provider Methods Example - -This sample demonstrates the three main methods of AzureAIProjectAgentProvider: -1. create_agent() - Create a new agent on the Azure AI service -2. get_agent() - Retrieve an existing agent from the service -3. as_agent() - Wrap an SDK agent version object without making HTTP calls - -It also shows how to use a single provider instance to spawn multiple agents -with different configurations, which is efficient for multi-agent scenarios. - -Each method returns a Agent that can be used for conversations. -""" - - -# 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." - - -async def create_agent_example() -> None: - """Example of using provider.create_agent() to create a new agent. - - This method creates a new agent version on the Azure AI service and returns - a Agent. Use this when you want to create a fresh agent with - specific configuration. - """ - print("=== provider.create_agent() Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a new agent with custom configuration - agent = await provider.create_agent( - name="WeatherAssistant", - instructions="You are a helpful weather assistant. Always be concise.", - description="An agent that provides weather information.", - tools=get_weather, - ) - - print(f"Created agent: {agent.name}") - print(f"Agent ID: {agent.id}") - - query = "What's the weather in Paris?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - -async def get_agent_by_name_example() -> None: - """Example of using provider.get_agent(name=...) to retrieve an agent by name. - - This method fetches the latest version of an existing agent from the service. - Use this when you know the agent name and want to use the most recent version. - """ - print("=== provider.get_agent(name=...) Example ===") - - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ): - # First, create an agent using the SDK directly - created_agent = await project_client.agents.create_version( - agent_name="TestAgentByName", - description="Test agent for get_agent by name example.", - definition=PromptAgentDefinition( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - instructions="You are a helpful assistant. End each response with '- Your Assistant'.", - ), - ) - - try: - # Get the agent using the provider by name (fetches latest version) - provider = AzureAIProjectAgentProvider(project_client=project_client) - agent = await provider.get_agent(name=created_agent.name) - - print(f"Retrieved agent: {agent.name}") - - query = "Hello!" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - finally: - # Clean up the agent - await project_client.agents.delete_version( - agent_name=created_agent.name, agent_version=created_agent.version - ) - - -async def get_agent_by_reference_example() -> None: - """Example of using provider.get_agent(reference=...) to retrieve a specific agent version. - - This method fetches a specific version of an agent using a reference mapping. - Use this when you need to use a particular version of an agent. - """ - print("=== provider.get_agent(reference=...) Example ===") - - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ): - # First, create an agent using the SDK directly - created_agent = await project_client.agents.create_version( - agent_name="TestAgentByReference", - description="Test agent for get_agent by reference example.", - definition=PromptAgentDefinition( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - instructions="You are a helpful assistant. Always respond in uppercase.", - ), - ) - - try: - # Get the agent using a reference mapping with specific version - provider = AzureAIProjectAgentProvider(project_client=project_client) - reference = {"name": created_agent.name, "version": created_agent.version} - agent = await provider.get_agent(reference=reference) - - print(f"Retrieved agent: {agent.name} (version via reference)") - - query = "Say hello" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - finally: - # Clean up the agent - await project_client.agents.delete_version( - agent_name=created_agent.name, agent_version=created_agent.version - ) - - -async def multiple_agents_example() -> None: - """Example of using a single provider to spawn multiple agents. - - A single provider instance can create multiple agents with different - configurations. - """ - print("=== Multiple Agents from Single Provider Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create multiple specialized agents from the same provider - weather_agent = await provider.create_agent( - name="WeatherExpert", - instructions="You are a weather expert. Provide brief weather information.", - tools=get_weather, - ) - - translator_agent = await provider.create_agent( - name="Translator", - instructions="You are a translator. Translate any text to French. Only output the translation.", - ) - - poet_agent = await provider.create_agent( - name="Poet", - instructions="You are a poet. Respond to everything with a short haiku.", - ) - - print(f"Created agents: {weather_agent.name}, {translator_agent.name}, {poet_agent.name}\n") - - # Use each agent for its specialty - weather_query = "What's the weather in London?" - print(f"User to WeatherExpert: {weather_query}") - weather_result = await weather_agent.run(weather_query) - print(f"WeatherExpert: {weather_result}\n") - - translate_query = "Hello, how are you today?" - print(f"User to Translator: {translate_query}") - translate_result = await translator_agent.run(translate_query) - print(f"Translator: {translate_result}\n") - - poet_query = "Tell me about the morning sun" - print(f"User to Poet: {poet_query}") - poet_result = await poet_agent.run(poet_query) - print(f"Poet: {poet_result}\n") - - -async def as_agent_example() -> None: - """Example of using provider.as_agent() to wrap an SDK object without HTTP calls. - - This method wraps an existing AgentVersionDetails into a Agent without - making additional HTTP calls. Use this when you already have the full - AgentVersionDetails from a previous SDK operation. - """ - print("=== provider.as_agent() Example ===") - - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ): - # Create an agent using the SDK directly - this returns AgentVersionDetails - agent_version_details = await project_client.agents.create_version( - agent_name="TestAgentAsAgent", - description="Test agent for as_agent example.", - definition=PromptAgentDefinition( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - instructions="You are a helpful assistant. Keep responses under 20 words.", - ), - ) - - try: - # Wrap the SDK object directly without any HTTP calls - provider = AzureAIProjectAgentProvider(project_client=project_client) - agent = provider.as_agent(agent_version_details) - - print(f"Wrapped agent: {agent.name} (no HTTP call needed)") - print(f"Agent version: {agent_version_details.version}") - - query = "What can you do?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - finally: - # Clean up the agent - await project_client.agents.delete_version( - agent_name=agent_version_details.name, agent_version=agent_version_details.version - ) - - -async def main() -> None: - print("=== Azure AI Project Agent Provider Methods Example ===\n") - - await create_agent_example() - await get_agent_by_name_example() - await get_agent_by_reference_example() - await as_agent_example() - await multiple_agents_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_use_latest_version.py b/python/samples/02-agents/providers/azure_ai/azure_ai_use_latest_version.py deleted file mode 100644 index 2647742d38..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_use_latest_version.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Latest Version Example - -This sample demonstrates how to reuse the latest version of an existing agent -instead of creating a new agent version on each instantiation. The first call creates a new agent, -while subsequent calls with `get_agent()` reuse the latest agent version. -""" - - -# 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." - - -async def main() -> None: - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # First call creates a new agent - agent = await provider.create_agent( - name="MyWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - # Second call retrieves the existing agent (latest version) instead of creating a new one - # This is useful when you want to reuse an agent that was created earlier - agent2 = await provider.get_agent( - name="MyWeatherAgent", - tools=get_weather, # Tools must be provided for function tools - ) - - query = "What's the weather like in Tokyo?" - print(f"User: {query}") - result = await agent2.run(query) - print(f"Agent: {result}\n") - - print(f"First agent ID with version: {agent.id}") - print(f"Second agent ID with version: {agent2.id}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_as_tool.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_as_tool.py deleted file mode 100644 index c217242df7..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_as_tool.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from collections.abc import Awaitable, Callable - -from agent_framework import FunctionInvocationContext -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent-as-Tool Example - -Demonstrates hierarchical agent architectures where one agent delegates -work to specialized sub-agents wrapped as tools using as_tool(). - -This pattern is useful when you want a coordinator agent to orchestrate -multiple specialized agents, each focusing on specific tasks. -""" - - -async def logging_middleware( - context: FunctionInvocationContext, - call_next: Callable[[], Awaitable[None]], -) -> None: - """MiddlewareTypes that logs tool invocations to show the delegation flow.""" - print(f"[Calling tool: {context.function.name}]") - print(f"[Request: {context.arguments}]") - - await call_next() - - print(f"[Response: {context.result}]") - - -async def main() -> None: - print("=== Azure AI Agent-as-Tool Pattern ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a specialized writer agent - writer = await provider.create_agent( - name="WriterAgent", - instructions="You are a creative writer. Write short, engaging content.", - ) - - # Convert writer agent to a tool using as_tool() - writer_tool = writer.as_tool( - name="creative_writer", - description="Generate creative content like taglines, slogans, or short copy", - arg_name="request", - arg_description="What to write", - ) - - # Create coordinator agent with writer as a tool - coordinator = await provider.create_agent( - name="CoordinatorAgent", - instructions="You coordinate with specialized agents. Delegate writing tasks to the creative_writer tool.", - tools=[writer_tool], - middleware=[logging_middleware], - ) - - query = "Create a tagline for a coffee shop" - print(f"User: {query}") - result = await coordinator.run(query) - print(f"Coordinator: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_to_agent.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_to_agent.py deleted file mode 100644 index 881b4a38f1..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_agent_to_agent.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Agent-to-Agent (A2A) Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with Agent-to-Agent (A2A) capabilities -to enable communication with other agents using the A2A protocol. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Ensure you have an A2A connection configured in your Azure AI project - and set A2A_PROJECT_CONNECTION_ID environment variable. -3. (Optional) A2A_ENDPOINT - If the connection is missing target (e.g., "Custom keys" type), - set the A2A endpoint URL directly. -""" - - -async def main() -> None: - # Configure A2A tool with connection ID - a2a_tool = { - "type": "a2a_preview", - "project_connection_id": os.environ["A2A_PROJECT_CONNECTION_ID"], - } - - # If the connection is missing a target, we need to set the A2A endpoint URL - if os.environ.get("A2A_ENDPOINT"): - a2a_tool["base_url"] = os.environ["A2A_ENDPOINT"] - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyA2AAgent", - instructions="""You are a helpful assistant that can communicate with other agents. - Use the A2A tool when you need to interact with other agents to complete tasks - or gather information from specialized agents.""", - tools=a2a_tool, - ) - - query = "What can the secondary agent do?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_application_endpoint.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_application_endpoint.py deleted file mode 100644 index 8183d2850a..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_application_endpoint.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import Agent -from agent_framework.azure import AzureAIClient -from azure.ai.projects.aio import AIProjectClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Application Endpoint Example - -This sample demonstrates working with pre-existing Azure AI Agents by providing -application endpoint instead of project endpoint. -""" - - -async def main() -> None: - # Create the client - async with ( - AzureCliCredential() as credential, - # Endpoint here should be application endpoint with format: - # /api/projects//applications//protocols - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - Agent( - name="ApplicationAgent", - client=AzureAIClient( - project_client=project_client, - ), - ) as agent, - ): - query = "How are you?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_azure_ai_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_azure_ai_search.py deleted file mode 100644 index 3e7ce71096..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_azure_ai_search.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os - -from agent_framework import Annotation -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Azure AI Search Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with Azure AI Search -to search through indexed data and answer user questions about it. - -Citations from Azure AI Search are automatically enriched with document-specific -URLs (get_url) that can be used to retrieve the original documents. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Ensure you have an Azure AI Search connection configured in your Azure AI project - and set AI_SEARCH_PROJECT_CONNECTION_ID and AI_SEARCH_INDEX_NAME environment variable. -""" - - -async def main() -> None: - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MySearchAgent", - instructions=( - "You are a helpful agent that searches hotel information using Azure AI Search. " - "Always use the search tool and index to find hotel data and provide accurate information." - ), - tools={ - "type": "azure_ai_search", - "azure_ai_search": { - "indexes": [ - { - "project_connection_id": os.environ["AI_SEARCH_PROJECT_CONNECTION_ID"], - "index_name": os.environ["AI_SEARCH_INDEX_NAME"], - # For query_type=vector, ensure your index has a field with vectorized data. - "query_type": "simple", - } - ] - }, - }, - ) - - query = ( - "Use Azure AI search knowledge tool to find detailed information about a winter hotel." - " Use the search tool and index." # You can modify prompt to force tool usage - ) - print(f"User: {query}") - - # Non-streaming: get response with enriched citations - result = await agent.run(query) - print(f"Result: {result}\n") - - # Display citations with document-specific URLs - if result.messages: - citations: list[Annotation] = [] - for msg in result.messages: - for content in msg.contents: - if hasattr(content, "annotations") and content.annotations: - citations.extend(content.annotations) - - if citations: - print("Citations:") - for i, citation in enumerate(citations, 1): - url = citation.get("url", "N/A") - # get_url contains the document-specific REST API URL from Azure AI Search - get_url = (citation.get("additional_properties") or {}).get("get_url") - print(f" [{i}] {citation.get('title', 'N/A')}") - print(f" URL: {url}") - if get_url: - print(f" Document URL: {get_url}") - - # Streaming: collect citations from streamed response - print("\n--- Streaming ---") - print(f"User: {query}") - print("Agent: ", end="", flush=True) - streaming_citations: list[Annotation] = [] - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - for content in getattr(chunk, "contents", []): - annotations = getattr(content, "annotations", []) - if annotations: - streaming_citations.extend(annotations) - - print() - if streaming_citations: - print("\nStreaming Citations:") - for i, citation in enumerate(streaming_citations, 1): - url = citation.get("url", "N/A") - get_url = (citation.get("additional_properties") or {}).get("get_url") - print(f" [{i}] {citation.get('title', 'N/A')}") - print(f" URL: {url}") - if get_url: - print(f" Document URL: {get_url}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_custom_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_custom_search.py deleted file mode 100644 index 123ee82431..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_custom_search.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Bing Custom Search Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with Bing Custom Search -to search custom search instances and provide responses with relevant results. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Ensure you have a Bing Custom Search connection configured in your Azure AI project - and set BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID and BING_CUSTOM_SEARCH_INSTANCE_NAME environment variables. -""" - - -async def main() -> None: - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyCustomSearchAgent", - instructions="""You are a helpful agent that can use Bing Custom Search tools to assist users. - Use the available Bing Custom Search tools to answer questions and perform tasks.""", - tools={ - "type": "bing_custom_search_preview", - "bing_custom_search_preview": { - "search_configurations": [ - { - "project_connection_id": os.environ["BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID"], - "instance_name": os.environ["BING_CUSTOM_SEARCH_INSTANCE_NAME"], - } - ] - }, - }, - ) - - query = "Tell me more about foundry agent service" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_grounding.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_grounding.py deleted file mode 100644 index e3a3e8330c..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_bing_grounding.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Bing Grounding Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with Bing Grounding -to search the web for current information and provide grounded responses. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Ensure you have a Bing connection configured in your Azure AI project - and set BING_PROJECT_CONNECTION_ID environment variable. - -To get your Bing connection ID: -- Go to Azure AI Foundry portal (https://ai.azure.com) -- Navigate to your project's "Connected resources" section -- Add a new connection for "Grounding with Bing Search" -- Copy the connection ID and set it as the BING_PROJECT_CONNECTION_ID environment variable -""" - - -async def main() -> None: - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyBingGroundingAgent", - instructions="""You are a helpful assistant that can search the web for current information. - Use the Bing search tool to find up-to-date information and provide accurate, well-sourced answers. - Always cite your sources when possible.""", - tools={ - "type": "bing_grounding", - "bing_grounding": { - "search_configurations": [ - { - "project_connection_id": os.environ["BING_PROJECT_CONNECTION_ID"], - } - ] - }, - }, - ) - - query = "What is today's date and weather in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_browser_automation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_browser_automation.py deleted file mode 100644 index 33cd302485..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_browser_automation.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Browser Automation Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with Browser Automation -to perform automated web browsing tasks and provide responses based on web interactions. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Ensure you have a Browser Automation connection configured in your Azure AI project - and set BROWSER_AUTOMATION_PROJECT_CONNECTION_ID environment variable. -""" - - -async def main() -> None: - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyBrowserAutomationAgent", - instructions="""You are an Agent helping with browser automation tasks. - You can answer questions, provide information, and assist with various tasks - related to web browsing using the Browser Automation tool available to you.""", - tools={ - "type": "browser_automation_preview", - "browser_automation_preview": { - "connection": { - "project_connection_id": os.environ["BROWSER_AUTOMATION_PROJECT_CONNECTION_ID"], - } - }, - }, - ) - - query = """Your goal is to report the percent of Microsoft year-to-date stock price change. - To do that, go to the website finance.yahoo.com. - At the top of the page, you will find a search bar. - Enter the value 'MSFT', to get information about the Microsoft stock price. - At the top of the resulting page you will see a default chart of Microsoft stock price. - Click on 'YTD' at the top of that chart, and report the percent value that shows up just below it.""" - - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter.py deleted file mode 100644 index d196e9dd73..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import ChatResponse -from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from openai.types.responses.response import Response as OpenAIResponse -from openai.types.responses.response_code_interpreter_tool_call import ResponseCodeInterpreterToolCall - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Code Interpreter Example - -This sample demonstrates using get_code_interpreter_tool() with AzureAIProjectAgentProvider -for Python code execution and mathematical problem solving. -""" - - -async def main() -> None: - """Example showing how to use the code interpreter tool with AzureAIProjectAgentProvider.""" - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - code_interpreter_tool = client.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="MyCodeInterpreterAgent", - instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=[code_interpreter_tool], - ) - - query = "Use code to get the factorial of 100?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - if ( - isinstance(result.raw_representation, ChatResponse) - and isinstance(result.raw_representation.raw_representation, OpenAIResponse) - and len(result.raw_representation.raw_representation.output) > 0 - ): - # Find the first ResponseCodeInterpreterToolCall item - code_interpreter_item = next( - ( - item - for item in result.raw_representation.raw_representation.output - if isinstance(item, ResponseCodeInterpreterToolCall) - ), - None, - ) - - if code_interpreter_item is not None: - generated_code = code_interpreter_item.code - print(f"Generated code:\n{generated_code}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_download.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_download.py deleted file mode 100644 index 686c395f74..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_download.py +++ /dev/null @@ -1,236 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import tempfile -from pathlib import Path - -from agent_framework import ( - Agent, - AgentResponseUpdate, - Annotation, - Content, -) -from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI V2 Code Interpreter File Download Sample - -This sample demonstrates how the AzureAIProjectAgentProvider handles file annotations -when code interpreter generates text files. It shows: -1. How to extract file IDs and container IDs from annotations -2. How to download container files using the OpenAI containers API -3. How to save downloaded files locally - -Note: Code interpreter generates files in containers, which require both -file_id and container_id to download via client.containers.files.content.retrieve(). -""" - -QUERY = ( - "Write a simple Python script that creates a text file called 'sample.txt' containing " - "'Hello from the code interpreter!' and save it to disk." -) - - -async def download_container_files(file_contents: list[Annotation | Content], agent: Agent) -> list[Path]: - """Download container files using the OpenAI containers API. - - Code interpreter generates files in containers, which require both file_id - and container_id to download. The container_id is stored in additional_properties. - - This function works for both streaming (Content with type="hosted_file") and non-streaming - (Annotation) responses. - - Args: - file_contents: List of Annotation or Content objects - containing file_id and container_id. - agent: The Agent instance with access to the AzureAIClient. - - Returns: - List of Path objects for successfully downloaded files. - """ - if not file_contents: - return [] - - # Create output directory in system temp folder - temp_dir = Path(tempfile.gettempdir()) - output_dir = temp_dir / "agent_framework_downloads" - output_dir.mkdir(exist_ok=True) - - print(f"\nDownloading {len(file_contents)} container file(s) to {output_dir.absolute()}...") - - # Access the OpenAI client from AzureAIClient - openai_client = agent.client.client # type: ignore[attr-defined] - - downloaded_files: list[Path] = [] - - for content in file_contents: - # Handle both Annotation (TypedDict) and Content objects - if isinstance(content, dict): # Annotation TypedDict - file_id = content.get("file_id") - additional_props = content.get("additional_properties", {}) - url = content.get("url") - else: # Content object - file_id = content.file_id - additional_props = content.additional_properties or {} - url = content.uri - - # Extract container_id from additional_properties - if not additional_props or "container_id" not in additional_props: - print(f" File {file_id}: ✗ Missing container_id") - continue - - container_id = additional_props["container_id"] - - # Extract filename based on content type - if isinstance(content, dict): # Annotation TypedDict - filename = url or f"{file_id}.txt" - # Extract filename from sandbox URL if present (e.g., sandbox:/mnt/data/sample.txt) - if filename.startswith("sandbox:"): - filename = filename.split("/")[-1] - else: # Content - filename = additional_props.get("filename") or f"{file_id}.txt" - - output_path = output_dir / filename - - try: - # Download using containers API - print(f" Downloading {filename}...", end="", flush=True) - file_content = await openai_client.containers.files.content.retrieve( - file_id=file_id, - container_id=container_id, - ) - - # file_content is HttpxBinaryResponseContent, read it - content_bytes = file_content.read() - - # Save to disk - output_path.write_bytes(content_bytes) - file_size = output_path.stat().st_size - print(f"({file_size} bytes)") - - downloaded_files.append(output_path) - - except Exception as e: - print(f"Failed: {e}") - - return downloaded_files - - -async def non_streaming_example() -> None: - """Example of downloading files from non-streaming response using Annotation.""" - print("=== Non-Streaming Response Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - code_interpreter_tool = client.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="V2CodeInterpreterFileAgent", - instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=[code_interpreter_tool], - ) - - print(f"User: {QUERY}\n") - - result = await agent.run(QUERY) - print(f"Agent: {result.text}\n") - - # Check for annotations in the response - annotations_found: list[Annotation] = [] - # AgentResponse has messages property, which contains Message objects - for message in result.messages: - for content in message.contents: - if content.type == "text" and content.annotations: - for annotation in content.annotations: - if annotation.get("file_id"): - annotations_found.append(annotation) - print(f"Found file annotation: file_id={annotation['file_id']}") - additional_props = annotation.get("additional_properties", {}) - if additional_props and "container_id" in additional_props: - print(f" container_id={additional_props['container_id']}") - - if annotations_found: - print(f"SUCCESS: Found {len(annotations_found)} file annotation(s)") - - # Download the container files (cast to Sequence for type compatibility) - downloaded_paths = await download_container_files(list(annotations_found), agent) - - if downloaded_paths: - print("\nDownloaded files available at:") - for path in downloaded_paths: - print(f" - {path.absolute()}") - else: - print("WARNING: No file annotations found in non-streaming response") - - -async def streaming_example() -> None: - """Example of downloading files from streaming response using Content with type='hosted_file'.""" - print("\n=== Streaming Response Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - code_interpreter_tool = client.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="V2CodeInterpreterFileAgentStreaming", - instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=[code_interpreter_tool], - ) - - print(f"User: {QUERY}\n") - file_contents_found: list[Content] = [] - text_chunks: list[str] = [] - - async for update in agent.run(QUERY, stream=True): - if isinstance(update, AgentResponseUpdate): - for content in update.contents: - if content.type == "text": - if content.text: - text_chunks.append(content.text) - if content.annotations: - for annotation in content.annotations: - if annotation.get("file_id"): - print(f"Found streaming annotation: file_id={annotation['file_id']}") - elif content.type == "hosted_file": - file_contents_found.append(content) - print(f"Found streaming hosted_file: file_id={content.file_id}") - if content.additional_properties and "container_id" in content.additional_properties: - print(f" container_id={content.additional_properties['container_id']}") - - print(f"\nAgent response: {''.join(text_chunks)[:200]}...") - - if file_contents_found: - print(f"SUCCESS: Found {len(file_contents_found)} file reference(s) in streaming") - - # Download the container files - downloaded_paths = await download_container_files(file_contents_found, agent) - - if downloaded_paths: - print("\n✓ Downloaded files available at:") - for path in downloaded_paths: - print(f" - {path.absolute()}") - else: - print("WARNING: No file annotations found in streaming response") - - -async def main() -> None: - print("AzureAIClient Code Interpreter File Download Sample\n") - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_generation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_generation.py deleted file mode 100644 index 38e3e7eada..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_code_interpreter_file_generation.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import ( - AgentResponseUpdate, -) -from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI V2 Code Interpreter File Generation Sample - -This sample demonstrates how the AzureAIProjectAgentProvider handles file annotations -when code interpreter generates text files. It shows both non-streaming -and streaming approaches to verify file ID extraction. -""" - -QUERY = ( - "Write a simple Python script that creates a text file called 'sample.txt' containing " - "'Hello from the code interpreter!' and save it to disk." -) - - -async def non_streaming_example() -> None: - """Example of extracting file annotations from non-streaming response.""" - print("=== Non-Streaming Response Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - code_interpreter_tool = client.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="CodeInterpreterFileAgent", - instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=[code_interpreter_tool], - ) - - print(f"User: {QUERY}\n") - - result = await agent.run(QUERY) - print(f"Agent: {result.text}\n") - - # Check for annotations in the response - annotations_found: list[str] = [] - # AgentResponse has messages property, which contains Message objects - for message in result.messages: - for content in message.contents: - if content.type == "text" and content.annotations: - for annotation in content.annotations: - if annotation.get("file_id"): - annotations_found.append(annotation["file_id"]) - print(f"Found file annotation: file_id={annotation['file_id']}") - - if annotations_found: - print(f"SUCCESS: Found {len(annotations_found)} file annotation(s)") - else: - print("WARNING: No file annotations found in non-streaming response") - - -async def streaming_example() -> None: - """Example of extracting file annotations from streaming response.""" - print("\n=== Streaming Response Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - code_interpreter_tool = client.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="V2CodeInterpreterFileAgentStreaming", - instructions="You are a helpful assistant that can write and execute Python code to create files.", - tools=[code_interpreter_tool], - ) - - print(f"User: {QUERY}\n") - annotations_found: list[str] = [] - text_chunks: list[str] = [] - file_ids_found: list[str] = [] - - async for update in agent.run(QUERY, stream=True): - if isinstance(update, AgentResponseUpdate): - for content in update.contents: - if content.type == "text": - if content.text: - text_chunks.append(content.text) - if content.annotations: - for annotation in content.annotations: - if annotation.get("file_id"): - annotations_found.append(annotation["file_id"]) - print(f"Found streaming annotation: file_id={annotation['file_id']}") - elif content.type == "hosted_file": - file_ids_found.append(content.file_id) - print(f"Found streaming HostedFileContent: file_id={content.file_id}") - - print(f"\nAgent response: {''.join(text_chunks)[:200]}...") - - if annotations_found or file_ids_found: - total = len(annotations_found) + len(file_ids_found) - print(f"SUCCESS: Found {total} file reference(s) in streaming") - else: - print("WARNING: No file annotations found in streaming response") - - -async def main() -> None: - print("AzureAIClient Code Interpreter File Generation Sample\n") - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_content_filtering.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_content_filtering.py deleted file mode 100644 index 3af4ef4854..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_content_filtering.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.ai.projects.models import RaiConfig -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Content Filtering (RAI Policy) Example - -This sample demonstrates how to enable content filtering on Azure AI agents using RaiConfig. - -Prerequisites: -1. Create an RAI Policy in Azure AI Foundry portal: - - Go to Azure AI Foundry > Your Project > Guardrails + Controls > Content Filters - - Create a new content filter or use an existing one - - Note the policy name - -2. Set environment variables: - - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name - -3. Run `az login` to authenticate -""" - - -async def main() -> None: - print("=== Azure AI Agent with Content Filtering ===\n") - - # Replace with your RAI policy from Azure AI Foundry portal - rai_policy_name = ( - "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/" - "Microsoft.CognitiveServices/accounts/{accountName}/raiPolicies/{policyName}" - ) - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create agent with content filtering enabled via default_options - agent = await provider.create_agent( - name="ContentFilteredAgent", - instructions="You are a helpful assistant.", - default_options={"rai_config": RaiConfig(rai_policy_name=rai_policy_name)}, - ) - - # Test with a normal query - query = "What is the capital of France?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - # Test with a query that might trigger content filtering - # (depending on your RAI policy configuration) - query2 = "Tell me something inappropriate." - print(f"User: {query2}") - try: - result2 = await agent.run(query2) - print(f"Agent: {result2}\n") - except Exception as e: - print(f"Content filter triggered: {e}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_agent.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_agent.py deleted file mode 100644 index 84c6cc54de..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_agent.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import PromptAgentDefinition -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Existing Agent Example - -This sample demonstrates working with pre-existing Azure AI Agents by using provider.get_agent() method, -showing agent reuse patterns for production scenarios. -""" - - -async def using_provider_get_agent() -> None: - print("=== Get existing Azure AI agent with provider.get_agent() ===") - - # Create the client - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ): - # Create remote agent using SDK directly - azure_ai_agent = await project_client.agents.create_version( - agent_name="MyNewTestAgent", - description="Agent for testing purposes.", - definition=PromptAgentDefinition( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - # Setting specific requirements to verify that this agent is used. - instructions="End each response with [END].", - ), - ) - - try: - # Get newly created agent as Agent by using provider.get_agent() - provider = AzureAIProjectAgentProvider(project_client=project_client) - agent = await provider.get_agent(name=azure_ai_agent.name) - - # Verify agent properties - print(f"Agent ID: {agent.id}") - print(f"Agent name: {agent.name}") - print(f"Agent description: {agent.description}") - - query = "How are you?" - print(f"User: {query}") - result = await agent.run(query) - # Response that indicates that previously created agent was used: - # "I'm here and ready to help you! How can I assist you today? [END]" - print(f"Agent: {result}\n") - finally: - # Clean up the agent manually - await project_client.agents.delete_version( - agent_name=azure_ai_agent.name, agent_version=azure_ai_agent.version - ) - - -async def main() -> None: - await using_provider_get_agent() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_conversation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_conversation.py deleted file mode 100644 index d19997b98a..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_existing_conversation.py +++ /dev/null @@ -1,108 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.ai.projects.aio import AIProjectClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Existing Conversation Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with existing conversation created on service side. -""" - - -# 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." - - -async def example_with_conversation_id() -> None: - """Example shows how to use existing conversation ID with the provider.""" - print("=== Azure AI Agent With Existing Conversation ===") - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ): - # Create a conversation using OpenAI client - openai_client = project_client.get_openai_client() - conversation = await openai_client.conversations.create() - conversation_id = conversation.id - print(f"Conversation ID: {conversation_id}") - - provider = AzureAIProjectAgentProvider(project_client=project_client) - agent = await provider.create_agent( - name="BasicAgent", - instructions="You are a helpful agent.", - tools=get_weather, - ) - - # Pass conversation_id at run level - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query, conversation_id=conversation_id) - print(f"Agent: {result.text}\n") - - query = "What was my last question?" - print(f"User: {query}") - result = await agent.run(query, conversation_id=conversation_id) - print(f"Agent: {result.text}\n") - - -async def example_with_session() -> None: - """This example shows how to specify existing conversation ID with AgentSession.""" - print("=== Azure AI Agent With Existing Conversation and Session ===") - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - ): - provider = AzureAIProjectAgentProvider(project_client=project_client) - agent = await provider.create_agent( - name="BasicAgent", - instructions="You are a helpful agent.", - tools=get_weather, - ) - - # Create a conversation using OpenAI client - openai_client = project_client.get_openai_client() - conversation = await openai_client.conversations.create() - conversation_id = conversation.id - print(f"Conversation ID: {conversation_id}") - - # Create a session with the existing ID - session = agent.create_session(service_session_id=conversation_id) - - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query, session=session) - print(f"Agent: {result.text}\n") - - query = "What was my last question?" - print(f"User: {query}") - result = await agent.run(query, session=session) - print(f"Agent: {result.text}\n") - - -async def main() -> None: - await example_with_conversation_id() - await example_with_session() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_explicit_settings.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_explicit_settings.py deleted file mode 100644 index 9ee83478a3..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_explicit_settings.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Explicit Settings Example - -This sample demonstrates creating Azure AI Agents with explicit configuration -settings rather than relying on environment variable defaults. -""" - - -# 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." - - -async def main() -> None: - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=credential, - ) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in New York?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_file_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_file_search.py deleted file mode 100644 index c15edd95dd..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_file_search.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import contextlib -import os -from pathlib import Path - -from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider -from azure.ai.projects.aio import AIProjectClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -The following sample demonstrates how to create a simple, Azure AI agent that -uses a file search tool to answer user questions. -""" - - -# Simulate a conversation with the agent -USER_INPUTS = [ - "Who is the youngest employee?", - "Who works in sales?", - "I have a customer request, who can help me?", -] - - -async def main() -> None: - """Main function demonstrating Azure AI agent with file search capabilities.""" - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - AzureAIProjectAgentProvider(project_client=project_client) as provider, - ): - openai_client = project_client.get_openai_client() - - try: - # 1. Upload file and create vector store via OpenAI client - pdf_file_path = Path(__file__).parents[3] / "shared" / "resources" / "employees.pdf" - print(f"Uploading file from: {pdf_file_path}") - - vector_store = await openai_client.vector_stores.create(name="my_vectorstore") - print(f"Created vector store, vector store ID: {vector_store.id}") - - with open(pdf_file_path, "rb") as f: - file = await openai_client.vector_stores.files.upload_and_poll( - vector_store_id=vector_store.id, - file=f, - ) - print(f"Uploaded file, file ID: {file.id}") - - # 2. Create a file search tool - client = AzureAIClient(project_client=project_client) - file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id]) - - # 3. Create an agent with file search capabilities using the provider - agent = await provider.create_agent( - name="EmployeeSearchAgent", - instructions=( - "You are a helpful assistant that can search through uploaded employee files " - "to answer questions about employees." - ), - tools=[file_search_tool], - ) - - # 4. Simulate conversation with the agent - for user_input in USER_INPUTS: - print(f"# User: '{user_input}'") - response = await agent.run(user_input) - print(f"# Agent: {response.text}") - finally: - # 5. Cleanup: Delete the vector store (also deletes associated files) - with contextlib.suppress(Exception): - await openai_client.vector_stores.delete(vector_store.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_hosted_mcp.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_hosted_mcp.py deleted file mode 100644 index d892834afc..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_hosted_mcp.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from typing import Any - -from agent_framework import AgentResponse, AgentSession, Message, SupportsAgentRun -from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Hosted MCP Example - -This sample demonstrates integrating hosted Model Context Protocol (MCP) tools with Azure AI Agent. -""" - - -async def handle_approvals_without_session(query: str, agent: "SupportsAgentRun") -> AgentResponse: - """When we don't have a session, we need to ensure we return with the input, approval request and approval.""" - - result = await agent.run(query, store=False) - while len(result.user_input_requests) > 0: - new_inputs: list[Any] = [query] - for user_input_needed in result.user_input_requests: - print( - f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}" - f" with arguments: {user_input_needed.function_call.arguments}" - ) - new_inputs.append(Message("assistant", [user_input_needed])) - user_approval = input("Approve function call? (y/n): ") - new_inputs.append( - Message("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")]) - ) - - result = await agent.run(new_inputs, store=False) - return result - - -async def handle_approvals_with_session( - query: str, agent: "SupportsAgentRun", session: "AgentSession" -) -> AgentResponse: - """Here we let the session deal with the previous responses, and we just rerun with the approval.""" - - result = await agent.run(query, session=session) - while len(result.user_input_requests) > 0: - new_input: list[Any] = [] - for user_input_needed in result.user_input_requests: - print( - f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}" - f" with arguments: {user_input_needed.function_call.arguments}" - ) - user_approval = input("Approve function call? (y/n): ") - new_input.append( - Message( - role="user", - contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], - ) - ) - result = await agent.run(new_input, session=session) - return result - - -async def run_hosted_mcp_without_approval() -> None: - """Example showing MCP Tools without approval.""" - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - # Create MCP tool using instance method - mcp_tool = client.get_mcp_tool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - approval_mode="never_require", - ) - - agent = await provider.create_agent( - name="MyLearnDocsAgent", - instructions="You are a helpful assistant that can help with Microsoft documentation questions.", - tools=[mcp_tool], - ) - - query = "How to create an Azure storage account using az cli?" - print(f"User: {query}") - result = await handle_approvals_without_session(query, agent) - print(f"{agent.name}: {result}\n") - - -async def run_hosted_mcp_with_approval_and_session() -> None: - """Example showing MCP Tools with approvals using a session.""" - print("=== MCP with approvals and with session ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - # Create MCP tool using instance method - mcp_tool = client.get_mcp_tool( - name="api-specs", - url="https://gitmcp.io/Azure/azure-rest-api-specs", - approval_mode="always_require", - ) - - agent = await provider.create_agent( - name="MyApiSpecsAgent", - instructions="You are a helpful agent that can use MCP tools to assist users.", - tools=[mcp_tool], - ) - - session = agent.create_session() - query = "Please summarize the Azure REST API specifications Readme" - print(f"User: {query}") - result = await handle_approvals_with_session(query, agent, session) - print(f"{agent.name}: {result}\n") - - -async def main() -> None: - print("=== Azure AI Agent with Hosted MCP Tools Example ===\n") - - await run_hosted_mcp_without_approval() - await run_hosted_mcp_with_approval_and_session() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_image_generation.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_image_generation.py deleted file mode 100644 index e9d783d080..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_image_generation.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import base64 -import tempfile -from pathlib import Path -from urllib import request as urllib_request - -from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Image Generation Example - -This sample demonstrates basic usage of AzureAIProjectAgentProvider to create an agent -that can generate images based on user requirements. - -Pre-requisites: -- Make sure to set up the AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME - environment variables before running this sample. -""" - - -async def main() -> None: - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - # Create image generation tool using instance method - image_gen_tool = client.get_image_generation_tool( - model="gpt-image-1", - size="1024x1024", - output_format="png", - quality="low", - background="opaque", - ) - - agent = await provider.create_agent( - name="ImageGenAgent", - instructions="Generate images based on user requirements.", - tools=[image_gen_tool], - ) - - query = "Generate an image of Microsoft logo." - print(f"User: {query}") - result = await agent.run( - query, - # These additional options are required for image generation - options={ - "extra_headers": {"x-ms-oai-image-generation-deployment": "gpt-image-1-mini"}, - }, - ) - print(f"Agent: {result}\n") - - # Save the image to a file - print("Downloading generated image...") - image_data = [ - content.outputs - for content in result.messages[0].contents - if content.type == "image_generation_tool_result" and content.outputs is not None - ] - if image_data and image_data[0]: - # Save to the OS temporary directory - filename = "microsoft.png" - file_path = Path(tempfile.gettempdir()) / filename - # outputs can be a list of Content items (data/uri) or a single item - out = image_data[0][0] if isinstance(image_data[0], list) else image_data[0] - data_bytes: bytes | None = None - uri = getattr(out, "uri", None) - if isinstance(uri, str): - if ";base64," in uri: - try: - b64 = uri.split(";base64,", 1)[1] - data_bytes = base64.b64decode(b64) - except Exception: - data_bytes = None - else: - try: - data_bytes = await asyncio.to_thread(lambda: urllib_request.urlopen(uri).read()) - except Exception: - data_bytes = None - - if data_bytes is None: - raise RuntimeError("Image output present but could not retrieve bytes.") - - with open(file_path, "wb") as f: - f.write(data_bytes) - - print(f"Image downloaded and saved to: {file_path}") - else: - print("No image data found in the agent response.") - - """ - Sample output: - User: Generate an image of Microsoft logo. - Agent: Here is the Microsoft logo image featuring its iconic four quadrants. - - Downloading generated image... - Image downloaded and saved to: .../microsoft.png - """ - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_local_mcp.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_local_mcp.py deleted file mode 100644 index 77dd0cdad3..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_local_mcp.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import MCPStreamableHTTPTool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Local MCP Example - -This sample demonstrates integration of Azure AI Agents with local Model Context Protocol (MCP) -servers. - -Pre-requisites: -- Make sure to set up the AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME - environment variables before running this sample. -""" - - -async def main() -> None: - """Example showing use of Local MCP Tool with AzureAIProjectAgentProvider.""" - print("=== Azure AI Agent with Local MCP Tools Example ===\n") - - mcp_tool = MCPStreamableHTTPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ) - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="DocsAgent", - instructions="You are a helpful assistant that can help with Microsoft documentation questions.", - tools=mcp_tool, - ) - - # Use agent as context manager to ensure proper cleanup - async with agent: - # First query - first_query = "How to create an Azure storage account using az cli?" - print(f"User: {first_query}") - first_result = await agent.run(first_query) - print(f"Agent: {first_result}") - print("\n=======================================\n") - # Second query - second_query = "What is Microsoft Agent Framework?" - print(f"User: {second_query}") - second_result = await agent.run(second_query) - print(f"Agent: {second_result}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_memory_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_memory_search.py deleted file mode 100644 index 9377a78214..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_memory_search.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os -import uuid - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import MemoryStoreDefaultDefinition, MemoryStoreDefaultOptions -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Memory Search Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with memory search capabilities -to retrieve relevant past user messages and maintain conversation context across sessions. -It shows explicit memory store creation using Azure AI Projects client and agent creation -using the Agent Framework. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Set AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME for the memory chat model. -3. Set AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME for the memory embedding model. -4. Deploy both a chat model (e.g. gpt-4.1) and an embedding model (e.g. text-embedding-3-small). -""" - - -async def main() -> None: - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - # Generate a unique memory store name to avoid conflicts - memory_store_name = f"agent_framework_memory_store_{uuid.uuid4().hex[:8]}" - - async with AzureCliCredential() as credential: - # Create the memory store using Azure AI Projects client - async with AIProjectClient(endpoint=endpoint, credential=credential) as project_client: - # Create a memory store using proper model classes - memory_store_definition = MemoryStoreDefaultDefinition( - chat_model=os.environ["AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME"], - embedding_model=os.environ["AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME"], - options=MemoryStoreDefaultOptions(user_profile_enabled=True, chat_summary_enabled=True), - ) - - memory_store = await project_client.beta.memory_stores.create( - name=memory_store_name, - description="Memory store for Agent Framework conversations", - definition=memory_store_definition, - ) - print(f"Created memory store: {memory_store.name} ({memory_store.id}): {memory_store.description}") - - # Then, create the agent using Agent Framework provider - async with AzureAIProjectAgentProvider(credential=credential) as provider: - agent = await provider.create_agent( - name="MyMemoryAgent", - instructions="""You are a helpful assistant that remembers past conversations. - Use the memory search tool to recall relevant information from previous interactions.""", - tools={ - "type": "memory_search_preview", - "memory_store_name": memory_store.name, - "scope": "user_123", - "update_delay": 1, # Wait 1 second before updating memories (use higher value in production) - }, - ) - - # First interaction - establish some preferences - print("=== First conversation ===") - query1 = "I prefer dark roast coffee" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Wait for memories to be processed - print("Waiting for memories to be stored...") - await asyncio.sleep(5) # Reduced wait time for demo purposes - - # Second interaction - test memory recall - print("=== Second conversation ===") - query2 = "Please order my usual coffee" - print(f"User: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2}\n") - - # Clean up - delete the memory store - async with AIProjectClient(endpoint=endpoint, credential=credential) as project_client: - await project_client.beta.memory_stores.delete(memory_store_name) - print("Memory store deleted") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_microsoft_fabric.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_microsoft_fabric.py deleted file mode 100644 index 531a18cb69..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_microsoft_fabric.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Microsoft Fabric Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with Microsoft Fabric -to query Fabric data sources and provide responses based on data analysis. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Ensure you have a Microsoft Fabric connection configured in your Azure AI project - and set FABRIC_PROJECT_CONNECTION_ID environment variable. -""" - - -async def main() -> None: - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyFabricAgent", - instructions="You are a helpful assistant.", - tools={ - "type": "fabric_dataagent_preview", - "fabric_dataagent_preview": { - "project_connections": [ - { - "project_connection_id": os.environ["FABRIC_PROJECT_CONNECTION_ID"], - } - ] - }, - }, - ) - - query = "Tell me about sales records" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_openapi.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_openapi.py deleted file mode 100644 index 2565c6ea23..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_openapi.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import json -from pathlib import Path - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with OpenAPI Tool Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with OpenAPI tools -to call external APIs defined by OpenAPI specifications. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. The countries.json OpenAPI specification is included in the resources folder. -""" - - -async def main() -> None: - # Load the OpenAPI specification - resources_path = Path(__file__).parents[3] / "shared" / "resources" / "countries.json" - - with open(resources_path) as f: - openapi_countries = json.load(f) - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyOpenAPIAgent", - instructions="""You are a helpful assistant that can use country APIs to provide information. - Use the available OpenAPI tools to answer questions about countries, currencies, and demographics.""", - tools={ - "type": "openapi", - "openapi": { - "name": "get_countries", - "spec": openapi_countries, - "description": "Retrieve information about countries by currency code", - "auth": {"type": "anonymous"}, - }, - }, - ) - - query = "What is the name and population of the country that uses currency with abbreviation THB?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_reasoning.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_reasoning.py deleted file mode 100644 index 60e6635fdd..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_reasoning.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.ai.projects.models import Reasoning -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Reasoning Example - -Demonstrates how to enable reasoning capabilities using the Reasoning option. -Shows both non-streaming and streaming approaches, including how to access -reasoning content (type="text_reasoning") separately from answer content. - -Requires a reasoning-capable model (e.g., gpt-5.2) deployed in your Azure AI Project configured -as `AZURE_AI_MODEL_DEPLOYMENT_NAME` in your environment. -""" - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="ReasoningWeatherAgent", - instructions="You are a helpful weather agent who likes to understand the underlying physics.", - default_options={"reasoning": Reasoning(effort="medium", summary="concise")}, - ) - - query = "How does the Bernoulli effect work?" - print(f"User: {query}") - result = await agent.run(query) - - for msg in result.messages: - for content in msg.contents: - if content.type == "text_reasoning": - print(f"[Reasoning]: {content.text}") - elif content.type == "text": - print(f"[Answer]: {content.text}") - print() - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="ReasoningWeatherAgent", - instructions="You are a helpful weather agent who likes to understand the underlying physics.", - default_options={"reasoning": Reasoning(effort="medium", summary="concise")}, - ) - - query = "Help explain how air updrafts work?" - print(f"User: {query}") - - shown_reasoning_label = False - shown_text_label = False - async for chunk in agent.run(query, stream=True): - for content in chunk.contents: - if content.type == "text_reasoning": - if not shown_reasoning_label: - print("[Reasoning]: ", end="", flush=True) - shown_reasoning_label = True - print(content.text, end="", flush=True) - elif content.type == "text": - if not shown_text_label: - print("\n\n[Answer]: ", end="", flush=True) - shown_text_label = True - print(content.text, end="", flush=True) - print("\n") - - -async def main() -> None: - print("=== Azure AI Agent with Reasoning Example ===") - - # await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_response_format.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_response_format.py deleted file mode 100644 index e1285b1d17..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_response_format.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import BaseModel, ConfigDict - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Response Format Example - -This sample demonstrates basic usage of AzureAIProjectAgentProvider with response format, -also known as structured outputs. -""" - - -class ReleaseBrief(BaseModel): - feature: str - benefit: str - launch_date: str - model_config = ConfigDict(extra="forbid") - - -async def main() -> None: - """Example of using response_format property.""" - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="ProductMarketerAgent", - instructions="Return launch briefs as structured JSON.", - # Specify Pydantic model for structured output via default_options - default_options={"response_format": ReleaseBrief}, - ) - - query = "Draft a launch brief for the Contoso Note app." - print(f"User: {query}") - result = await agent.run(query) - - try: - release_brief = result.value - print("Agent:") - print(f"Feature: {release_brief.feature}") - print(f"Benefit: {release_brief.benefit}") - print(f"Launch date: {release_brief.launch_date}") - except Exception: - print(f"Failed to parse response: {result.text}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_runtime_json_schema.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_runtime_json_schema.py deleted file mode 100644 index ae74566023..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_runtime_json_schema.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Response Format Example with Runtime JSON Schema - -This sample demonstrates basic usage of AzureAIProjectAgentProvider with response format, -also known as structured outputs. -""" - - -runtime_schema = { - "title": "WeatherDigest", - "type": "object", - "properties": { - "location": {"type": "string"}, - "conditions": {"type": "string"}, - "temperature_c": {"type": "number"}, - "advisory": {"type": "string"}, - }, - # OpenAI strict mode requires every property to appear in required. - "required": ["location", "conditions", "temperature_c", "advisory"], - "additionalProperties": False, -} - - -async def main() -> None: - """Example of using response_format property with a runtime JSON schema.""" - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Pass response_format via default_options using dict schema format - agent = await provider.create_agent( - name="WeatherDigestAgent", - instructions="Return sample weather digest as structured JSON.", - default_options={ - "response_format": { - "type": "json_schema", - "json_schema": { - "name": runtime_schema["title"], - "strict": True, - "schema": runtime_schema, - }, - } - }, - ) - - query = "Draft a sample weather digest." - print(f"User: {query}") - result = await agent.run(query) - - print(result.text) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_session.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_session.py deleted file mode 100644 index 209c6e2695..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_session.py +++ /dev/null @@ -1,165 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Session Management Example - -This sample demonstrates session management with Azure AI Agent, showing -persistent conversation capabilities using service-managed sessions as well as storing messages in-memory. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production -# See: -# samples/02-agents/tools/function_tool_with_approval.py -# 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." - - -async def example_with_automatic_session_creation() -> None: - """Example showing automatic session creation.""" - print("=== Automatic Session Creation Example ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # First conversation - no session provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no session provided, will create another new session - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") - - -async def example_with_session_persistence_in_memory() -> None: - """ - Example showing session persistence across multiple conversations. - In this example, messages are stored in-memory. - """ - print("=== Session Persistence Example (In-Memory) ===") - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a new session that will be reused - session = agent.create_session() - - # First conversation - first_query = "What's the weather like in Tokyo?" - print(f"User: {first_query}") - first_result = await agent.run(first_query, session=session, options={"store": False}) - print(f"Agent: {first_result.text}") - - # Second conversation using the same session - maintains context - second_query = "How about London?" - print(f"\nUser: {second_query}") - second_result = await agent.run(second_query, session=session, options={"store": False}) - print(f"Agent: {second_result.text}") - - # Third conversation - agent should remember both previous cities - third_query = "Which of the cities I asked about has better weather?" - print(f"\nUser: {third_query}") - third_result = await agent.run(third_query, session=session, options={"store": False}) - print(f"Agent: {third_result.text}") - print("Note: The agent remembers context from previous messages in the same session.\n") - - -async def example_with_existing_session_id() -> None: - """ - Example showing how to work with an existing session ID from the service. - In this example, messages are stored on the server. - """ - print("=== Existing Session ID Example ===") - - # First, create a conversation and capture the session ID - existing_session_id = None - - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="BasicWeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Start a conversation and get the session ID - session = agent.create_session() - - first_query = "What's the weather in Paris?" - print(f"User: {first_query}") - first_result = await agent.run(first_query, session=session) - print(f"Agent: {first_result.text}") - - # The session ID is set after the first response - existing_session_id = session.service_session_id - print(f"Session ID: {existing_session_id}") - - if existing_session_id: - print("\n--- Continuing with the same session ID in a new agent instance ---") - - # Retrieve the same agent (reuses existing agent version on the service) - second_agent = await provider.get_agent( - name="BasicWeatherAgent", - tools=get_weather, - ) - - # Attach the existing service session ID so conversation context is preserved - session = second_agent.get_session(service_session_id=existing_session_id) - - second_query = "What was the last city I asked about?" - print(f"User: {second_query}") - second_result = await second_agent.run(second_query, session=session) - print(f"Agent: {second_result.text}") - print("Note: The agent continues the conversation from the previous session by using session ID.\n") - - -async def main() -> None: - print("=== Azure AI Agent Session Management Examples ===\n") - - await example_with_automatic_session_creation() - await example_with_session_persistence_in_memory() - await example_with_existing_session_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_sharepoint.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_sharepoint.py deleted file mode 100644 index ce6bf85837..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_sharepoint.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -import asyncio -import os - -from agent_framework.azure import AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with SharePoint Example - -This sample demonstrates usage of AzureAIProjectAgentProvider with SharePoint -to search through SharePoint content and answer user questions about it. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables. -2. Ensure you have a SharePoint connection configured in your Azure AI project - and set SHAREPOINT_PROJECT_CONNECTION_ID environment variable. -""" - - -async def main() -> None: - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MySharePointAgent", - instructions="""You are a helpful agent that can use SharePoint tools to assist users. - Use the available SharePoint tools to answer questions and perform tasks.""", - tools={ - "type": "sharepoint_grounding_preview", - "sharepoint_grounding_preview": { - "project_connections": [ - { - "project_connection_id": os.environ["SHAREPOINT_PROJECT_CONNECTION_ID"], - } - ] - }, - }, - ) - - query = "What is Contoso whistleblower policy?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai/azure_ai_with_web_search.py b/python/samples/02-agents/providers/azure_ai/azure_ai_with_web_search.py deleted file mode 100644 index 5134b275d4..0000000000 --- a/python/samples/02-agents/providers/azure_ai/azure_ai_with_web_search.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIClient, AzureAIProjectAgentProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent With Web Search - -This sample demonstrates basic usage of AzureAIProjectAgentProvider to create an agent -that can perform web searches using get_web_search_tool(). - -Pre-requisites: -- Make sure to set up the AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME - environment variables before running this sample. -""" - - -async def main() -> None: - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIProjectAgentProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIClient(credential=credential) - # Create web search tool using instance method - web_search_tool = client.get_web_search_tool() - - agent = await provider.create_agent( - name="WebsearchAgent", - instructions="You are a helpful assistant that can search the web", - tools=[web_search_tool], - ) - - query = "What's the weather today in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - """ - Sample output: - User: What's the weather today in Seattle? - Agent: Here is the updated weather forecast for Seattle: The current temperature is approximately 57°F, - mostly cloudy conditions, with light winds and a chance of rain later tonight. Check out more details - at the [National Weather Service](https://forecast.weather.gov/zipcity.php?inputstring=Seattle%2CWA). - """ - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/README.md b/python/samples/02-agents/providers/azure_ai_agent/README.md deleted file mode 100644 index 3a52984006..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# Azure AI Agent Examples - -This folder contains examples demonstrating different ways to create and use agents with Azure AI using the `AzureAIAgentsProvider` from the `agent_framework.azure` package. These examples use the `azure-ai-agents` 1.x (V1) API surface. For updated V2 (`azure-ai-projects` 2.x) samples, see the [Azure AI V2 examples folder](../azure_ai/). - -## Provider Pattern - -All examples in this folder use the `AzureAIAgentsProvider` class which provides a high-level interface for agent operations: - -- **`create_agent()`** - Create a new agent on the Azure AI service -- **`get_agent()`** - Retrieve an existing agent by ID or from a pre-fetched Agent object -- **`as_agent()`** - Wrap an SDK Agent object as a Agent without HTTP calls - -```python -from agent_framework.azure import AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential - -async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, -): - agent = await provider.create_agent( - name="MyAgent", - instructions="You are a helpful assistant.", - tools=my_function, - ) - result = await agent.run("Hello!") -``` - -## Examples - -| File | Description | -|------|-------------| -| [`azure_ai_provider_methods.py`](azure_ai_provider_methods.py) | Comprehensive example demonstrating all `AzureAIAgentsProvider` methods: `create_agent()`, `get_agent()`, `as_agent()`, and managing multiple agents from a single provider. | -| [`azure_ai_basic.py`](azure_ai_basic.py) | The simplest way to create an agent using `AzureAIAgentsProvider`. It automatically handles all configuration using environment variables. Shows both streaming and non-streaming responses. | -| [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to find real-time information from the web using custom search configurations. Demonstrates how to use `AzureAIAgentClient.get_web_search_tool()` with custom search instances. | -| [`azure_ai_with_bing_grounding.py`](azure_ai_with_bing_grounding.py) | Shows how to use Bing Grounding search with Azure AI agents to find real-time information from the web. Demonstrates `AzureAIAgentClient.get_web_search_tool()` with proper source citations and comprehensive error handling. | -| [`azure_ai_with_bing_grounding_citations.py`](azure_ai_with_bing_grounding_citations.py) | Demonstrates how to extract and display citations from Bing Grounding search responses. Shows how to collect citation annotations (title, URL, snippet) during streaming responses, enabling users to verify sources and access referenced content. | -| [`azure_ai_with_code_interpreter_file_generation.py`](azure_ai_with_code_interpreter_file_generation.py) | Shows how to retrieve file IDs from code interpreter generated files using both streaming and non-streaming approaches. | -| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use `AzureAIAgentClient.get_code_interpreter_tool()` with Azure AI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | -| [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with an existing SDK Agent object using `provider.as_agent()`. This wraps the agent without making HTTP calls. | -| [`azure_ai_with_existing_session.py`](azure_ai_with_existing_session.py) | Shows how to work with a pre-existing session by providing the session ID. Demonstrates proper cleanup of manually created sessions. | -| [`azure_ai_with_explicit_settings.py`](azure_ai_with_explicit_settings.py) | Shows how to create an agent with explicitly configured provider settings, including project endpoint and model deployment name. | -| [`azure_ai_with_azure_ai_search.py`](azure_ai_with_azure_ai_search.py) | Demonstrates how to use Azure AI Search with Azure AI agents. Shows how to create an agent with search tools using the SDK directly and wrap it with `provider.get_agent()`. | -| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Demonstrates how to use `AzureAIAgentClient.get_file_search_tool()` with Azure AI agents to search through uploaded documents. Shows file upload, vector store creation, and querying document content. | -| [`azure_ai_with_function_tools.py`](azure_ai_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to use `AzureAIAgentClient.get_mcp_tool()` with hosted Model Context Protocol (MCP) servers for enhanced functionality and tool integration. Demonstrates remote MCP server connections and tool discovery. | -| [`azure_ai_with_local_mcp.py`](azure_ai_with_local_mcp.py) | Shows how to integrate Azure AI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. Demonstrates both agent-level and run-level tool configuration. | -| [`azure_ai_with_multiple_tools.py`](azure_ai_with_multiple_tools.py) | Demonstrates how to use multiple tools together with Azure AI agents, including web search, MCP servers, and function tools using client static methods. Shows coordinated multi-tool interactions and approval workflows. | -| [`azure_ai_with_openapi_tools.py`](azure_ai_with_openapi_tools.py) | Demonstrates how to use OpenAPI tools with Azure AI agents to integrate external REST APIs. Shows OpenAPI specification loading, anonymous authentication, session context management, and coordinated multi-API conversations. | -| [`azure_ai_with_response_format.py`](azure_ai_with_response_format.py) | Demonstrates how to use structured outputs with Azure AI agents using Pydantic models. | -| [`azure_ai_with_session.py`](azure_ai_with_session.py) | Demonstrates session management with Azure AI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | - -## Environment Variables - -Before running the examples, you need to set up your environment variables. You can do this in one of two ways: - -### Option 1: Using a .env file (Recommended) - -1. Copy the `.env.example` file from the `python` directory to create a `.env` file: - ```bash - cp ../../.env.example ../../.env - ``` - -2. Edit the `.env` file and add your values: - ``` - AZURE_AI_PROJECT_ENDPOINT="your-project-endpoint" - AZURE_AI_MODEL_DEPLOYMENT_NAME="your-model-deployment-name" - ``` - -3. For samples using Bing Grounding search (like `azure_ai_with_bing_grounding.py` and `azure_ai_with_multiple_tools.py`), you'll also need: - ``` - BING_CONNECTION_ID="your-bing-connection-id" - ``` - - To get your Bing connection details: - - Go to [Azure AI Foundry portal](https://ai.azure.com) - - Navigate to your project's "Connected resources" section - - Add a new connection for "Grounding with Bing Search" - - Copy the ID - -4. For samples using Bing Custom Search (like `azure_ai_with_bing_custom_search.py`), you'll also need: - ``` - BING_CUSTOM_CONNECTION_ID="your-bing-custom-connection-id" - BING_CUSTOM_INSTANCE_NAME="your-bing-custom-instance-name" - ``` - - To get your Bing Custom Search connection details: - - Go to [Azure AI Foundry portal](https://ai.azure.com) - - Navigate to your project's "Connected resources" section - - Add a new connection for "Grounding with Bing Custom Search" - - Copy the connection ID and instance name - -### Option 2: Using environment variables directly - -Set the environment variables in your shell: - -```bash -export AZURE_AI_PROJECT_ENDPOINT="your-project-endpoint" -export AZURE_AI_MODEL_DEPLOYMENT_NAME="your-model-deployment-name" -export BING_CONNECTION_ID="your-bing-connection-id" -export BING_CUSTOM_CONNECTION_ID="your-bing-custom-connection-id" -export BING_CUSTOM_INSTANCE_NAME="your-bing-custom-instance-name" -``` - -### Required Variables - -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint (required for all examples) -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment (required for all examples) - -### Optional Variables - -- `BING_CONNECTION_ID`: Your Bing connection ID (required for `azure_ai_with_bing_grounding.py` and `azure_ai_with_multiple_tools.py`) -- `BING_CUSTOM_CONNECTION_ID`: Your Bing Custom Search connection ID (required for `azure_ai_with_bing_custom_search.py`) -- `BING_CUSTOM_INSTANCE_NAME`: Your Bing Custom Search instance name (required for `azure_ai_with_bing_custom_search.py`) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_basic.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_basic.py deleted file mode 100644 index 640653df26..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_basic.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Basic Example - -This sample demonstrates basic usage of AzureAIAgentsProvider to create agents with automatic -lifecycle management. Shows both streaming and non-streaming responses with function tools. -""" - - -# 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." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - - -async def main() -> None: - print("=== Basic Azure AI Chat Client Agent Example ===") - - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_provider_methods.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_provider_methods.py deleted file mode 100644 index 571f737315..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_provider_methods.py +++ /dev/null @@ -1,149 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIAgentsProvider -from azure.ai.agents.aio import AgentsClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Provider Methods Example - -This sample demonstrates the methods available on the AzureAIAgentsProvider class: -- create_agent(): Create a new agent on the service -- get_agent(): Retrieve an existing agent by ID -- as_agent(): Wrap an SDK Agent object without making HTTP calls -""" - - -# 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." - - -async def create_agent_example() -> None: - """Create a new agent using provider.create_agent().""" - print("\n--- create_agent() ---") - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather assistant.", - tools=get_weather, - ) - - print(f"Created: {agent.name} (ID: {agent.id})") - result = await agent.run("What's the weather in Seattle?") - print(f"Response: {result}") - - -async def get_agent_example() -> None: - """Retrieve an existing agent by ID using provider.get_agent().""" - print("\n--- get_agent() ---") - - async with ( - AzureCliCredential() as credential, - AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, - AzureAIAgentsProvider(agents_client=agents_client) as provider, - ): - # Create an agent directly with SDK (simulating pre-existing agent) - sdk_agent = await agents_client.create_agent( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - name="ExistingAgent", - instructions="You always respond with 'Hello!'", - ) - - try: - # Retrieve using provider - agent = await provider.get_agent(sdk_agent.id) - print(f"Retrieved: {agent.name} (ID: {agent.id})") - - result = await agent.run("Hi there!") - print(f"Response: {result}") - finally: - await agents_client.delete_agent(sdk_agent.id) - - -async def as_agent_example() -> None: - """Wrap an SDK Agent object using provider.as_agent().""" - print("\n--- as_agent() ---") - - async with ( - AzureCliCredential() as credential, - AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, - AzureAIAgentsProvider(agents_client=agents_client) as provider, - ): - # Create agent using SDK - sdk_agent = await agents_client.create_agent( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - name="WrappedAgent", - instructions="You respond with poetry.", - ) - - try: - # Wrap synchronously (no HTTP call) - agent = provider.as_agent(sdk_agent) - print(f"Wrapped: {agent.name} (ID: {agent.id})") - - result = await agent.run("Tell me about the sunset.") - print(f"Response: {result}") - finally: - await agents_client.delete_agent(sdk_agent.id) - - -async def multiple_agents_example() -> None: - """Create and manage multiple agents with a single provider.""" - print("\n--- Multiple Agents ---") - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - weather_agent = await provider.create_agent( - name="WeatherSpecialist", - instructions="You are a weather specialist.", - tools=get_weather, - ) - - greeter_agent = await provider.create_agent( - name="GreeterAgent", - instructions="You are a friendly greeter.", - ) - - print(f"Created: {weather_agent.name}, {greeter_agent.name}") - - greeting = await greeter_agent.run("Hello!") - print(f"Greeter: {greeting}") - - weather = await weather_agent.run("What's the weather in Tokyo?") - print(f"Weather: {weather}") - - -async def main() -> None: - print("Azure AI Agent Provider Methods") - - await create_agent_example() - await get_agent_example() - await as_agent_example() - await multiple_agents_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_azure_ai_search.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_azure_ai_search.py deleted file mode 100644 index 67833d2dd0..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_azure_ai_search.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import Annotation -from agent_framework.azure import AzureAIAgentsProvider -from azure.ai.agents.aio import AgentsClient -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import ConnectionType -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Azure AI Search Example - -This sample demonstrates how to create an Azure AI agent that uses Azure AI Search -to search through indexed hotel data and answer user questions about hotels. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables -2. Ensure you have an Azure AI Search connection configured in your Azure AI project -3. The search index "hotels-sample-index" should exist in your Azure AI Search service - (you can create this using the Azure portal with sample hotel data) - -NOTE: To ensure consistent search tool usage: -- Include explicit instructions for the agent to use the search tool -- Mention the search requirement in your queries -- Use `tool_choice="required"` to force tool usage - -More info on `query type` can be found here: -https://learn.microsoft.com/en-us/python/api/azure-ai-agents/azure.ai.agents.models.aisearchindexresource?view=azure-python-preview -""" - - -async def main() -> None: - """Main function demonstrating Azure AI agent with raw Azure AI Search tool.""" - print("=== Azure AI Agent with Raw Azure AI Search Tool ===") - - # Create the client and manually create an agent with Azure AI Search tool - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client, - AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, - AzureAIAgentsProvider(agents_client=agents_client) as provider, - ): - ai_search_conn_id = "" - async for connection in project_client.connections.list(): - if connection.type == ConnectionType.AZURE_AI_SEARCH: - ai_search_conn_id = connection.id - break - - # 1. Create Azure AI agent with the search tool using SDK directly - # (Azure AI Search tool requires special tool_resources configuration) - azure_ai_agent = await agents_client.create_agent( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - name="HotelSearchAgent", - instructions=( - "You are a helpful agent that searches hotel information using Azure AI Search. " - "Always use the search tool and index to find hotel data and provide accurate information." - ), - tools=[{"type": "azure_ai_search"}], - tool_resources={ - "azure_ai_search": { - "indexes": [ - { - "index_connection_id": ai_search_conn_id, - "index_name": "hotels-sample-index", - "query_type": "vector", - } - ] - } - }, - ) - - try: - # 2. Use provider.as_agent() to wrap the existing agent - agent = provider.as_agent(agent=azure_ai_agent) - - print("This agent uses raw Azure AI Search tool to search hotel data.\n") - - # 3. Simulate conversation with the agent - user_input = ( - "Use Azure AI search knowledge tool to find detailed information about a winter hotel." - " Use the search tool and index." # You can modify prompt to force tool usage - ) - print(f"User: {user_input}") - print("Agent: ", end="", flush=True) - # Stream the response and collect citations - citations: list[Annotation] = [] - async for chunk in agent.run(user_input, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - # Collect citations from Azure AI Search responses - for content in getattr(chunk, "contents", []): - annotations = getattr(content, "annotations", []) - if annotations: - citations.extend(annotations) - - print() - - # Display collected citation - if citations: - print("\n\nCitation:") - for i, citation in enumerate(citations, 1): - print(f"[{i}] {citation.get('url')}") - - print("\n" + "=" * 50 + "\n") - print("Hotel search conversation completed!") - - finally: - # Clean up the agent manually - await agents_client.delete_agent(azure_ai_agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_custom_search.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_custom_search.py deleted file mode 100644 index 4268568f85..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_custom_search.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -The following sample demonstrates how to create an Azure AI agent that -uses Bing Custom Search to find real-time information from the web. - -More information on Bing Custom Search and difference from Bing Grounding can be found here: -https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-custom-search - -Prerequisites: -1. A connected Grounding with Bing Custom Search resource in your Azure AI project -2. Set BING_CUSTOM_CONNECTION_ID environment variable - Example: BING_CUSTOM_CONNECTION_ID="your-bing-custom-connection-id" -3. Set BING_CUSTOM_INSTANCE_NAME environment variable - Example: BING_CUSTOM_INSTANCE_NAME="your-bing-custom-instance-name" - -To set up Bing Custom Search: -1. Go to Azure AI Foundry portal (https://ai.azure.com) -2. Navigate to your project's "Connected resources" section -3. Add a new connection for "Grounding with Bing Custom Search" -4. Copy the connection ID and instance name and set the appropriate environment variables -""" - - -async def main() -> None: - """Main function demonstrating Azure AI agent with Bing Custom Search.""" - # Use AzureAIAgentsProvider for agent creation and management - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - # Create Bing Custom Search tool using instance method - # The connection ID and instance name will be automatically picked up from environment variables - # (BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME) - bing_search_tool = client.get_web_search_tool() - - agent = await provider.create_agent( - name="BingSearchAgent", - instructions=( - "You are a helpful agent that can use Bing Custom Search tools to assist users. " - "Use the available Bing Custom Search tools to answer questions and perform tasks." - ), - tools=[bing_search_tool], - ) - - # 3. Demonstrate agent capabilities with bing custom search - print("=== Azure AI Agent with Bing Custom Search ===\n") - - user_input = "Tell me more about foundry agent service" - print(f"User: {user_input}") - response = await agent.run(user_input) - print(f"Agent: {response.text}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding.py deleted file mode 100644 index 7fb83b378d..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -The following sample demonstrates how to create an Azure AI agent that -uses Bing Grounding search to find real-time information from the web. - -Prerequisites: -1. A connected Grounding with Bing Search resource in your Azure AI project -2. Set BING_CONNECTION_ID environment variable - Example: BING_CONNECTION_ID="your-bing-connection-id" - -To set up Bing Grounding: -1. Go to Azure AI Foundry portal (https://ai.azure.com) -2. Navigate to your project's "Connected resources" section -3. Add a new connection for "Grounding with Bing Search" -4. Copy either the connection name or ID and set the appropriate environment variable -""" - - -async def main() -> None: - """Main function demonstrating Azure AI agent with Bing Grounding search.""" - # Use AzureAIAgentsProvider for agent creation and management - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - # Create Bing Grounding search tool using instance method - # The connection ID will be automatically picked up from environment variable - bing_search_tool = client.get_web_search_tool() - - agent = await provider.create_agent( - name="BingSearchAgent", - instructions=( - "You are a helpful assistant that can search the web for current information. " - "Use the Bing search tool to find up-to-date information and provide accurate, " - "well-sourced answers. Always cite your sources when possible." - ), - tools=[bing_search_tool], - ) - - # 3. Demonstrate agent capabilities with web search - print("=== Azure AI Agent with Bing Grounding Search ===\n") - - user_input = "What is the most popular programming language?" - print(f"User: {user_input}") - response = await agent.run(user_input) - print(f"Agent: {response.text}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding_citations.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding_citations.py deleted file mode 100644 index 1b240f9812..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_bing_grounding_citations.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import Annotation -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -This sample demonstrates how to create an Azure AI agent that uses Bing Grounding -search to find real-time information from the web with comprehensive citation support. -It shows how to extract and display citations (title, URL, and snippet) from Bing -Grounding responses, enabling users to verify sources and explore referenced content. - -Prerequisites: -1. A connected Grounding with Bing Search resource in your Azure AI project -2. Set BING_CONNECTION_ID environment variable - Example: BING_CONNECTION_ID="your-bing-connection-id" - -To set up Bing Grounding: -1. Go to Azure AI Foundry portal (https://ai.azure.com) -2. Navigate to your project's "Connected resources" section -3. Add a new connection for "Grounding with Bing Search" -4. Copy the connection ID and set the BING_CONNECTION_ID environment variable -""" - - -async def main() -> None: - """Main function demonstrating Azure AI agent with Bing Grounding search.""" - # Use AzureAIAgentsProvider for agent creation and management - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - # Create Bing Grounding search tool using instance method - # The connection ID will be automatically picked up from environment variable - bing_search_tool = client.get_web_search_tool() - - agent = await provider.create_agent( - name="BingSearchAgent", - instructions=( - "You are a helpful assistant that can search the web for current information. " - "Use the Bing search tool to find up-to-date information and provide accurate, " - "well-sourced answers. Always cite your sources when possible." - ), - tools=[bing_search_tool], - ) - - # 3. Demonstrate agent capabilities with web search - print("=== Azure AI Agent with Bing Grounding Search ===\n") - - user_input = "What is the most popular programming language?" - print(f"User: {user_input}") - print("Agent: ", end="", flush=True) - - # Stream the response and collect citations - citations: list[Annotation] = [] - async for chunk in agent.run(user_input, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - - # Collect citations from Bing Grounding responses - for content in getattr(chunk, "contents", []): - annotations = getattr(content, "annotations", []) - if annotations: - citations.extend(annotations) - - print() - - # Display collected citations - if citations: - print("\n\nCitations:") - for i, citation in enumerate(citations, 1): - print(f"[{i}] {citation['title']}: {citation.get('url')}") - if "snippet" in citation: - print(f" Snippet: {citation.get('snippet')}") - else: - print("\nNo citations found in the response.") - - print() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter.py deleted file mode 100644 index 353a10f6a0..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import AgentResponse, ChatResponseUpdate -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.ai.agents.models import ( - RunStepDeltaCodeInterpreterDetailItemObject, -) -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Code Interpreter Example - -This sample demonstrates using get_code_interpreter_tool() with Azure AI Agents -for Python code execution and mathematical problem solving. -""" - - -def print_code_interpreter_inputs(response: AgentResponse) -> None: - """Helper method to access code interpreter data.""" - - print("\nCode Interpreter Inputs during the run:") - if response.raw_representation is None: - return - for chunk in response.raw_representation: - if isinstance(chunk, ChatResponseUpdate) and isinstance( - chunk.raw_representation, RunStepDeltaCodeInterpreterDetailItemObject - ): - print(chunk.raw_representation.input, end="") - print("\n") - - -async def main() -> None: - """Example showing how to use the code interpreter tool with Azure AI.""" - print("=== Azure AI Agent with Code Interpreter Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - code_interpreter_tool = client.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="CodingAgent", - instructions=("You are a helpful assistant that can write and execute Python code to solve problems."), - tools=[code_interpreter_tool], - ) - query = "Generate the factorial of 100 using python code, show the code and execute it." - print(f"User: {query}") - response = await agent.run(query) - print(f"Agent: {response}") - # To review the code interpreter outputs, you can access - # them from the response raw_representations, just uncomment the next line: - # print_code_interpreter_inputs(response) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py deleted file mode 100644 index e96b439b92..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.ai.agents.aio import AgentsClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Code Interpreter File Generation Example - -This sample demonstrates using get_code_interpreter_tool() with AzureAIAgentsProvider -to generate a text file and then retrieve it. - -The test flow: -1. Create an agent with code interpreter tool -2. Ask the agent to generate a txt file using Python code -3. Capture the file_id from HostedFileContent in the response -4. Retrieve the file using the agents_client.files API -""" - - -async def main() -> None: - """Test file generation and retrieval with code interpreter.""" - - async with ( - AzureCliCredential() as credential, - AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, - AzureAIAgentsProvider(agents_client=agents_client) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - code_interpreter_tool = client.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="CodeInterpreterAgent", - instructions=( - "You are a Python code execution assistant. " - "ALWAYS use the code interpreter tool to execute Python code when asked to create files. " - "Write actual Python code to create files, do not just describe what you would do." - ), - tools=[code_interpreter_tool], - ) - - # Be very explicit about wanting code execution and a download link - query = ( - "Use the code interpreter to execute this Python code and then provide me " - "with a download link for the generated file:\n" - "```python\n" - "with open('/mnt/data/sample.txt', 'w') as f:\n" - " f.write('Hello, World! This is a test file.')\n" - "'/mnt/data/sample.txt'\n" # Return the path so it becomes downloadable - "```" - ) - print(f"User: {query}\n") - print("=" * 60) - - # Collect file_ids from the response - file_ids: list[str] = [] - - async for chunk in agent.run(query, stream=True): - for content in chunk.contents: - if content.type == "text": - print(content.text, end="", flush=True) - elif content.type == "hosted_file" and content.file_id: - file_ids.append(content.file_id) - print(f"\n[File generated: {content.file_id}]") - - print("\n" + "=" * 60) - - # Attempt to retrieve discovered files - if file_ids: - print(f"\nAttempting to retrieve {len(file_ids)} file(s):") - for file_id in file_ids: - try: - file_info = await agents_client.files.get(file_id) - print(f" File {file_id}: Retrieved successfully") - print(f" Filename: {file_info.filename}") - print(f" Purpose: {file_info.purpose}") - print(f" Bytes: {file_info.bytes}") - except Exception as e: - print(f" File {file_id}: FAILED to retrieve - {e}") - else: - print("No file IDs were captured from the response.") - - # List all files to see if any exist - print("\nListing all files in the agent service:") - try: - files_list = await agents_client.files.list() - count = 0 - for file_info in files_list.data: - count += 1 - print(f" - {file_info.id}: {file_info.filename} ({file_info.purpose})") - if count == 0: - print(" No files found.") - except Exception as e: - print(f" Failed to list files: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_agent.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_agent.py deleted file mode 100644 index 95a93303f1..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_agent.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework.azure import AzureAIAgentsProvider -from azure.ai.agents.aio import AgentsClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Existing Agent Example - -This sample demonstrates working with pre-existing Azure AI Agents by providing -agent IDs, showing agent reuse patterns for production scenarios. -""" - - -async def main() -> None: - print("=== Azure AI Agent with Existing Agent ===") - - # Create the client and provider - async with ( - AzureCliCredential() as credential, - AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, - AzureAIAgentsProvider(agents_client=agents_client) as provider, - ): - # Create an agent on the service with default instructions - # These instructions will persist on created agent for every run. - azure_ai_agent = await agents_client.create_agent( - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - instructions="End each response with [END].", - ) - - try: - # Wrap existing agent instance using provider.as_agent() - agent = provider.as_agent(azure_ai_agent) - - query = "How are you?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - finally: - # Clean up the agent manually - await agents_client.delete_agent(azure_ai_agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_session.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_session.py deleted file mode 100644 index 76f08b37ea..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_existing_session.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIAgentsProvider -from azure.ai.agents.aio import AgentsClient -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Existing Session Example - -This sample demonstrates working with pre-existing conversation sessions -by providing session IDs for session reuse patterns. -""" - - -# 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." - - -async def main() -> None: - print("=== Azure AI Agent with Existing Session ===") - - # Create the client and provider - async with ( - AzureCliCredential() as credential, - AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, - AzureAIAgentsProvider(agents_client=agents_client) as provider, - ): - # Create a session that will persist - created_thread = await agents_client.threads.create() - - try: - # Create agent using provider - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - session = agent.get_session(service_session_id=created_thread.id) - result = await agent.run("What's the weather like in Tokyo?", session=session) - print(f"Result: {result}\n") - finally: - # Clean up the session manually - await agents_client.threads.delete(created_thread.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_explicit_settings.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_explicit_settings.py deleted file mode 100644 index f34d9e41e9..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_explicit_settings.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Explicit Settings Example - -This sample demonstrates creating Azure AI Agents with explicit configuration -settings rather than relying on environment variable defaults. -""" - - -# 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." - - -async def main() -> None: - print("=== Azure AI Agent with Explicit Settings ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - credential=credential, - ) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - result = await agent.run("What's the weather like in New York?") - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_file_search.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_file_search.py deleted file mode 100644 index 7721152e6e..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_file_search.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from pathlib import Path - -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.ai.agents.aio import AgentsClient -from azure.ai.agents.models import FileInfo, VectorStore -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -The following sample demonstrates how to create a simple, Azure AI agent that -uses a file search tool to answer user questions. -""" - - -# Simulate a conversation with the agent -USER_INPUTS = [ - "Who is the youngest employee?", - "Who works in sales?", - "I have a customer request, who can help me?", -] - - -async def main() -> None: - """Main function demonstrating Azure AI agent with file search capabilities.""" - file: FileInfo | None = None - vector_store: VectorStore | None = None - - async with ( - AzureCliCredential() as credential, - AgentsClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as agents_client, - AzureAIAgentsProvider(agents_client=agents_client) as provider, - ): - try: - # 1. Upload file and create vector store - pdf_file_path = Path(__file__).parents[3] / "shared" / "resources" / "employees.pdf" - print(f"Uploading file from: {pdf_file_path}") - - file = await agents_client.files.upload_and_poll(file_path=str(pdf_file_path), purpose="assistants") - print(f"Uploaded file, file ID: {file.id}") - - vector_store = await agents_client.vector_stores.create_and_poll(file_ids=[file.id], name="my_vectorstore") - print(f"Created vector store, vector store ID: {vector_store.id}") - - # 2. Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id]) - - # 3. Create an agent with file search capabilities - agent = await provider.create_agent( - name="EmployeeSearchAgent", - instructions=( - "You are a helpful assistant that can search through uploaded employee files " - "to answer questions about employees." - ), - tools=[file_search_tool], - ) - - # 4. Simulate conversation with the agent - for user_input in USER_INPUTS: - print(f"# User: '{user_input}'") - response = await agent.run(user_input) - print(f"# Agent: {response.text}") - - finally: - # 5. Cleanup: Delete the vector store and file - try: - if vector_store: - await agents_client.vector_stores.delete(vector_store.id) - if file: - await agents_client.files.delete(file.id) - except Exception: - # Ignore cleanup errors to avoid masking issues - pass - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_function_tools.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_function_tools.py deleted file mode 100644 index 3366af58ce..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_function_tools.py +++ /dev/null @@ -1,155 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from datetime import datetime, timezone -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Function Tools Example - -This sample demonstrates function tool integration with Azure AI Agents, -showing both agent-level and query-level tool configuration patterns. -""" - - -# 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." - - -@tool(approval_mode="never_require") -def get_time() -> str: - """Get the current UTC time.""" - current_time = datetime.now(timezone.utc) - return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." - - -async def tools_on_agent_level() -> None: - """Example showing tools defined when creating the agent.""" - print("=== Tools Defined on Agent Level ===") - - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="AssistantAgent", - instructions="You are a helpful assistant that can provide weather and time information.", - tools=[get_weather, get_time], # Tools defined at agent creation - ) - - # First query - agent can use weather tool - query1 = "What's the weather like in New York?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query - agent can use time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2}\n") - - # Third query - agent can use both tools if needed - query3 = "What's the weather in London and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3) - print(f"Agent: {result3}\n") - - -async def tools_on_run_level() -> None: - """Example showing tools passed to the run method.""" - print("=== Tools Passed to Run Method ===") - - # Agent created without tools - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="AssistantAgent", - instructions="You are a helpful assistant.", - # No tools defined here - ) - - # First query with weather tool - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1, tools=[get_weather]) # Tool passed to run method - print(f"Agent: {result1}\n") - - # Second query with time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2, tools=[get_time]) # Different tool for this query - print(f"Agent: {result2}\n") - - # Third query with multiple tools - query3 = "What's the weather in Chicago and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3, tools=[get_weather, get_time]) # Multiple tools - print(f"Agent: {result3}\n") - - -async def mixed_tools_example() -> None: - """Example showing both agent-level tools and run-method tools.""" - print("=== Mixed Tools Example (Agent + Run Method) ===") - - # Agent created with some base tools - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="AssistantAgent", - instructions="You are a comprehensive assistant that can help with various information requests.", - tools=[get_weather], # Base tool available for all queries - ) - - # Query using both agent tool and additional run-method tools - query = "What's the weather in Denver and what's the current UTC time?" - print(f"User: {query}") - - # Agent has access to get_weather (from creation) + additional tools from run method - result = await agent.run( - query, - tools=[get_time], # Additional tools for this specific query - ) - print(f"Agent: {result}\n") - - -async def main() -> None: - print("=== Azure AI Chat Client Agent with Function Tools Examples ===\n") - - await tools_on_agent_level() - await tools_on_run_level() - await mixed_tools_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_hosted_mcp.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_hosted_mcp.py deleted file mode 100644 index 2401ee9331..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_hosted_mcp.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from typing import Any - -from agent_framework import AgentResponse, AgentSession, SupportsAgentRun -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Hosted MCP Example - -This sample demonstrates integration of Azure AI Agents with hosted Model Context Protocol (MCP) -servers, including user approval workflows for function call security. -""" - - -async def handle_approvals_with_session( - query: str, agent: "SupportsAgentRun", session: "AgentSession" -) -> AgentResponse: - """Here we let the session deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import Message - - result = await agent.run(query, session=session, store=True) - while len(result.user_input_requests) > 0: - new_input: list[Any] = [] - for user_input_needed in result.user_input_requests: - print( - f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}" - f" with arguments: {user_input_needed.function_call.arguments}" - ) - user_approval = input("Approve function call? (y/n): ") - new_input.append( - Message( - role="user", - contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], - ) - ) - result = await agent.run(new_input, session=session, store=True) - return result - - -async def main() -> None: - """Example showing Hosted MCP tools for a Azure AI Agent.""" - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - # Create MCP tool using instance method - mcp_tool = client.get_mcp_tool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ) - - agent = await provider.create_agent( - name="DocsAgent", - instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=[mcp_tool], - ) - session = agent.create_session() - # First query - query1 = "How to create an Azure storage account using az cli?" - print(f"User: {query1}") - result1 = await handle_approvals_with_session(query1, agent, session) - print(f"{agent.name}: {result1}\n") - print("\n=======================================\n") - # Second query - query2 = "What is Microsoft Agent Framework?" - print(f"User: {query2}") - result2 = await handle_approvals_with_session(query2, agent, session) - print(f"{agent.name}: {result2}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_local_mcp.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_local_mcp.py deleted file mode 100644 index f7165e4c8d..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_local_mcp.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import MCPStreamableHTTPTool -from agent_framework.azure import AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Local MCP Example - -This sample demonstrates integration of Azure AI Agents with local Model Context Protocol (MCP) -servers, showing both agent-level and run-level tool configuration patterns. -""" - - -async def mcp_tools_on_run_level() -> None: - """Example showing MCP tools defined when running the agent.""" - print("=== Tools Defined on Run Level ===") - - # Tools are provided when running the agent - # This means we have to ensure we connect to the MCP server before running the agent - # and pass the tools to the run method. - async with ( - AzureCliCredential() as credential, - MCPStreamableHTTPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ) as mcp_server, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="DocsAgent", - instructions="You are a helpful assistant that can help with microsoft documentation questions.", - ) - # First query - query1 = "How to create an Azure storage account using az cli?" - print(f"User: {query1}") - result1 = await agent.run(query1, tools=mcp_server) - print(f"{agent.name}: {result1}\n") - print("\n=======================================\n") - # Second query - query2 = "What is Microsoft Agent Framework?" - print(f"User: {query2}") - result2 = await agent.run(query2, tools=mcp_server) - print(f"{agent.name}: {result2}\n") - - -async def mcp_tools_on_agent_level() -> None: - """Example showing local MCP tools passed when creating the agent.""" - print("=== Tools Defined on Agent Level ===") - - # Tools are provided when creating the agent - # The Agent will connect to the MCP server through its context manager - # and discover tools at runtime - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="DocsAgent", - instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=MCPStreamableHTTPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), - ) - # Use agent as context manager to connect MCP tools - async with agent: - # First query - query1 = "How to create an Azure storage account using az cli?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"{agent.name}: {result1}\n") - print("\n=======================================\n") - # Second query - query2 = "What is Microsoft Agent Framework?" - print(f"User: {query2}") - result2 = await agent.run(query2) - print(f"{agent.name}: {result2}\n") - - -async def main() -> None: - print("=== Azure AI Chat Client Agent with MCP Tools Examples ===\n") - - await mcp_tools_on_agent_level() - await mcp_tools_on_run_level() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_multiple_tools.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_multiple_tools.py deleted file mode 100644 index c7e8056219..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_multiple_tools.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from datetime import datetime, timezone -from typing import Any - -from agent_framework import ( - AgentSession, - SupportsAgentRun, - tool, -) -from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Multiple Tools Example - -This sample demonstrates integrating multiple tools (MCP and Web Search) with Azure AI Agents, -including user approval workflows for function call security. - -Prerequisites: -1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables -2. For Bing search functionality, set BING_CONNECTION_ID environment variable to your Bing connection ID - Example: BING_CONNECTION_ID="/subscriptions/{subscription-id}/resourceGroups/{resource-group}/ - providers/Microsoft.CognitiveServices/accounts/{ai-service-name}/projects/{project-name}/ - connections/{connection-name}" - -To set up Bing Grounding: -1. Go to Azure AI Foundry portal (https://ai.azure.com) -2. Navigate to your project's "Connected resources" section -3. Add a new connection for "Grounding with Bing Search" -4. Copy the connection ID and set it as the BING_CONNECTION_ID environment variable -""" - - -# 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_time() -> str: - """Get the current UTC time.""" - current_time = datetime.now(timezone.utc) - return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." - - -async def handle_approvals_with_session(query: str, agent: "SupportsAgentRun", session: "AgentSession"): - """Here we let the session deal with the previous responses, and we just rerun with the approval.""" - from agent_framework import Message - - result = await agent.run(query, session=session, store=True) - while len(result.user_input_requests) > 0: - new_input: list[Any] = [] - for user_input_needed in result.user_input_requests: - print( - f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}" - f" with arguments: {user_input_needed.function_call.arguments}" - ) - user_approval = input("Approve function call? (y/n): ") - new_input.append( - Message( - role="user", - contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")], - ) - ) - result = await agent.run(new_input, session=session, store=True) - return result - - -async def main() -> None: - """Example showing multiple tools for an Azure AI Agent.""" - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # Create a client to access hosted tool factory methods - client = AzureAIAgentClient(credential=credential) - # Create tools using instance methods - mcp_tool = client.get_mcp_tool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ) - web_search_tool = client.get_web_search_tool() - - agent = await provider.create_agent( - name="DocsAgent", - instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=[ - mcp_tool, - web_search_tool, - get_time, - ], - ) - session = agent.create_session() - # First query - query1 = "How to create an Azure storage account using az cli and what time is it?" - print(f"User: {query1}") - result1 = await handle_approvals_with_session(query1, agent, session) - print(f"{agent.name}: {result1}\n") - print("\n=======================================\n") - # Second query - query2 = "What is Microsoft Agent Framework and use a web search to see what is Reddit saying about it?" - print(f"User: {query2}") - result2 = await handle_approvals_with_session(query2, agent, session) - print(f"{agent.name}: {result2}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_openapi_tools.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_openapi_tools.py deleted file mode 100644 index 6eb032ae2c..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_openapi_tools.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import json -from pathlib import Path -from typing import Any - -from agent_framework.azure import AzureAIAgentsProvider -from azure.ai.agents.models import OpenApiAnonymousAuthDetails, OpenApiTool -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -""" -The following sample demonstrates how to create a simple, Azure AI agent that -uses OpenAPI tools to answer user questions. -""" - -# Simulate a conversation with the agent -USER_INPUTS = [ - "What is the name and population of the country that uses currency with abbreviation THB?", - "What is the current weather in the capital city of that country?", -] - - -def load_openapi_specs() -> tuple[dict[str, Any], dict[str, Any]]: - """Load OpenAPI specification files.""" - resources_path = Path(__file__).parents[3] / "shared" / "resources" - - with open(resources_path / "weather.json") as weather_file: - weather_spec = json.load(weather_file) - - with open(resources_path / "countries.json") as countries_file: - countries_spec = json.load(countries_file) - - return weather_spec, countries_spec - - -async def main() -> None: - """Main function demonstrating Azure AI agent with OpenAPI tools.""" - # 1. Load OpenAPI specifications (synchronous operation) - weather_openapi_spec, countries_openapi_spec = load_openapi_specs() - - # 2. Use AzureAIAgentsProvider for agent creation and management - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # 3. Create OpenAPI tools using Azure AI's OpenApiTool - auth = OpenApiAnonymousAuthDetails() - - openapi_weather = OpenApiTool( - name="get_weather", - spec=weather_openapi_spec, - description="Retrieve weather information for a location using wttr.in service", - auth=auth, - ) - - openapi_countries = OpenApiTool( - name="get_country_info", - spec=countries_openapi_spec, - description="Retrieve country information including population and capital city", - auth=auth, - ) - - # 4. Create an agent with OpenAPI tools - # Note: We need to pass the Azure AI native OpenApiTool definitions directly - # since the agent framework doesn't have a HostedOpenApiTool wrapper yet - agent = await provider.create_agent( - name="OpenAPIAgent", - instructions=( - "You are a helpful assistant that can search for country information " - "and weather data using APIs. When asked about countries, use the country " - "API to find information. When asked about weather, use the weather API. " - "Provide clear, informative answers based on the API results." - ), - # Pass the raw tool definitions from Azure AI's OpenApiTool - tools=[*openapi_countries.definitions, *openapi_weather.definitions], - ) - - # 5. Simulate conversation with the agent maintaining session context - print("=== Azure AI Agent with OpenAPI Tools ===\n") - - # Create a session to maintain conversation context across multiple runs - session = agent.create_session() - - for user_input in USER_INPUTS: - print(f"User: {user_input}") - # Pass the session to maintain context across multiple agent.run() calls - response = await agent.run(user_input, session=session) - print(f"Agent: {response.text}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_response_format.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_response_format.py deleted file mode 100644 index 8fd4a7d365..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_response_format.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework.azure import AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import BaseModel, ConfigDict - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent Provider Response Format Example - -This sample demonstrates using AzureAIAgentsProvider with response_format -for structured outputs in two ways: -1. Setting default response_format at agent creation time (default_options) -2. Overriding response_format at runtime (options parameter in agent.run) -""" - - -class WeatherInfo(BaseModel): - """Structured weather information.""" - - location: str - temperature: int - conditions: str - recommendation: str - model_config = ConfigDict(extra="forbid") - - -class CityInfo(BaseModel): - """Structured city information.""" - - city_name: str - population: int - country: str - model_config = ConfigDict(extra="forbid") - - -async def main() -> None: - """Example of using response_format at creation time and runtime.""" - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - # Create agent with default response_format (WeatherInfo) - agent = await provider.create_agent( - name="StructuredReporter", - instructions="Return structured JSON based on the requested format.", - default_options={"response_format": WeatherInfo}, - ) - - # Request 1: Uses default response_format from agent creation - print("--- Request 1: Using default response_format (WeatherInfo) ---") - query1 = "What's the weather like in Paris today?" - print(f"User: {query1}") - - result1 = await agent.run(query1) - - try: - weather = result1.value - print("Agent:") - print(f" Location: {weather.location}") - print(f" Temperature: {weather.temperature}") - print(f" Conditions: {weather.conditions}") - print(f" Recommendation: {weather.recommendation}") - except Exception: - print(f"Failed to parse response: {result1.text}") - - # Request 2: Override response_format at runtime with CityInfo - print("\n--- Request 2: Runtime override with CityInfo ---") - query2 = "Tell me about Tokyo." - print(f"User: {query2}") - - result2 = await agent.run(query2, options={"response_format": CityInfo}) - - try: - city = result2.value - print("Agent:") - print(f" City: {city.city_name}") - print(f" Population: {city.population}") - print(f" Country: {city.country}") - except Exception: - print(f"Failed to parse response: {result2.text}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_session.py b/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_session.py deleted file mode 100644 index 7ea7e4b5db..0000000000 --- a/python/samples/02-agents/providers/azure_ai_agent/azure_ai_with_session.py +++ /dev/null @@ -1,170 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import AgentSession, tool -from agent_framework.azure import AzureAIAgentsProvider -from azure.identity.aio import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure AI Agent with Session Management Example - -This sample demonstrates session management with Azure AI Agents, comparing -automatic session creation with explicit session management for persistent context. -""" - - -# 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." - - -async def example_with_automatic_session_creation() -> None: - """Example showing automatic session creation (service-managed session).""" - print("=== Automatic Session Creation Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # First conversation - no session provided, will be created automatically - first_query = "What's the weather like in Seattle?" - print(f"User: {first_query}") - first_result = await agent.run(first_query) - print(f"Agent: {first_result.text}") - - # Second conversation - still no session provided, will create another new session - second_query = "What was the last city I asked about?" - print(f"\nUser: {second_query}") - second_result = await agent.run(second_query) - print(f"Agent: {second_result.text}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") - - -async def example_with_session_persistence() -> None: - """Example showing session persistence across multiple conversations.""" - print("=== Session Persistence Example ===") - print("Using the same session across multiple conversations to maintain context.\n") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a new session that will be reused - session = agent.create_session() - - # First conversation - first_query = "What's the weather like in Tokyo?" - print(f"User: {first_query}") - first_result = await agent.run(first_query, session=session) - print(f"Agent: {first_result.text}") - - # Second conversation using the same session - maintains context - second_query = "How about London?" - print(f"\nUser: {second_query}") - second_result = await agent.run(second_query, session=session) - print(f"Agent: {second_result.text}") - - # Third conversation - agent should remember both previous cities - third_query = "Which of the cities I asked about has better weather?" - print(f"\nUser: {third_query}") - third_result = await agent.run(third_query, session=session) - print(f"Agent: {third_result.text}") - print("Note: The agent remembers context from previous messages in the same session.\n") - - -async def example_with_existing_session_id() -> None: - """Example showing how to work with an existing session ID from the service.""" - print("=== Existing Session ID Example ===") - print("Using a specific session ID to continue an existing conversation.\n") - - # First, create a conversation and capture the session ID - existing_session_id = None - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Start a conversation and get the session ID - session = agent.create_session() - first_query = "What's the weather in Paris?" - print(f"User: {first_query}") - first_result = await agent.run(first_query, session=session) - print(f"Agent: {first_result.text}") - - # The session ID is set after the first response - existing_session_id = session.service_session_id - print(f"Session ID: {existing_session_id}") - - if existing_session_id: - print("\n--- Continuing with the same session ID in a new agent instance ---") - - # Create a new provider and agent but use the existing session ID - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) - - second_query = "What was the last city I asked about?" - print(f"User: {second_query}") - second_result = await agent.run(second_query, session=session) - print(f"Agent: {second_result.text}") - print("Note: The agent continues the conversation from the previous session.\n") - - -async def main() -> None: - print("=== Azure AI Chat Client Agent Session Management Examples ===\n") - - await example_with_automatic_session_creation() - await example_with_session_persistence() - await example_with_existing_session_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_openai/README.md b/python/samples/02-agents/providers/azure_openai/README.md deleted file mode 100644 index 6971183ccf..0000000000 --- a/python/samples/02-agents/providers/azure_openai/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Azure OpenAI Agent Examples - -This folder contains examples demonstrating different ways to create and use agents with the different Azure OpenAI chat client from the `agent_framework.azure` package. - -## Examples - -| File | Description | -|------|-------------| -| [`azure_assistants_basic.py`](azure_assistants_basic.py) | The simplest way to create an agent using `Agent` with `AzureOpenAIAssistantsClient`. Shows both streaming and non-streaming responses with automatic assistant creation and cleanup. | -| [`azure_assistants_with_code_interpreter.py`](azure_assistants_with_code_interpreter.py) | Shows how to use `AzureOpenAIAssistantsClient.get_code_interpreter_tool()` with Azure agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | -| [`azure_assistants_with_existing_assistant.py`](azure_assistants_with_existing_assistant.py) | Shows how to work with a pre-existing assistant by providing the assistant ID to the Azure Assistants client. Demonstrates proper cleanup of manually created assistants. | -| [`azure_assistants_with_explicit_settings.py`](azure_assistants_with_explicit_settings.py) | Shows how to initialize an agent with a specific assistants client, configuring settings explicitly including endpoint and deployment name. | -| [`azure_assistants_with_function_tools.py`](azure_assistants_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`azure_assistants_with_session.py`](azure_assistants_with_session.py) | Demonstrates session management with Azure agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`azure_chat_client_basic.py`](azure_chat_client_basic.py) | The simplest way to create an agent using `Agent` with `AzureOpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with Azure OpenAI models. | -| [`azure_chat_client_with_explicit_settings.py`](azure_chat_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific chat client, configuring settings explicitly including endpoint and deployment name. | -| [`azure_chat_client_with_function_tools.py`](azure_chat_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`azure_chat_client_with_session.py`](azure_chat_client_with_session.py) | Demonstrates session management with Azure agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`azure_responses_client_basic.py`](azure_responses_client_basic.py) | The simplest way to create an agent using `Agent` with `AzureOpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with Azure OpenAI models. | -| [`azure_responses_client_code_interpreter_files.py`](azure_responses_client_code_interpreter_files.py) | Demonstrates using `AzureOpenAIResponsesClient.get_code_interpreter_tool()` with file uploads for data analysis. Shows how to create, upload, and analyze CSV files using Python code execution with Azure OpenAI Responses. | -| [`azure_responses_client_image_analysis.py`](azure_responses_client_image_analysis.py) | Shows how to use Azure OpenAI Responses for image analysis and vision tasks. Demonstrates multi-modal messages combining text and image content using remote URLs. | -| [`azure_responses_client_with_code_interpreter.py`](azure_responses_client_with_code_interpreter.py) | Shows how to use `AzureOpenAIResponsesClient.get_code_interpreter_tool()` with Azure agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. | -| [`azure_responses_client_with_explicit_settings.py`](azure_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including endpoint and deployment name. | -| [`azure_responses_client_with_file_search.py`](azure_responses_client_with_file_search.py) | Demonstrates using `AzureOpenAIResponsesClient.get_file_search_tool()` with Azure OpenAI Responses Client for direct document-based question answering and information retrieval from vector stores. | -| [`azure_responses_client_with_foundry.py`](azure_responses_client_with_foundry.py) | Shows how to create an agent using an Azure AI Foundry project endpoint instead of a direct Azure OpenAI endpoint. Requires the `azure-ai-projects` package. | -| [`azure_responses_client_with_function_tools.py`](azure_responses_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`azure_responses_client_with_hosted_mcp.py`](azure_responses_client_with_hosted_mcp.py) | Shows how to integrate Azure OpenAI Responses Client with hosted Model Context Protocol (MCP) servers using `AzureOpenAIResponsesClient.get_mcp_tool()` for extended functionality. | -| [`azure_responses_client_with_local_mcp.py`](azure_responses_client_with_local_mcp.py) | Shows how to integrate Azure OpenAI Responses Client with local Model Context Protocol (MCP) servers using MCPStreamableHTTPTool for extended functionality. | -| [`azure_responses_client_with_session.py`](azure_responses_client_with_session.py) | Demonstrates session management with Azure agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | - -## Environment Variables - -Make sure to set the following environment variables before running the examples: - -- `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint -- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat model deployment -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI Responses deployment - -For the Foundry project sample (`azure_responses_client_with_foundry.py`), also set: -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint - -Optionally, you can set: -- `AZURE_OPENAI_API_VERSION`: The API version to use (default is `2024-02-15-preview`) -- `AZURE_OPENAI_API_KEY`: Your Azure OpenAI API key (if not using `AzureCliCredential`) -- `AZURE_OPENAI_BASE_URL`: Your Azure OpenAI base URL (if different from the endpoint) - -## Authentication - -All examples use `AzureCliCredential` for authentication. Run `az login` in your terminal before running the examples, or replace `AzureCliCredential` with your preferred authentication method. - -## Required role-based access control (RBAC) roles - -To access the Azure OpenAI API, your Azure account or service principal needs one of the following RBAC roles assigned to the Azure OpenAI resource: - -- **Cognitive Services OpenAI User**: Provides read access to Azure OpenAI resources and the ability to call the inference APIs. This is the minimum role required for running these examples. -- **Cognitive Services OpenAI Contributor**: Provides full access to Azure OpenAI resources, including the ability to create, update, and delete deployments and models. - -For most scenarios, the **Cognitive Services OpenAI User** role is sufficient. You can assign this role through the Azure portal under the Azure OpenAI resource's "Access control (IAM)" section. - -For more detailed information about Azure OpenAI RBAC roles, see: [Role-based access control for Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/role-based-access-control) diff --git a/python/samples/02-agents/providers/azure_openai/azure_assistants_basic.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_basic.py deleted file mode 100644 index a1e61be0e8..0000000000 --- a/python/samples/02-agents/providers/azure_openai/azure_assistants_basic.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureOpenAIAssistantsClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure OpenAI Assistants Basic Example - -This sample demonstrates basic usage of AzureOpenAIAssistantsClient with automatic -assistant lifecycle management, showing both streaming and non-streaming responses. -""" - - -# 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." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - # Since no assistant ID is provided, the assistant will be automatically created - # and deleted after getting a response - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()).as_agent( - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - # Since no assistant ID is provided, the assistant will be automatically created - # and deleted after getting a response - async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()).as_agent( - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - - -async def main() -> None: - print("=== Basic Azure OpenAI Assistants Chat Client Agent Example ===") - - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_code_interpreter.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_code_interpreter.py deleted file mode 100644 index c1bbd54e20..0000000000 --- a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_code_interpreter.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio - -from agent_framework import Agent, AgentResponseUpdate, ChatResponseUpdate -from agent_framework.azure import AzureOpenAIAssistantsClient -from dotenv import load_dotenv -from openai.types.beta.threads.runs import ( - CodeInterpreterToolCallDelta, - RunStepDelta, - RunStepDeltaEvent, - ToolCallDeltaObject, -) -from openai.types.beta.threads.runs.code_interpreter_tool_call_delta import CodeInterpreter - -# Load environment variables from .env file -load_dotenv() - -""" -Azure OpenAI Assistants with Code Interpreter Example - -This sample demonstrates using get_code_interpreter_tool() with Azure OpenAI Assistants -for Python code execution and mathematical problem solving. -""" - - -def get_code_interpreter_chunk(chunk: AgentResponseUpdate) -> str | None: - """Helper method to access code interpreter data.""" - if ( - isinstance(chunk.raw_representation, ChatResponseUpdate) - and isinstance(chunk.raw_representation.raw_representation, RunStepDeltaEvent) - and isinstance(chunk.raw_representation.raw_representation.delta, RunStepDelta) - and isinstance(chunk.raw_representation.raw_representation.delta.step_details, ToolCallDeltaObject) - and chunk.raw_representation.raw_representation.delta.step_details.tool_calls - ): - for tool_call in chunk.raw_representation.raw_representation.delta.step_details.tool_calls: - if ( - isinstance(tool_call, CodeInterpreterToolCallDelta) - and isinstance(tool_call.code_interpreter, CodeInterpreter) - and tool_call.code_interpreter.input is not None - ): - return tool_call.code_interpreter.input - return None - - -async def main() -> None: - """Example showing how to use the code interpreter tool with Azure OpenAI Assistants.""" - print("=== Azure OpenAI Assistants Agent with Code Interpreter Example ===") - - # Create code interpreter tool using static method - client = AzureOpenAIAssistantsClient() - code_interpreter_tool = client.get_code_interpreter_tool() - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with Agent( - client=client, - instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=[code_interpreter_tool], - ) as agent: - query = "What is current datetime?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - generated_code = "" - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - code_interpreter_chunk = get_code_interpreter_chunk(chunk) - if code_interpreter_chunk is not None: - generated_code += code_interpreter_chunk - - print(f"\nGenerated code:\n{generated_code}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_existing_assistant.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_existing_assistant.py deleted file mode 100644 index e110d52cb8..0000000000 --- a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_existing_assistant.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.azure import AzureOpenAIAssistantsClient -from azure.identity import AzureCliCredential, get_bearer_token_provider -from dotenv import load_dotenv -from openai import AsyncAzureOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure OpenAI Assistants with Existing Assistant Example - -This sample demonstrates working with pre-existing Azure OpenAI Assistants -using existing assistant IDs rather than creating new ones. -""" - - -# 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." - - -async def main() -> None: - print("=== Azure OpenAI Assistants Chat Client with Existing Assistant ===") - - token_provider = get_bearer_token_provider(AzureCliCredential(), "https://cognitiveservices.azure.com/.default") - - client = AsyncAzureOpenAI( - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - azure_ad_token_provider=token_provider, - api_version="2025-01-01-preview", - ) - - # Create an assistant that will persist - created_assistant = await client.beta.assistants.create( - model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], name="WeatherAssistant" - ) - - try: - async with Agent( - client=AzureOpenAIAssistantsClient(async_client=client, assistant_id=created_assistant.id), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - result = await agent.run("What's the weather like in Tokyo?") - print(f"Result: {result}\n") - finally: - # Clean up the assistant manually - await client.beta.assistants.delete(created_assistant.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_explicit_settings.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_explicit_settings.py deleted file mode 100644 index 8d4ca0bc7f..0000000000 --- a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_explicit_settings.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.azure import AzureOpenAIAssistantsClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure OpenAI Assistants with Explicit Settings Example - -This sample demonstrates creating Azure OpenAI Assistants with explicit configuration -settings rather than relying on environment variable defaults. -""" - - -# 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." - - -async def main() -> None: - print("=== Azure Assistants Client with Explicit Settings ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with AzureOpenAIAssistantsClient( - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - result = await agent.run("What's the weather like in New York?") - print(f"Result: {result}\n") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_function_tools.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_function_tools.py deleted file mode 100644 index 1543a25f42..0000000000 --- a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_function_tools.py +++ /dev/null @@ -1,140 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from datetime import datetime, timezone -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.azure import AzureOpenAIAssistantsClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure OpenAI Assistants with Function Tools Example - -This sample demonstrates function tool integration with Azure OpenAI Assistants, -showing both agent-level and query-level tool configuration patterns. -""" - - -# 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." - - -@tool(approval_mode="never_require") -def get_time() -> str: - """Get the current UTC time.""" - current_time = datetime.now(timezone.utc) - return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." - - -async def tools_on_agent_level() -> None: - """Example showing tools defined when creating the agent.""" - print("=== Tools Defined on Agent Level ===") - - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that can provide weather and time information.", - tools=[get_weather, get_time], # Tools defined at agent creation - ) as agent: - # First query - agent can use weather tool - query1 = "What's the weather like in New York?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query - agent can use time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2}\n") - - # Third query - agent can use both tools if needed - query3 = "What's the weather in London and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3) - print(f"Agent: {result3}\n") - - -async def tools_on_run_level() -> None: - """Example showing tools passed to the run method.""" - print("=== Tools Passed to Run Method ===") - - # Agent created without tools - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant.", - # No tools defined here - ) as agent: - # First query with weather tool - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1, tools=[get_weather]) # Tool passed to run method - print(f"Agent: {result1}\n") - - # Second query with time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2, tools=[get_time]) # Different tool for this query - print(f"Agent: {result2}\n") - - # Third query with multiple tools - query3 = "What's the weather in Chicago and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3, tools=[get_weather, get_time]) # Multiple tools - print(f"Agent: {result3}\n") - - -async def mixed_tools_example() -> None: - """Example showing both agent-level tools and run-method tools.""" - print("=== Mixed Tools Example (Agent + Run Method) ===") - - # Agent created with some base tools - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a comprehensive assistant that can help with various information requests.", - tools=[get_weather], # Base tool available for all queries - ) as agent: - # Query using both agent tool and additional run-method tools - query = "What's the weather in Denver and what's the current UTC time?" - print(f"User: {query}") - - # Agent has access to get_weather (from creation) + additional tools from run method - result = await agent.run( - query, - tools=[get_time], # Additional tools for this specific query - ) - print(f"Agent: {result}\n") - - -async def main() -> None: - print("=== Azure OpenAI Assistants Chat Client Agent with Function Tools Examples ===\n") - - await tools_on_agent_level() - await tools_on_run_level() - await mixed_tools_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_session.py b/python/samples/02-agents/providers/azure_openai/azure_assistants_with_session.py deleted file mode 100644 index 4f3e952d86..0000000000 --- a/python/samples/02-agents/providers/azure_openai/azure_assistants_with_session.py +++ /dev/null @@ -1,150 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from random import randint -from typing import Annotated - -from agent_framework import Agent, AgentSession, tool -from agent_framework.azure import AzureOpenAIAssistantsClient -from azure.identity import AzureCliCredential -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -Azure OpenAI Assistants with Session Management Example - -This sample demonstrates session management with Azure OpenAI Assistants, comparing -automatic session creation with explicit session management for persistent context. -""" - - -# 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." - - -async def example_with_automatic_session_creation() -> None: - """Example showing automatic session creation (service-managed session).""" - print("=== Automatic Session Creation Example ===") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # First conversation - no session provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no session provided, will create another new session - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") - - -async def example_with_session_persistence() -> None: - """Example showing session persistence across multiple conversations.""" - print("=== Session Persistence Example ===") - print("Using the same session across multiple conversations to maintain context.\n") - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # Create a new session that will be reused - session = agent.create_session() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # Second conversation using the same session - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, session=session) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, session=session) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same session.\n") - - -async def example_with_existing_session_id() -> None: - """Example showing how to work with an existing session ID from the service.""" - print("=== Existing Session ID Example ===") - print("Using a specific session ID to continue an existing conversation.\n") - - # First, create a conversation and capture the session ID - existing_session_id = None - - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. - async with Agent( - client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # Start a conversation and get the session ID - session = agent.create_session() - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # The session ID is set after the first response - existing_session_id = session.service_session_id - print(f"Session ID: {existing_session_id}") - - if existing_session_id: - print("\n--- Continuing with the same session ID in a new agent instance ---") - - # Create a new agent instance but use the existing session ID - async with Agent( - client=AzureOpenAIAssistantsClient(thread_id=existing_session_id, credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) as agent: - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent.run(query2, session=session) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous session.\n") - - -async def main() -> None: - print("=== Azure OpenAI Assistants Chat Client Agent Session Management Examples ===\n") - - await example_with_automatic_session_creation() - await example_with_session_persistence() - await example_with_existing_session_id() - - -if __name__ == "__main__": - asyncio.run(main()) 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/02-agents/providers/foundry/README.md b/python/samples/02-agents/providers/foundry/README.md new file mode 100644 index 0000000000..80ddab2dcd --- /dev/null +++ b/python/samples/02-agents/providers/foundry/README.md @@ -0,0 +1,48 @@ +# Foundry Provider Samples + +This folder contains Azure AI Foundry and Foundry Local samples for Agent Framework. + +## FoundryAgent Samples + +| File | Description | +|------|-------------| +| [`foundry_agent_basic.py`](foundry_agent_basic.py) | Foundry Agent basic example | +| [`foundry_agent_custom_client.py`](foundry_agent_custom_client.py) | Foundry Agent custom client configuration | +| [`foundry_agent_hosted.py`](foundry_agent_hosted.py) | Foundry Agent for hosted agents | +| [`foundry_agent_with_env_vars.py`](foundry_agent_with_env_vars.py) | Foundry Agent using environment variables | +| [`foundry_agent_with_function_tools.py`](foundry_agent_with_function_tools.py) | Foundry Agent with local function tools | + +## FoundryChatClient Samples + +| File | Description | +|------|-------------| +| [`foundry_chat_client.py`](foundry_chat_client.py) | Foundry Chat Client with project endpoint example | +| [`foundry_chat_client_basic.py`](foundry_chat_client_basic.py) | Foundry Chat Client basic example | +| [`foundry_chat_client_code_interpreter_files.py`](foundry_chat_client_code_interpreter_files.py) | Foundry Chat Client with code interpreter and files | +| [`foundry_chat_client_image_analysis.py`](foundry_chat_client_image_analysis.py) | Foundry Chat Client with image analysis | +| [`foundry_chat_client_with_code_interpreter.py`](foundry_chat_client_with_code_interpreter.py) | Foundry Chat Client with code interpreter | +| [`foundry_chat_client_with_explicit_settings.py`](foundry_chat_client_with_explicit_settings.py) | Foundry Chat Client with explicit settings | +| [`foundry_chat_client_with_file_search.py`](foundry_chat_client_with_file_search.py) | Foundry Chat Client with file search | +| [`foundry_chat_client_with_function_tools.py`](foundry_chat_client_with_function_tools.py) | Foundry Chat Client with function tools | +| [`foundry_chat_client_with_hosted_mcp.py`](foundry_chat_client_with_hosted_mcp.py) | Foundry Chat Client with hosted MCP | +| [`foundry_chat_client_with_local_mcp.py`](foundry_chat_client_with_local_mcp.py) | Foundry Chat Client with local MCP | +| [`foundry_chat_client_with_session.py`](foundry_chat_client_with_session.py) | Foundry Chat Client with session management | + +## FoundryLocalClient Samples + +### Prerequisites + +1. Install Foundry Local and required local runtime components. +2. Install the connector package: + + ```bash + pip install agent-framework-foundry-local --pre + ``` + +| File | Description | +|------|-------------| +| [`foundry_local_agent.py`](foundry_local_agent.py) | Basic Foundry Local agent usage with streaming and non-streaming responses, plus function tool calling. | + +### Environment Variables + +- `FOUNDRY_LOCAL_MODEL_ID`: Optional model alias/ID to use by default when `model_id` is not passed to `FoundryLocalClient`. diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_basic.py b/python/samples/02-agents/providers/foundry/foundry_agent_basic.py new file mode 100644 index 0000000000..4c896e6803 --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_agent_basic.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework.foundry import FoundryAgent +from azure.identity import AzureCliCredential + +""" +Foundry Agent — Connect to a pre-configured agent in Microsoft Foundry + +This sample shows the simplest way to connect to an existing PromptAgent +in Azure AI Foundry and run it. The agent's instructions, model, and hosted +tools are all configured on the service — you just connect and run. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_AGENT_NAME — Name of the agent in Foundry + FOUNDRY_AGENT_VERSION — Version of the agent (for PromptAgents) +""" + + +async def main() -> None: + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + + result = await agent.run("What is the capital of France?") + print(f"Agent: {result}") + + # Streaming + print("Agent (streaming): ", end="", flush=True) + async for chunk in agent.run("Tell me a fun fact.", stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_custom_client.py b/python/samples/02-agents/providers/foundry/foundry_agent_custom_client.py new file mode 100644 index 0000000000..6ab3e44ee2 --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_agent_custom_client.py @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework.foundry import FoundryAgent, RawFoundryAgentChatClient +from azure.identity import AzureCliCredential + +""" +Foundry Agent — Custom client configuration + +This sample demonstrates three ways to customize the FoundryAgent client layer: + +1. Default: FoundryAgent creates a RawFoundryAgentChatClient (full middleware) internally +2. client_type: Pass RawFoundryAgentChatClient for no client middleware +3. Composition: Use Agent(client=RawFoundryAgentChatClient(...)) directly + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_AGENT_NAME — Name of the agent in Foundry + FOUNDRY_AGENT_VERSION — Version of the agent +""" + + +async def main() -> None: + # Option 1: Default — full middleware on both agent and client + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + result = await agent.run("Hello from the default setup!") + print(f"Default: {result}\n") + + # Option 2: Raw client — no client-level middleware (agent middleware still active) + agent_raw_client = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-agent", + agent_version="1.0", + credential=AzureCliCredential(), + client_type=RawFoundryAgentChatClient, + ) + result = await agent_raw_client.run("Hello from raw client!") + print(f"Raw client: {result}\n") + + # Option 3: Composition — use Agent(client=...) directly + # this will not run the checks that the `FoundryAgent` does on things like tools. + client = RawFoundryAgentChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + agent_composed = Agent(client=client) + result = await agent_composed.run("Hello from composed setup!") + print(f"Composed: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_hosted.py b/python/samples/02-agents/providers/foundry/foundry_agent_hosted.py new file mode 100644 index 0000000000..188e374af9 --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_agent_hosted.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework.foundry import FoundryAgent +from azure.identity import AzureCliCredential + +""" +Foundry Agent — Connect to a HostedAgent (no version needed) + +HostedAgents in Azure AI Foundry are pre-deployed agents that don't require +a version number. You only need the agent name to connect. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_AGENT_NAME — Name of the hosted agent +""" + + +async def main() -> None: + # HostedAgents don't need agent_version + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-hosted-agent", + credential=AzureCliCredential(), + ) + + result = await agent.run("Summarize the latest news about AI.") + print(f"Agent: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_with_env_vars.py b/python/samples/02-agents/providers/foundry/foundry_agent_with_env_vars.py new file mode 100644 index 0000000000..4b64025de4 --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_agent_with_env_vars.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework.foundry import FoundryAgent +from azure.identity import AzureCliCredential + +""" +Foundry Agent with Environment Variables + +This sample shows the recommended pattern for advanced samples that use +environment variables for configuration. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_AGENT_NAME — Name of the agent in Foundry + FOUNDRY_AGENT_VERSION — Version of the agent (optional, for PromptAgents) +""" + + +async def main() -> None: + agent = FoundryAgent( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + agent_name=os.environ["FOUNDRY_AGENT_NAME"], + agent_version=os.environ.get("FOUNDRY_AGENT_VERSION"), + credential=AzureCliCredential(), + ) + + session = agent.create_session() + + result = await agent.run("Hello! My name is Alice.", session=session) + print(f"Agent: {result}\n") + + result = await agent.run("What's my name?", session=session) + print(f"Agent: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/foundry_agent_with_function_tools.py b/python/samples/02-agents/providers/foundry/foundry_agent_with_function_tools.py new file mode 100644 index 0000000000..29277d563f --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_agent_with_function_tools.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import tool +from agent_framework.foundry import FoundryAgent +from azure.identity import AzureCliCredential +from pydantic import Field + +""" +Foundry Agent with Local Function Tools + +This sample shows how to connect to a Foundry agent and provide local function +tools. The Foundry agent must already have these tools defined in its configuration +(as declaration-only tools). The local implementations are matched by name. + +Only FunctionTool objects are accepted — hosted tools (code interpreter, file search, +web search, etc.) must be configured on the agent definition in the service. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_AGENT_NAME — Name of the agent in Foundry + FOUNDRY_AGENT_VERSION — Version of the agent +""" + + +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The city to get weather for.")], +) -> str: + """Get the current weather for a location.""" + return f"The weather in {location} is sunny, 22°C." + + +async def main() -> None: + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-weather-agent", + agent_version="1.0", + credential=AzureCliCredential(), + tools=[get_weather], + ) + + result = await agent.run("What's the weather in Paris?") + print(f"Agent: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_foundry.py b/python/samples/02-agents/providers/foundry/foundry_chat_client.py similarity index 60% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_foundry.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client.py index f7d78683f1..1e858b92d5 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_foundry.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client.py @@ -5,34 +5,31 @@ import os from random import randint from typing import Annotated -from agent_framework import tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv -from pydantic import Field # Load environment variables from .env file load_dotenv() """ -Azure OpenAI Responses Client with Foundry Project Example +Foundry Chat Client with Project Endpoint Example -This sample demonstrates how to create an AzureOpenAIResponsesClient using an -Azure AI Foundry project endpoint. Instead of providing an Azure OpenAI endpoint +This sample demonstrates how to create a FoundryChatClient using a +Foundry project endpoint. Instead of providing a service endpoint directly, you provide a Foundry project endpoint and the client is created via the Azure AI Foundry project SDK. This requires: -- The `azure-ai-projects` package to be installed. -- The `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint. -- The `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` environment variable set to the model deployment name. -""" # Load environment variables from .env file if present +- The `FOUNDRY_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint. +- The `FOUNDRY_MODEL` environment variable set to the model deployment name. +""" -# 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.")], + location: Annotated[str, "The location to get the weather for."], ) -> str: """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] @@ -43,15 +40,17 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - # 1. Create the AzureOpenAIResponsesClient using a Foundry project endpoint. + # 1. Create the FoundryChatClient using a Foundry project endpoint. # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. credential = AzureCliCredential() - agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=credential, - ).as_agent( + ) + agent = Agent( + client=_client, instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -67,15 +66,17 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - # 1. Create the AzureOpenAIResponsesClient using a Foundry project endpoint. + # 1. Create the FoundryChatClient using a Foundry project endpoint. # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. credential = AzureCliCredential() - agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=credential, - ).as_agent( + ) + agent = Agent( + client=_client, instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -91,7 +92,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Azure OpenAI Responses Client with Foundry Project Example ===") + print("=== Foundry Chat Client with Project Endpoint Example ===") await non_streaming_example() await streaming_example() @@ -103,7 +104,7 @@ if __name__ == "__main__": """ Sample output: -=== Azure OpenAI Responses Client with Foundry Project Example === +=== Foundry Chat Client with Project Endpoint Example === === Non-streaming Response Example === User: What's the weather like in Seattle? Result: The weather in Seattle is cloudy with a high of 18°C. diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_basic.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_basic.py similarity index 80% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_basic.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_basic.py index 73820443f3..3ea10e864f 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_basic.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_basic.py @@ -4,8 +4,8 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -14,10 +14,13 @@ from pydantic import Field load_dotenv() """ -Azure OpenAI Responses Client Basic Example +Foundry Chat Client Basic Example -This sample demonstrates basic usage of AzureOpenAIResponsesClient for structured +This sample demonstrates basic usage of FoundryChatClient for structured response generation, showing both streaming and non-streaming responses. + +This uses a deployed model in Foundry, with the Responses API endpoint of Foundry. +The client has full support for tools, response formats, etc. """ @@ -39,7 +42,8 @@ async def non_streaming_example() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = AzureOpenAIResponsesClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -56,7 +60,8 @@ async def streaming_example() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = AzureOpenAIResponsesClient(credential=AzureCliCredential()).as_agent( + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -71,7 +76,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Basic Azure OpenAI Responses Client Agent Example ===") + print("=== Foundry Chat Client Basic Example ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_code_interpreter_files.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_code_interpreter_files.py similarity index 83% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_code_interpreter_files.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_code_interpreter_files.py index eb81f941b8..1e156da17c 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_code_interpreter_files.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_code_interpreter_files.py @@ -5,7 +5,7 @@ import os import tempfile from agent_framework import Agent -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from openai import AsyncAzureOpenAI @@ -14,9 +14,9 @@ from openai import AsyncAzureOpenAI load_dotenv() """ -Azure OpenAI Responses Client with Code Interpreter and Files Example +Foundry Chat Client with Code Interpreter and Files Example -This sample demonstrates using get_code_interpreter_tool() with Azure OpenAI Responses +This sample demonstrates using get_code_interpreter_tool() with Responses on Foundry for Python code execution and data analysis with uploaded files. """ @@ -24,7 +24,7 @@ for Python code execution and data analysis with uploaded files. async def create_sample_file_and_upload(openai_client: AsyncAzureOpenAI) -> tuple[str, str]: - """Create a sample CSV file and upload it to Azure OpenAI.""" + """Create a sample CSV file and upload it for Foundry code interpreter use.""" csv_data = """name,department,salary,years_experience Alice Johnson,Engineering,95000,5 Bob Smith,Sales,75000,3 @@ -39,8 +39,8 @@ Frank Wilson,Engineering,88000,6 temp_file.write(csv_data) temp_file_path = temp_file.name - # Upload file to Azure OpenAI - print("Uploading file to Azure OpenAI...") + # Upload file for the code interpreter tool + print("Uploading file for code interpreter...") with open(temp_file_path, "rb") as file: uploaded_file = await openai_client.files.create( file=file, @@ -63,9 +63,9 @@ async def cleanup_files(openai_client: AsyncAzureOpenAI, temp_file_path: str, fi async def main() -> None: - print("=== Azure OpenAI Code Interpreter with File Upload ===") + print("=== Foundry Chat Client with Code Interpreter and File Upload ===") - # Initialize Azure OpenAI client for file operations + # Initialize the underlying OpenAI client for file operations credential = AzureCliCredential() async def get_token(): @@ -79,8 +79,8 @@ async def main() -> None: temp_file_path, file_id = await create_sample_file_and_upload(openai_client) - # Create agent using Azure OpenAI Responses client - client = AzureOpenAIResponsesClient(credential=credential) + # Create agent using FoundryChatClient + client = FoundryChatClient(credential=credential) # Create code interpreter tool with file access code_interpreter_tool = client.get_code_interpreter_tool(file_ids=[file_id]) diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_image_analysis.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_image_analysis.py similarity index 64% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_image_analysis.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_image_analysis.py index 5066c6c832..c6d95d2fca 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_image_analysis.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_image_analysis.py @@ -2,8 +2,8 @@ import asyncio -from agent_framework import Content -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, Content +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -11,18 +11,19 @@ from dotenv import load_dotenv load_dotenv() """ -Azure OpenAI Responses Client with Image Analysis Example +Foundry Chat Client with Image Analysis Example -This sample demonstrates using Azure OpenAI Responses for image analysis and vision tasks, +This sample demonstrates using FoundryChatClient for image analysis and vision tasks, showing multi-modal messages combining text and image content. """ async def main(): - print("=== Azure Responses Agent with Image Analysis ===") + print("=== Foundry Chat Client with Image Analysis ===") - # 1. Create an Azure Responses agent with vision capabilities - agent = AzureOpenAIResponsesClient(credential=AzureCliCredential()).as_agent( + # 1. Create a Foundry-backed agent with vision capabilities + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), name="VisionAgent", instructions="You are a image analysist, you get a image and need to respond with what you see in the picture.", ) diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_code_interpreter.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_code_interpreter.py similarity index 84% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_code_interpreter.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_with_code_interpreter.py index 8862f81741..96907e5d41 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_code_interpreter.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_code_interpreter.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Agent, ChatResponse -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from openai.types.responses.response import Response as OpenAIResponse @@ -13,20 +13,20 @@ from openai.types.responses.response_code_interpreter_tool_call import ResponseC load_dotenv() """ -Azure OpenAI Responses Client with Code Interpreter Example +Foundry Chat Client with Code Interpreter Example -This sample demonstrates using get_code_interpreter_tool() with Azure OpenAI Responses +This sample demonstrates using get_code_interpreter_tool() with FoundryChatClient for Python code execution and mathematical problem solving. """ async def main() -> None: - """Example showing how to use the code interpreter tool with Azure OpenAI Responses.""" - print("=== Azure OpenAI Responses Agent with Code Interpreter Example ===") + """Example showing how to use the code interpreter tool with FoundryChatClient.""" + print("=== Foundry Chat Client with Code Interpreter Example ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + client = FoundryChatClient(credential=AzureCliCredential()) # Create code interpreter tool using instance method code_interpreter_tool = client.get_code_interpreter_tool() diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_explicit_settings.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_explicit_settings.py similarity index 69% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_explicit_settings.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_with_explicit_settings.py index f31efaa611..6100df27a0 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_explicit_settings.py @@ -5,8 +5,8 @@ import os from random import randint from typing import Annotated -from agent_framework import tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -15,10 +15,10 @@ from pydantic import Field load_dotenv() """ -Azure OpenAI Responses Client with Explicit Settings Example +Foundry Chat Client with Explicit Settings Example -This sample demonstrates creating Azure OpenAI Responses Client with explicit configuration -settings rather than relying on environment variable defaults. +This sample demonstrates creating FoundryChatClient with explicit project endpoint and +model settings rather than relying on environment variable defaults. """ @@ -35,17 +35,19 @@ def get_weather( async def main() -> None: - print("=== Azure Responses Client with Explicit Settings ===") + print("=== Foundry Chat Client with Explicit Settings ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - agent = AzureOpenAIResponsesClient( - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + _client = FoundryChatClient( + model=os.environ["FOUNDRY_MODEL"], + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=AzureCliCredential(), - ).as_agent( + ) + agent = Agent( + client=_client, instructions="You are a helpful weather agent.", - tools=get_weather, + tools=[get_weather], ) result = await agent.run("What's the weather like in New York?") diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_file_search.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_file_search.py similarity index 75% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_file_search.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_with_file_search.py index f3fc3c852c..5c4790ec71 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_file_search.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_file_search.py @@ -4,7 +4,7 @@ import asyncio import contextlib from agent_framework import Agent -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -12,22 +12,22 @@ from dotenv import load_dotenv load_dotenv() """ -Azure OpenAI Responses Client with File Search Example +Foundry Chat Client with File Search Example -This sample demonstrates using get_file_search_tool() with Azure OpenAI Responses Client +This sample demonstrates using get_file_search_tool() with FoundryChatClient for direct document-based question answering and information retrieval. Prerequisites: - Set environment variables: - - AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint URL - - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: Your Responses API deployment name + - FOUNDRY_PROJECT_ENDPOINT: Your Foundry project endpoint URL + - FOUNDRY_MODEL: Your Responses API deployment name - Authenticate via 'az login' for AzureCliCredential """ # Helper functions -async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str, str]: +async def create_vector_store(client: FoundryChatClient) -> tuple[str, str]: """Create a vector store with sample documents.""" file = await client.client.files.create( file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="assistants" @@ -43,7 +43,7 @@ async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str, return file.id, vector_store.id -async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str, vector_store_id: str) -> None: +async def delete_vector_store(client: FoundryChatClient, file_id: str, vector_store_id: str) -> None: """Delete the vector store after using it.""" with contextlib.suppress(Exception): await client.client.vector_stores.delete(vector_store_id=vector_store_id) @@ -52,11 +52,11 @@ async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str, async def main() -> None: - print("=== Azure OpenAI Responses Client with File Search Example ===\n") + print("=== Foundry Chat Client with File Search Example ===\n") - # Initialize Responses client + # Initialize the Foundry chat client # Make sure you're logged in via 'az login' before running this sample - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + client = FoundryChatClient(credential=AzureCliCredential()) file_id, vector_store_id = await create_vector_store(client) diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_function_tools.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_function_tools.py similarity index 90% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_function_tools.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_with_function_tools.py index 1ebda9ce37..ff4f6b84da 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_function_tools.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_function_tools.py @@ -6,7 +6,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -15,9 +15,9 @@ from pydantic import Field load_dotenv() """ -Azure OpenAI Responses Client with Function Tools Example +Foundry Chat Client with Function Tools Example -This sample demonstrates function tool integration with Azure OpenAI Responses Client, +This sample demonstrates function tool integration with FoundryChatClient, showing both agent-level and query-level tool configuration patterns. """ @@ -50,7 +50,7 @@ async def tools_on_agent_level() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -82,7 +82,7 @@ async def tools_on_run_level() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", # No tools defined here ) @@ -114,7 +114,7 @@ async def mixed_tools_example() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) @@ -132,7 +132,7 @@ async def mixed_tools_example() -> None: async def main() -> None: - print("=== Azure OpenAI Responses Client Agent with Function Tools Examples ===\n") + print("=== Foundry Chat Client with Function Tools Examples ===\n") await tools_on_agent_level() await tools_on_run_level() diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_hosted_mcp.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py similarity index 95% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_hosted_mcp.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py index f81bfc83c7..214ff005e9 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_hosted_mcp.py @@ -4,7 +4,7 @@ import asyncio from typing import TYPE_CHECKING, Any from agent_framework import Agent -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -12,10 +12,10 @@ from dotenv import load_dotenv load_dotenv() """ -Azure OpenAI Responses Client with Hosted MCP Example +Foundry Chat Client with Hosted MCP Example This sample demonstrates integrating hosted Model Context Protocol (MCP) tools with -Azure OpenAI Responses Client, including user approval workflows for function call security. +FoundryChatClient, including user approval workflows for function call security. """ if TYPE_CHECKING: @@ -103,7 +103,7 @@ async def run_hosted_mcp_without_session_and_specific_approval() -> None: """Example showing Mcp Tools with approvals without using a session.""" print("=== Mcp with approvals and without session ===") credential = AzureCliCredential() - client = AzureOpenAIResponsesClient(credential=credential) + client = FoundryChatClient(credential=credential) # Create MCP tool with specific approval settings mcp_tool = client.get_mcp_tool( @@ -140,7 +140,7 @@ async def run_hosted_mcp_without_approval() -> None: """Example showing Mcp Tools without approvals.""" print("=== Mcp without approvals ===") credential = AzureCliCredential() - client = AzureOpenAIResponsesClient(credential=credential) + client = FoundryChatClient(credential=credential) # Create MCP tool without approval requirements mcp_tool = client.get_mcp_tool( @@ -178,7 +178,7 @@ async def run_hosted_mcp_with_session() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") credential = AzureCliCredential() - client = AzureOpenAIResponsesClient(credential=credential) + client = FoundryChatClient(credential=credential) # Create MCP tool with always require approval mcp_tool = client.get_mcp_tool( @@ -215,7 +215,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") credential = AzureCliCredential() - client = AzureOpenAIResponsesClient(credential=credential) + client = FoundryChatClient(credential=credential) # Create MCP tool with always require approval mcp_tool = client.get_mcp_tool( @@ -253,7 +253,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Hosted Mcp Tools Examples ===\n") + print("=== Foundry Chat Client with Hosted MCP Examples ===\n") await run_hosted_mcp_without_approval() await run_hosted_mcp_without_session_and_specific_approval() diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_local_mcp.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_local_mcp.py similarity index 66% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_local_mcp.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_with_local_mcp.py index e470d9bfe9..e06a5d6981 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_local_mcp.py @@ -4,7 +4,7 @@ import asyncio import os from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -12,9 +12,9 @@ from dotenv import load_dotenv load_dotenv() """ -Azure OpenAI Responses Client with local Model Context Protocol (MCP) Example +Foundry Chat Client with Local Model Context Protocol (MCP) Example -This sample demonstrates integration of Azure OpenAI Responses Client with local Model Context Protocol (MCP) +This sample demonstrates integration of FoundryChatClient with local Model Context Protocol (MCP) servers. """ @@ -24,24 +24,24 @@ servers. MCP_NAME = os.environ.get("MCP_NAME", "Microsoft Learn MCP") # example name MCP_URL = os.environ.get("MCP_URL", "https://learn.microsoft.com/api/mcp") # example endpoint -# Environment variables for Azure OpenAI Responses authentication -# AZURE_OPENAI_ENDPOINT="" -# AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="" -# AZURE_OPENAI_API_VERSION="" # e.g. "2025-03-01-preview" +# Environment variables for FoundryChatClient authentication +# FOUNDRY_PROJECT_ENDPOINT="" +# FOUNDRY_MODEL="" async def main(): - """Example showing local MCP tools for a Azure OpenAI Responses Agent.""" + """Example showing local MCP tools for a Foundry Chat Client agent.""" # AuthN: use Azure CLI credential = AzureCliCredential() - # Build an agent backed by Azure OpenAI Responses - # (endpoint/deployment/api_version can also come from env vars above) - responses_client = AzureOpenAIResponsesClient( + # Build an agent backed by FoundryChatClient + # (project endpoint and model can also come from env vars above) + responses_client = FoundryChatClient( credential=credential, ) - agent: Agent = responses_client.as_agent( + agent: Agent = Agent( + client=responses_client, name="DocsAgent", instructions=("You are a helpful assistant that can help with Microsoft documentation questions."), ) diff --git a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_session.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_session.py similarity index 56% rename from python/samples/02-agents/providers/azure_openai/azure_responses_client_with_session.py rename to python/samples/02-agents/providers/foundry/foundry_chat_client_with_session.py index b7ed1d4011..f63bebdc9a 100644 --- a/python/samples/02-agents/providers/azure_openai/azure_responses_client_with_session.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_session.py @@ -4,8 +4,8 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import Agent, AgentSession, tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, AgentSession, InMemoryHistoryProvider, tool +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -14,9 +14,9 @@ from pydantic import Field load_dotenv() """ -Azure OpenAI Responses Client with Session Management Example +Foundry Chat Client with Session Management Example -This sample demonstrates session management with Azure OpenAI Responses Client, comparing +This sample demonstrates session management with FoundryChatClient, comparing automatic session creation with explicit session management for persistent context. """ @@ -34,13 +34,13 @@ def get_weather( async def example_with_automatic_session_creation() -> None: - """Example showing automatic session creation.""" + """Example showing automatic session creation (service-managed session).""" print("=== Automatic Session Creation Example ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -59,17 +59,15 @@ async def example_with_automatic_session_creation() -> None: print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") -async def example_with_session_persistence_in_memory() -> None: - """ - Example showing session persistence across multiple conversations. - In this example, messages are stored in-memory. - """ - print("=== Session Persistence Example (In-Memory) ===") +async def example_with_session_persistence() -> None: + """Example showing session persistence across multiple conversations.""" + print("=== Session Persistence Example ===") + print("Using the same session across multiple conversations to maintain context.\n") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -97,62 +95,66 @@ async def example_with_session_persistence_in_memory() -> None: print("Note: The agent remembers context from previous messages in the same session.\n") -async def example_with_existing_session_id() -> None: - """ - Example showing how to work with an existing session ID from the service. - In this example, messages are stored on the server using Azure OpenAI conversation state. - """ - print("=== Existing Session ID Example ===") - - # First, create a conversation and capture the session ID - existing_session_id = None +async def example_with_existing_session_messages() -> None: + """Example showing how to work with existing session messages for Foundry-backed agents.""" + print("=== Existing Session Messages Example ===") # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), instructions="You are a helpful weather agent.", tools=get_weather, ) - # Start a conversation and get the session ID + # Start a conversation and build up message history session = agent.create_session() query1 = "What's the weather in Paris?" print(f"User: {query1}") - # Enable Azure OpenAI conversation state by setting `store` parameter to True - result1 = await agent.run(query1, session=session, store=True) + result1 = await agent.run(query1, session=session) print(f"Agent: {result1.text}") - # The session ID is set after the first response - existing_session_id = session.service_session_id - print(f"Session ID: {existing_session_id}") + # The session now contains the conversation history in state + memory_state = session.state.get(InMemoryHistoryProvider.DEFAULT_SOURCE_ID, {}) + messages = memory_state.get("messages", []) + if messages: + print(f"Session contains {len(messages)} messages") - if existing_session_id: - print("\n--- Continuing with the same session ID in a new agent instance ---") + print("\n--- Continuing with the same session in a new agent instance ---") - agent = Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) + # Create a new agent instance but use the existing session with its message history + new_agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) + # Use the same session object which contains the conversation history + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await new_agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation using the local message history.\n") - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent.run(query2, session=session, store=True) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous session by using session ID.\n") + print("\n--- Alternative: Creating a new session from existing messages ---") + + # You can also create a new session from existing messages + new_session = AgentSession() + + query3 = "How does the Paris weather compare to London?" + print(f"User: {query3}") + result3 = await new_agent.run(query3, session=new_session) + print(f"Agent: {result3.text}") + print("Note: This creates a new session with the same conversation history.\n") async def main() -> None: - print("=== Azure OpenAI Response Client Agent Session Management Examples ===\n") + print("=== Foundry Chat Client Session Management Examples ===\n") await example_with_automatic_session_creation() - await example_with_session_persistence_in_memory() - await example_with_existing_session_id() + await example_with_session_persistence() + await example_with_existing_session_messages() if __name__ == "__main__": diff --git a/python/samples/02-agents/providers/foundry_local/foundry_local_agent.py b/python/samples/02-agents/providers/foundry/foundry_local_agent.py similarity index 88% rename from python/samples/02-agents/providers/foundry_local/foundry_local_agent.py rename to python/samples/02-agents/providers/foundry/foundry_local_agent.py index 0ea0c15bc2..a758c75914 100644 --- a/python/samples/02-agents/providers/foundry_local/foundry_local_agent.py +++ b/python/samples/02-agents/providers/foundry/foundry_local_agent.py @@ -5,12 +5,10 @@ from __future__ import annotations import asyncio from random import randint -from typing import TYPE_CHECKING, Annotated +from typing import Annotated -from agent_framework.microsoft import FoundryLocalClient - -if TYPE_CHECKING: - from agent_framework import Agent +from agent_framework import Agent +from agent_framework.foundry import FoundryLocalClient """ This sample demonstrates basic usage of the FoundryLocalClient. @@ -59,15 +57,16 @@ async def streaming_example(agent: Agent) -> None: async def main() -> None: print("=== Basic Foundry Local Client Agent Example ===") - client = FoundryLocalClient(model_id="phi-4-mini") - print(f"Client Model ID: {client.model_id}\n") + client = FoundryLocalClient(model="phi-4-mini") + print(f"Client Model ID: {client.model}\n") print("Other available models (tool calling supported only):") for model in client.manager.list_catalog_models(): if model.supports_tool_calling: print( f"- {model.alias} for {model.task} - id={model.id} - {(model.file_size_mb / 1000):.2f} GB - {model.license}" ) - agent = client.as_agent( + agent = Agent( + client=client, name="LocalAgent", instructions="You are a helpful agent.", tools=get_weather, diff --git a/python/samples/02-agents/providers/foundry_local/README.md b/python/samples/02-agents/providers/foundry_local/README.md deleted file mode 100644 index 72451a9a69..0000000000 --- a/python/samples/02-agents/providers/foundry_local/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Foundry Local Examples - -This folder contains examples demonstrating how to run local models with `FoundryLocalClient` via `agent_framework.microsoft`. - -## Prerequisites - -1. Install Foundry Local and required local runtime components. -2. Install the connector package: - - ```bash - pip install agent-framework-foundry-local --pre - ``` - -## Examples - -| File | Description | -|------|-------------| -| [`foundry_local_agent.py`](foundry_local_agent.py) | Basic Foundry Local agent usage with streaming and non-streaming responses, plus function tool calling. | - -## Environment Variables - -- `FOUNDRY_LOCAL_MODEL_ID`: Optional model alias/ID to use by default when `model_id` is not passed to `FoundryLocalClient`. diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py index fbfdc34f43..145702591f 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py @@ -19,9 +19,7 @@ from copilot.generated.session_events import PermissionRequest from copilot.types import PermissionRequestResult -def prompt_permission( - request: PermissionRequest, context: dict[str, str] -) -> PermissionRequestResult: +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: """Permission handler that prompts the user for approval.""" print(f"\n[Permission Request: {request.kind}]") diff --git a/python/samples/02-agents/providers/ollama/ollama_agent_basic.py b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py index 28cad484c1..3652b069c4 100644 --- a/python/samples/02-agents/providers/ollama/ollama_agent_basic.py +++ b/python/samples/02-agents/providers/ollama/ollama_agent_basic.py @@ -3,7 +3,7 @@ import asyncio from datetime import datetime -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.ollama import OllamaChatClient from dotenv import load_dotenv @@ -36,7 +36,8 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = OllamaChatClient().as_agent( + agent = Agent( + client=OllamaChatClient(), name="TimeAgent", instructions="You are a helpful time agent answer in one sentence.", tools=get_time, @@ -52,7 +53,8 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = OllamaChatClient().as_agent( + agent = Agent( + client=OllamaChatClient(), name="TimeAgent", instructions="You are a helpful time agent answer in one sentence.", tools=get_time, diff --git a/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py b/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py index 97c24086a0..b23727311b 100644 --- a/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py +++ b/python/samples/02-agents/providers/ollama/ollama_agent_reasoning.py @@ -2,6 +2,7 @@ import asyncio +from agent_framework import Agent from agent_framework.ollama import OllamaChatClient from dotenv import load_dotenv @@ -24,7 +25,8 @@ https://ollama.com/ async def main() -> None: print("=== Response Reasoning Example ===") - agent = OllamaChatClient().as_agent( + agent = Agent( + client=OllamaChatClient(), name="TimeAgent", instructions="You are a helpful agent answer in one sentence.", default_options={"think": True}, # Enable Reasoning on agent level diff --git a/python/samples/02-agents/providers/ollama/ollama_with_openai_chat_client.py b/python/samples/02-agents/providers/ollama/ollama_with_openai_chat_client.py index 4069128346..9671ee234f 100644 --- a/python/samples/02-agents/providers/ollama/ollama_with_openai_chat_client.py +++ b/python/samples/02-agents/providers/ollama/ollama_with_openai_chat_client.py @@ -5,7 +5,7 @@ import os from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv @@ -41,14 +41,16 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = OpenAIChatClient( + _client = OpenAIChatClient( api_key="ollama", # Just a placeholder, Ollama doesn't require API key base_url=os.getenv("OLLAMA_ENDPOINT"), - model_id=os.getenv("OLLAMA_MODEL"), - ).as_agent( + model=os.getenv("OLLAMA_MODEL"), + ) + agent = Agent( + client=_client, name="WeatherAgent", instructions="You are a helpful weather agent.", - tools=get_weather, + tools=[get_weather], ) query = "What's the weather like in Seattle?" @@ -61,14 +63,16 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = OpenAIChatClient( + _client = OpenAIChatClient( api_key="ollama", # Just a placeholder, Ollama doesn't require API key base_url=os.getenv("OLLAMA_ENDPOINT"), - model_id=os.getenv("OLLAMA_MODEL"), - ).as_agent( + model=os.getenv("OLLAMA_MODEL"), + ) + agent = Agent( + client=_client, name="WeatherAgent", instructions="You are a helpful weather agent.", - tools=get_weather, + tools=[get_weather], ) query = "What's the weather like in Portland?" diff --git a/python/samples/02-agents/providers/openai/openai_assistants_basic.py b/python/samples/02-agents/providers/openai/openai_assistants_basic.py index 3631691474..5901b6ef38 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_basic.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_basic.py @@ -44,7 +44,7 @@ async def non_streaming_example() -> None: # Create a new assistant via the provider agent = await provider.create_agent( name="WeatherAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful weather agent.", tools=[get_weather], ) @@ -69,7 +69,7 @@ async def streaming_example() -> None: # Create a new assistant via the provider agent = await provider.create_agent( name="WeatherAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful weather agent.", tools=[get_weather], ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py b/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py index f479177928..0cc9d33f73 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py @@ -5,7 +5,7 @@ import os from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIAssistantProvider from dotenv import load_dotenv from openai import AsyncOpenAI @@ -46,7 +46,7 @@ async def create_agent_example() -> None: ): agent = await provider.create_agent( name="WeatherAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful weather assistant.", tools=[get_weather], ) @@ -69,7 +69,7 @@ async def get_agent_example() -> None: ): # Create an assistant directly with SDK (simulating pre-existing assistant) sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), name="ExistingAssistant", instructions="You always respond with 'Hello!'", ) @@ -86,7 +86,7 @@ async def get_agent_example() -> None: async def as_agent_example() -> None: - """Wrap an SDK Assistant object using provider.as_agent().""" + """Wrap an SDK Assistant object using Agent(client=provider, ...).""" print("\n--- as_agent() ---") async with ( @@ -95,14 +95,14 @@ async def as_agent_example() -> None: ): # Create assistant using SDK sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), name="WrappedAssistant", instructions="You respond with poetry.", ) try: # Wrap synchronously (no HTTP call) - agent = provider.as_agent(sdk_assistant) + agent = Agent(client=provider, agent=sdk_assistant) print(f"Wrapped: {agent.name} (ID: {agent.id})") result = await agent.run("Tell me about the sunset.") @@ -121,14 +121,14 @@ async def multiple_agents_example() -> None: ): weather_agent = await provider.create_agent( name="WeatherSpecialist", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a weather specialist.", tools=[get_weather], ) greeter_agent = await provider.create_agent( name="GreeterAgent", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a friendly greeter.", ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py b/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py index 9f43996fc4..044804e3c5 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py @@ -55,7 +55,7 @@ async def main() -> None: agent = await provider.create_agent( name="CodeHelper", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful assistant that can write and execute Python code to solve problems.", tools=[chat_client.get_code_interpreter_tool()], ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py b/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py index 5716779558..563dbb38a4 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py @@ -5,7 +5,7 @@ import os from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIAssistantProvider from dotenv import load_dotenv from openai import AsyncOpenAI @@ -43,7 +43,7 @@ async def example_get_agent_by_id() -> None: # Create an assistant via SDK (simulating an existing assistant) created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), name="WeatherAssistant", tools=[ { @@ -86,7 +86,7 @@ async def example_as_agent_wrap_sdk_object() -> None: # Create and fetch an assistant via SDK created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), name="SimpleAssistant", instructions="You are a friendly assistant.", ) @@ -94,8 +94,9 @@ async def example_as_agent_wrap_sdk_object() -> None: try: # Use as_agent() to wrap the SDK object - agent = provider.as_agent( - created_assistant, + agent = Agent( + client=provider, + agent=created_assistant, instructions="You are an extremely helpful assistant. Be enthusiastic!", ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py index 24dfa0e827..d7adef004c 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py @@ -43,7 +43,7 @@ async def main() -> None: agent = await provider.create_agent( name="WeatherAssistant", - model=os.environ["OPENAI_CHAT_MODEL_ID"], + model=os.environ["OPENAI_MODEL"], instructions="You are a helpful weather agent.", tools=[get_weather], ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py b/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py index ba6de333c5..ad67986d4e 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py @@ -50,7 +50,7 @@ async def main() -> None: agent = await provider.create_agent( name="SearchAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful assistant that searches files in a knowledge base.", tools=[chat_client.get_file_search_tool()], ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py b/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py index eebbb07b87..ffd64d9ca2 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py @@ -53,7 +53,7 @@ async def tools_on_agent_level() -> None: # The agent can use these tools for any query during its lifetime agent = await provider.create_agent( name="InfoAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -90,7 +90,7 @@ async def tools_on_run_level() -> None: # Agent created with base tools, additional tools can be passed at run time agent = await provider.create_agent( name="FlexibleAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful assistant.", tools=[get_weather], # Base tool ) @@ -127,7 +127,7 @@ async def mixed_tools_example() -> None: # Agent created with some base tools agent = await provider.create_agent( name="ComprehensiveAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py b/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py index 79dad58afe..740b36107d 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py @@ -50,7 +50,7 @@ async def main() -> None: # Create agent with default response_format (WeatherInfo) agent = await provider.create_agent( name="StructuredReporter", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="Return structured JSON based on the requested format.", default_options={"response_format": WeatherInfo}, ) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py b/python/samples/02-agents/providers/openai/openai_assistants_with_session.py index ba55904315..2259c5638d 100644 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py +++ b/python/samples/02-agents/providers/openai/openai_assistants_with_session.py @@ -43,7 +43,7 @@ async def example_with_automatic_session_creation() -> None: agent = await provider.create_agent( name="WeatherAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful weather agent.", tools=[get_weather], ) @@ -75,7 +75,7 @@ async def example_with_session_persistence() -> None: agent = await provider.create_agent( name="WeatherAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful weather agent.", tools=[get_weather], ) @@ -120,7 +120,7 @@ async def example_with_existing_session_id() -> None: agent = await provider.create_agent( name="WeatherAssistant", - model=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4"), + model=os.environ.get("OPENAI_MODEL", "gpt-4"), instructions="You are a helpful weather agent.", tools=[get_weather], ) diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py b/python/samples/02-agents/providers/openai/openai_chat_client_basic.py index 5b36460427..d2834fe1e9 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_basic.py @@ -4,7 +4,7 @@ import asyncio from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv @@ -35,7 +35,8 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, @@ -51,7 +52,8 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py index 4601557d40..6d4984c66e 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py @@ -5,7 +5,7 @@ import os from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -36,10 +36,13 @@ def get_weather( async def main() -> None: print("=== OpenAI Chat Client with Explicit Settings ===") - agent = OpenAIChatClient( - model_id=os.environ["OPENAI_CHAT_MODEL_ID"], + _client = OpenAIChatClient( + model=os.environ["OPENAI_MODEL"], api_key=os.environ["OPENAI_API_KEY"], - ).as_agent( + ) + + agent = Agent( + client=_client, instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py index bb06046fa3..00057dd76d 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py @@ -59,7 +59,8 @@ async def mcp_tools_on_agent_level() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime # The agent will connect to the MCP server through its context manager. - async with OpenAIChatClient().as_agent( + async with Agent( + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py index 8045da9e81..ba21d0a325 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py @@ -3,6 +3,7 @@ import asyncio import json +from agent_framework import Agent from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions from dotenv import load_dotenv @@ -36,7 +37,8 @@ runtime_schema = { async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") - agent = OpenAIChatClient[OpenAIChatOptions]().as_agent( + agent = Agent( + client=OpenAIChatClient[OpenAIChatOptions](), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -69,7 +71,8 @@ async def non_streaming_example() -> None: async def streaming_example() -> None: print("=== Streaming runtime JSON schema example ===") - agent = OpenAIChatClient().as_agent( + agent = Agent( + client=OpenAIChatClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py b/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py index 384bce5aa8..623dc25f43 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py @@ -18,7 +18,7 @@ for real-time information retrieval and current data access. async def main() -> None: - client = OpenAIChatClient(model_id="gpt-4o-search-preview") + client = OpenAIChatClient(model="gpt-4o-search-preview") # Create web search tool with location context web_search_tool = client.get_web_search_tool( diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py b/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py index 572a487563..82fee38455 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import Content +from agent_framework import Agent, Content from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -21,7 +21,8 @@ async def main(): print("=== OpenAI Responses Agent with Image Analysis ===") # 1. Create an OpenAI Responses agent with vision capabilities - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="VisionAgent", instructions="You are a image analysist, you get a image and need to respond with what you see in the picture.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py b/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py index 6ed7a48fd0..6e01a4dbbd 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py @@ -6,7 +6,7 @@ import tempfile import urllib.request as urllib_request from pathlib import Path -from agent_framework import Content +from agent_framework import Agent, Content from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -61,7 +61,8 @@ async def main() -> None: # Create an agent with customized image generation options client = OpenAIResponsesClient() - agent = client.as_agent( + agent = Agent( + client=client, instructions="You are a helpful AI that can generate images.", tools=[ client.get_image_generation_tool( diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py b/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py index 4e83347fab..a4fc3849b8 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py @@ -2,6 +2,7 @@ import asyncio +from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient, OpenAIResponsesOptions from dotenv import load_dotenv @@ -23,7 +24,8 @@ In this case they are here: https://platform.openai.com/docs/api-reference/respo """ -agent = OpenAIResponsesClient[OpenAIResponsesOptions](model_id="gpt-5").as_agent( +agent = Agent( + client=OpenAIResponsesClient[OpenAIResponsesOptions](model_id="gpt-5"), name="MathHelper", instructions="You are a personal math tutor. When asked a math question, " "reason over how best to approach the problem and share your thought process.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py b/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py index d256445328..7aafd6f704 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py @@ -6,23 +6,19 @@ import tempfile from pathlib import Path import anyio -from agent_framework import Content +from agent_framework import Agent, Content from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() - """OpenAI Responses Client Streaming Image Generation Example - Demonstrates streaming partial image generation using OpenAI's image generation tool. Shows progressive image rendering with partial images for improved user experience. - Note: The number of partial images received depends on generation speed: - High quality/complex images: More partials (generation takes longer) - Low quality/simple images: Fewer partials (generation completes quickly) - You may receive fewer partial images than requested if generation is fast - Important: The final partial image IS the complete, full-quality image. Each partial represents a progressive refinement, with the last one being the finished result. """ @@ -35,7 +31,6 @@ async def save_image_from_data_uri(data_uri: str, filename: str) -> None: # Extract base64 data base64_data = data_uri.split(",", 1)[1] image_bytes = base64.b64decode(base64_data) - # Save to file await anyio.Path(filename).write_bytes(image_bytes) print(f" Saved: {filename} ({len(image_bytes) / 1024:.1f} KB)") @@ -46,10 +41,10 @@ async def save_image_from_data_uri(data_uri: str, filename: str) -> None: async def main(): """Demonstrate streaming image generation with partial images.""" print("=== OpenAI Streaming Image Generation Example ===\n") - # Create agent with streaming image generation enabled client = OpenAIResponsesClient() - agent = client.as_agent( + agent = Agent( + client=client, instructions="You are a helpful agent that can generate images.", tools=[ client.get_image_generation_tool( @@ -59,18 +54,14 @@ async def main(): ) ], ) - query = "Draw a beautiful sunset over a calm ocean with sailboats" print(f" User: {query}") print() - # Track partial images image_count = 0 - # Use temp directory for output output_dir = Path(tempfile.gettempdir()) / "generated_images" output_dir.mkdir(exist_ok=True) - print(" Streaming response:") async for update in agent.run(query, stream=True): for content in update.contents: @@ -81,18 +72,14 @@ async def main(): image_output: Content = content.outputs if image_output.type == "data" and image_output.additional_properties.get("is_partial_image"): print(f" Image {image_count} received") - # Extract file extension from media_type (e.g., "image/png" -> "png") extension = "png" # Default fallback if image_output.media_type and "/" in image_output.media_type: extension = image_output.media_type.split("/")[-1] - # Save images with correct extension filename = output_dir / f"image{image_count}.{extension}" await save_image_from_data_uri(image_output.uri, str(filename)) - image_count += 1 - # Summary print("\n Summary:") print(f" Images received: {image_count}") diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py index 8c858b417f..567c7fcaef 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable -from agent_framework import FunctionInvocationContext +from agent_framework import Agent, FunctionInvocationContext from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -40,7 +40,8 @@ async def main() -> None: client = OpenAIResponsesClient() # Create a specialized writer agent - writer = client.as_agent( + writer = Agent( + client=client, name="WriterAgent", instructions="You are a creative writer. Write short, engaging content.", ) @@ -54,7 +55,8 @@ async def main() -> None: ) # Create coordinator agent with writer as a tool - coordinator = client.as_agent( + coordinator = Agent( + client=client, name="CoordinatorAgent", instructions="You coordinate with specialized agents. Delegate writing tasks to the creative_writer tool.", tools=[writer_tool], diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py index f45b5b8778..20a3f720d1 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py @@ -5,7 +5,7 @@ import os from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from pydantic import Field @@ -36,10 +36,13 @@ def get_weather( async def main() -> None: print("=== OpenAI Responses Client with Explicit Settings ===") - agent = OpenAIResponsesClient( - model_id=os.environ["OPENAI_RESPONSES_MODEL_ID"], + _client = OpenAIResponsesClient( + model=os.environ["OPENAI_MODEL"], api_key=os.environ["OPENAI_API_KEY"], - ).as_agent( + ) + + agent = Agent( + client=_client, instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py index 8a08e50a31..cdc4ce13fb 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py @@ -3,6 +3,7 @@ import asyncio import json +from agent_framework import Agent from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -36,7 +37,8 @@ runtime_schema = { async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -69,7 +71,8 @@ async def non_streaming_example() -> None: async def streaming_example() -> None: print("=== Streaming runtime JSON schema example ===") - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py index 429786245d..d2599c0bd8 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import AgentResponse +from agent_framework import Agent, AgentResponse from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from pydantic import BaseModel @@ -29,7 +29,8 @@ async def non_streaming_example() -> None: print("=== Non-streaming example ===") # Create an OpenAI Responses agent - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) @@ -54,7 +55,8 @@ async def streaming_example() -> None: print("=== Streaming example ===") # Create an OpenAI Responses agent - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) diff --git a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py index e9b4757bb6..0d8da5e6d4 100644 --- a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py +++ b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py @@ -7,7 +7,7 @@ from textwrap import dedent from typing import Any from agent_framework import Agent, Skill, SkillResource, SkillsProvider -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -75,7 +75,9 @@ unit_converter_skill = Skill( # --------------------------------------------------------------------------- # 2. Dynamic Resources — callable function via @skill.resource # --------------------------------------------------------------------------- -@unit_converter_skill.resource(name="conversion-policy", description="Current conversion formatting and rounding policy") +@unit_converter_skill.resource( + name="conversion-policy", description="Current conversion formatting and rounding policy" +) def conversion_policy(**kwargs: Any) -> Any: """Return the current conversion policy. @@ -126,12 +128,12 @@ def convert_units(value: float, factor: float, **kwargs: Any) -> str: async def main() -> None: """Run the code-defined skills demo.""" - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini") + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") - client = AzureOpenAIResponsesClient( + client = FoundryChatClient( project_endpoint=endpoint, - deployment_name=deployment, + model=deployment, credential=AzureCliCredential(), ) @@ -148,8 +150,7 @@ async def main() -> None: print("Converting units") print("-" * 60) response = await agent.run( - "How many kilometers is a marathon (26.2 miles)? " - "And how many pounds is 75 kilograms?", + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?", precision=2, ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py index 044514e7b7..bcb6bc35ff 100644 --- a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py +++ b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py @@ -6,7 +6,7 @@ import sys from pathlib import Path from agent_framework import Agent, SkillsProvider -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -40,13 +40,13 @@ load_dotenv() async def main() -> None: """Run the file-based skills demo.""" - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini") + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") # Create the chat client - client = AzureOpenAIResponsesClient( + client = FoundryChatClient( project_endpoint=endpoint, - deployment_name=deployment, + model=deployment, credential=AzureCliCredential(), ) @@ -70,8 +70,7 @@ async def main() -> None: print("Converting units") print("-" * 60) response = await agent.run( - "How many kilometers is a marathon (26.2 miles)? " - "And how many pounds is 75 kilograms?" + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?" ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py index 228c8809ff..0d1d618eae 100644 --- a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py +++ b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py @@ -20,7 +20,6 @@ def main() -> None: parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") args = parser.parse_args() - result = round(args.value * args.factor, 4) print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) diff --git a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py index 4e0d9173b7..14d015f125 100644 --- a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py +++ b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py @@ -13,7 +13,7 @@ from agent_framework import ( Skill, SkillsProvider, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -107,13 +107,13 @@ def convert_volume(value: float, factor: float) -> str: async def main() -> None: """Run the combined skills demo.""" - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini") + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") # Create the chat client - client = AzureOpenAIResponsesClient( + client = FoundryChatClient( project_endpoint=endpoint, - deployment_name=deployment, + model=deployment, credential=AzureCliCredential(), ) @@ -137,8 +137,7 @@ async def main() -> None: print("Converting units") print("-" * 60) response = await agent.run( - "How many kilometers is a marathon (26.2 miles)? " - "And how many liters is a 5-gallon bucket?" + "How many kilometers is a marathon (26.2 miles)? And how many liters is a 5-gallon bucket?" ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py index 228c8809ff..0d1d618eae 100644 --- a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py +++ b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py @@ -20,7 +20,6 @@ def main() -> None: parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") args = parser.parse_args() - result = round(args.value * args.factor, 4) print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) diff --git a/python/samples/02-agents/skills/script_approval/script_approval.py b/python/samples/02-agents/skills/script_approval/script_approval.py index b1613ef28f..25c6c07baf 100644 --- a/python/samples/02-agents/skills/script_approval/script_approval.py +++ b/python/samples/02-agents/skills/script_approval/script_approval.py @@ -5,7 +5,7 @@ import os from textwrap import dedent from agent_framework import Agent, Skill, SkillsProvider -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -29,8 +29,8 @@ How it works: an error if rejected. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME (defaults to "gpt-4o-mini"). +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_MODEL (defaults to "gpt-4o-mini"). """ # Load environment variables from .env file @@ -56,12 +56,12 @@ def deploy(version: str, environment: str = "staging") -> str: async def main() -> None: """Run the skill script approval demo.""" - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini") + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") - client = AzureOpenAIResponsesClient( + client = FoundryChatClient( project_endpoint=endpoint, - deployment_name=deployment, + model=deployment, credential=AzureCliCredential(), ) diff --git a/python/samples/02-agents/skills/subprocess_script_runner.py b/python/samples/02-agents/skills/subprocess_script_runner.py index 1d38bae754..e24724a0e0 100644 --- a/python/samples/02-agents/skills/subprocess_script_runner.py +++ b/python/samples/02-agents/skills/subprocess_script_runner.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. """Sample subprocess-based skill script runner. - Executes file-based skill scripts as local Python subprocesses. This is provided for demonstration purposes only. """ @@ -18,30 +17,23 @@ from agent_framework import Skill, SkillScript def subprocess_script_runner(skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> str: """Run a skill script as a local Python subprocess. - Resolves the script's absolute path from the skill directory, converts the ``args`` dict to CLI flags, and returns captured output. - Args: skill: The skill that owns the script. script: The script to run. args: Optional arguments forwarded as CLI flags. - Returns: The combined stdout/stderr output, or an error message. """ if not skill.path: return f"Error: Skill '{skill.name}' has no directory path." - if not script.path: return f"Error: Script '{script.name}' has no file path. Only file-based scripts can be executed locally." - script_path = Path(skill.path) / script.path if not script_path.is_file(): return f"Error: Script file not found: {script_path}" - cmd = [sys.executable, str(script_path)] - # Convert args dict to CLI flags if args: for key, value in args.items(): @@ -51,7 +43,6 @@ def subprocess_script_runner(skill: Skill, script: SkillScript, args: dict[str, elif value is not None: cmd.append(f"--{key}") cmd.append(str(value)) - try: result = subprocess.run( cmd, @@ -60,15 +51,12 @@ def subprocess_script_runner(skill: Skill, script: SkillScript, args: dict[str, timeout=30, cwd=str(script_path.parent), ) - output = result.stdout if result.stderr: output += f"\nStderr:\n{result.stderr}" if result.returncode != 0: output += f"\nScript exited with code {result.returncode}" - return output.strip() or "(no output)" - except subprocess.TimeoutExpired: return f"Error: Script '{script.name}' timed out after 30 seconds." except OSError as e: diff --git a/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py b/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py index fa78a9ede5..211c3b5681 100644 --- a/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py +++ b/python/samples/02-agents/tools/agent_as_tool_with_session_propagation.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Awaitable, Callable -from agent_framework import AgentContext, AgentSession, FunctionInvocationContext, tool +from agent_framework import Agent, AgentContext, AgentSession, FunctionInvocationContext, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -65,7 +65,8 @@ async def main() -> None: client = OpenAIResponsesClient() - research_agent = client.as_agent( + research_agent = Agent( + client=client, name="ResearchAgent", instructions="You are a research assistant. Provide concise answers and store your findings.", middleware=[log_session], @@ -80,7 +81,8 @@ async def main() -> None: propagate_session=True, ) - coordinator = client.as_agent( + coordinator = Agent( + client=client, name="CoordinatorAgent", instructions=( "You coordinate research. Use the 'research' tool to start research " diff --git a/python/samples/02-agents/tools/control_total_tool_executions.py b/python/samples/02-agents/tools/control_total_tool_executions.py index eaad6e225b..c53b228430 100644 --- a/python/samples/02-agents/tools/control_total_tool_executions.py +++ b/python/samples/02-agents/tools/control_total_tool_executions.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -88,7 +88,8 @@ async def scenario_max_iterations(): client.function_invocation_configuration["max_iterations"] = 3 print(f" max_iterations = {client.function_invocation_configuration['max_iterations']}") - agent = client.as_agent( + agent = Agent( + client=client, name="ResearchAgent", instructions=( "You are a research assistant. Use the search_web tool to answer " @@ -125,7 +126,8 @@ async def scenario_max_function_calls(): print(f" max_iterations = {client.function_invocation_configuration['max_iterations']}") print(f" max_function_calls = {client.function_invocation_configuration['max_function_calls']}") - agent = client.as_agent( + agent = Agent( + client=client, name="ResearchAgent", instructions=( "You are a research assistant. Use the search_web and get_weather " @@ -135,8 +137,7 @@ async def scenario_max_function_calls(): ) response = await agent.run( - "Search for the weather in Paris, London, Tokyo, " - "New York, and Sydney, and also search for best travel tips." + "Search for the weather in Paris, London, Tokyo, New York, and Sydney, and also search for best travel tips." ) print(f" Response: {response.text[:200]}...") print() @@ -156,7 +157,8 @@ async def scenario_max_invocations(): print("Scenario 3: max_invocations — lifetime cap on a tool") print("=" * 60) - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="APIAgent", instructions="Use call_expensive_api when asked to analyze something.", tools=[call_expensive_api], @@ -213,12 +215,14 @@ async def scenario_per_agent_tool_limits(): agent_b_lookup = tool(name="lookup", approval_mode="never_require", max_invocations=5)(_do_lookup) client = OpenAIResponsesClient() - agent_a = client.as_agent( + agent_a = Agent( + client=client, name="AgentA", instructions="Use the lookup tool to answer questions.", tools=[agent_a_lookup], ) - agent_b = client.as_agent( + agent_b = Agent( + client=client, name="AgentB", instructions="Use the lookup tool to answer questions.", tools=[agent_b_lookup], @@ -236,8 +240,12 @@ async def scenario_per_agent_tool_limits(): session_b = agent_b.create_session() await agent_b.run("Look up quantum computing", session=session_b) - print(f" agent_a_lookup.invocation_count = {agent_a_lookup.invocation_count} (limit {agent_a_lookup.max_invocations})") - print(f" agent_b_lookup.invocation_count = {agent_b_lookup.invocation_count} (limit {agent_b_lookup.max_invocations})") + print( + f" agent_a_lookup.invocation_count = {agent_a_lookup.invocation_count} (limit {agent_a_lookup.max_invocations})" + ) + print( + f" agent_b_lookup.invocation_count = {agent_b_lookup.invocation_count} (limit {agent_b_lookup.max_invocations})" + ) print(" → Agent A hit its limit; Agent B used 1 of 5.") print() @@ -254,8 +262,8 @@ async def scenario_combined(): client = OpenAIResponsesClient() # 1. Configure the client with both iteration and function call limits. - client.function_invocation_configuration["max_iterations"] = 5 # max 5 LLM roundtrips - client.function_invocation_configuration["max_function_calls"] = 8 # max 8 total tool calls + client.function_invocation_configuration["max_iterations"] = 5 # max 5 LLM roundtrips + client.function_invocation_configuration["max_function_calls"] = 8 # max 8 total tool calls print(f" max_iterations = {client.function_invocation_configuration['max_iterations']}") print(f" max_function_calls = {client.function_invocation_configuration['max_function_calls']}") @@ -267,7 +275,8 @@ async def scenario_combined(): print(f" premium_lookup.max_invocations = {premium_lookup.max_invocations}") - agent = client.as_agent( + agent = Agent( + client=client, name="MultiToolAgent", instructions="Use all available tools to answer comprehensively.", tools=[search_web, get_weather, premium_lookup], diff --git a/python/samples/02-agents/tools/function_invocation_configuration.py b/python/samples/02-agents/tools/function_invocation_configuration.py index 3c3479c87a..78fdecbb2c 100644 --- a/python/samples/02-agents/tools/function_invocation_configuration.py +++ b/python/samples/02-agents/tools/function_invocation_configuration.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -33,7 +33,7 @@ async def main(): client.function_invocation_configuration["max_iterations"] = 40 print(f"Function invocation configured as: \n{client.function_invocation_configuration}") - agent = client.as_agent(name="ToolAgent", instructions="Use the provided tools.", tools=add) + agent = Agent(client=client, name="ToolAgent", instructions="Use the provided tools.", tools=add) print("=" * 60) print("Call add(239847293, 29834)") diff --git a/python/samples/02-agents/tools/function_tool_declaration_only.py b/python/samples/02-agents/tools/function_tool_declaration_only.py index 2d7c54c791..efa98d85bc 100644 --- a/python/samples/02-agents/tools/function_tool_declaration_only.py +++ b/python/samples/02-agents/tools/function_tool_declaration_only.py @@ -2,7 +2,7 @@ import asyncio -from agent_framework import FunctionTool +from agent_framework import Agent, FunctionTool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -25,7 +25,8 @@ async def main(): description="Get the current time in ISO 8601 format.", ) - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="DeclarationOnlyToolAgent", instructions="You are a helpful agent that uses tools.", tools=function_declaration, diff --git a/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py b/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py index 39eef147be..7f6b8896f0 100644 --- a/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py +++ b/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py @@ -21,7 +21,7 @@ Usage: import asyncio -from agent_framework import FunctionTool +from agent_framework import Agent, FunctionTool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -61,8 +61,11 @@ async def main() -> None: # - "func": the parameter name that will receive the injected function tool = FunctionTool.from_dict(definition, dependencies={"function_tool": {"name:add_numbers": {"func": func}}}) - agent = OpenAIResponsesClient().as_agent( - name="FunctionToolAgent", instructions="You are a helpful assistant.", tools=tool + agent = Agent( + client=OpenAIResponsesClient(), + name="FunctionToolAgent", + instructions="You are a helpful assistant.", + tools=tool, ) response = await agent.run("What is 5 + 3?") print(f"Response: {response.text}") diff --git a/python/samples/02-agents/tools/function_tool_recover_from_failures.py b/python/samples/02-agents/tools/function_tool_recover_from_failures.py index 527a35c79a..48f7b80a56 100644 --- a/python/samples/02-agents/tools/function_tool_recover_from_failures.py +++ b/python/samples/02-agents/tools/function_tool_recover_from_failures.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -45,7 +45,8 @@ def safe_divide( async def main(): # tools = Tools() - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[greet, safe_divide], diff --git a/python/samples/02-agents/tools/function_tool_with_approval_and_sessions.py b/python/samples/02-agents/tools/function_tool_with_approval_and_sessions.py index 089eb7dbdd..2f09420a4a 100644 --- a/python/samples/02-agents/tools/function_tool_with_approval_and_sessions.py +++ b/python/samples/02-agents/tools/function_tool_with_approval_and_sessions.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, Message, tool -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -32,7 +32,7 @@ async def approval_example() -> None: print("=== Tool Approval with Session ===\n") agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), name="CalendarAgent", instructions="You are a helpful calendar assistant.", tools=[add_to_calendar], @@ -68,7 +68,7 @@ async def rejection_example() -> None: print("=== Tool Rejection with Session ===\n") agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), name="CalendarAgent", instructions="You are a helpful calendar assistant.", tools=[add_to_calendar], diff --git a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py index 10fbde187f..231a980b45 100644 --- a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py +++ b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py @@ -17,7 +17,7 @@ Two approaches are shown: import asyncio from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from pydantic import BaseModel, Field @@ -69,7 +69,8 @@ def get_current_time(timezone: str = "UTC") -> str: async def main(): - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="AssistantAgent", instructions="You are a helpful assistant. Use the available tools to answer questions.", tools=[get_weather, get_current_time], diff --git a/python/samples/02-agents/tools/function_tool_with_kwargs.py b/python/samples/02-agents/tools/function_tool_with_kwargs.py index 61db84eb17..77664d6f8c 100644 --- a/python/samples/02-agents/tools/function_tool_with_kwargs.py +++ b/python/samples/02-agents/tools/function_tool_with_kwargs.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import FunctionInvocationContext, tool +from agent_framework import Agent, FunctionInvocationContext, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from pydantic import Field @@ -43,7 +43,8 @@ def get_weather( async def main() -> None: - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather], diff --git a/python/samples/02-agents/tools/function_tool_with_max_exceptions.py b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py index 7830b53794..b8ed2f5b58 100644 --- a/python/samples/02-agents/tools/function_tool_with_max_exceptions.py +++ b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -35,7 +35,8 @@ def safe_divide( async def main(): # tools = Tools() - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[safe_divide], diff --git a/python/samples/02-agents/tools/function_tool_with_max_invocations.py b/python/samples/02-agents/tools/function_tool_with_max_invocations.py index 1619cb4046..0cea02c23e 100644 --- a/python/samples/02-agents/tools/function_tool_with_max_invocations.py +++ b/python/samples/02-agents/tools/function_tool_with_max_invocations.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -24,7 +24,8 @@ def unicorn_function(times: Annotated[int, "The number of unicorns to return."]) async def main(): # tools = Tools() - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[unicorn_function], diff --git a/python/samples/02-agents/tools/function_tool_with_session_injection.py b/python/samples/02-agents/tools/function_tool_with_session_injection.py index 53cc63c2c0..21df5cc2c9 100644 --- a/python/samples/02-agents/tools/function_tool_with_session_injection.py +++ b/python/samples/02-agents/tools/function_tool_with_session_injection.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import AgentSession, FunctionInvocationContext, tool +from agent_framework import Agent, AgentSession, FunctionInvocationContext, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv from pydantic import Field @@ -36,7 +36,8 @@ async def get_weather( async def main() -> None: - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather], @@ -47,12 +48,8 @@ async def main() -> None: session = agent.create_session() # Run the agent with the session; tools receive it via ctx.session. - print( - f"Agent: {await agent.run('What is the weather in London?', session=session)}" - ) - print( - f"Agent: {await agent.run('What is the weather in Amsterdam?', session=session)}" - ) + print(f"Agent: {await agent.run('What is the weather in London?', session=session)}") + print(f"Agent: {await agent.run('What is the weather in Amsterdam?', session=session)}") print(f"Agent: {await agent.run('What cities did I ask about?', session=session)}") diff --git a/python/samples/02-agents/tools/tool_in_class.py b/python/samples/02-agents/tools/tool_in_class.py index 61f3230148..7a4c1051b7 100644 --- a/python/samples/02-agents/tools/tool_in_class.py +++ b/python/samples/02-agents/tools/tool_in_class.py @@ -3,7 +3,7 @@ import asyncio from typing import Annotated -from agent_framework import tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -49,7 +49,8 @@ async def main(): # Applying the tool decorator to one of the methods of the class add_function = tool(description="Add two numbers.")(tools.add) - agent = OpenAIResponsesClient().as_agent( + agent = Agent( + client=OpenAIResponsesClient(), name="ToolAgent", instructions="Use the provided tools.", ) diff --git a/python/samples/02-agents/typed_options.py b/python/samples/02-agents/typed_options.py index 65e59ec3d4..f33cc21186 100644 --- a/python/samples/02-agents/typed_options.py +++ b/python/samples/02-agents/typed_options.py @@ -5,6 +5,7 @@ from typing import Literal from agent_framework import Agent from agent_framework.anthropic import AnthropicClient +from agent_framework.foundry import FoundryChatClient from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions from dotenv import load_dotenv @@ -39,7 +40,7 @@ async def demo_anthropic_chat_client() -> None: print("\n=== Anthropic ChatClient with TypedDict Options ===\n") # Create Anthropic client - client = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client = AnthropicClient(model="claude-sonnet-4-5-20250929") # Standard options work great: response = await client.get_response( @@ -61,7 +62,7 @@ async def demo_anthropic_agent() -> None: """Demonstrate Agent with Anthropic client and typed options.""" print("\n=== Agent with Anthropic and Typed Options ===\n") - client = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client = AnthropicClient(model="claude-sonnet-4-5-20250929") # Create a typed agent for Anthropic - IDE knows Anthropic-specific options! agent = Agent( @@ -148,7 +149,7 @@ async def demo_openai_agent() -> None: # or on the client when constructing the client instance: # client = OpenAIChatClient[OpenAIReasoningChatOptions]() agent = Agent[OpenAIReasoningChatOptions]( - client=OpenAIChatClient(model_id="o3"), + client=FoundryChatClient(model="o3"), name="weather-assistant", instructions="You are a helpful assistant. Answer concisely.", # Options can be set at construction time diff --git a/python/samples/03-workflows/_start-here/step2_agents_in_a_workflow.py b/python/samples/03-workflows/_start-here/step2_agents_in_a_workflow.py index 6ace56da55..326bd717ed 100644 --- a/python/samples/03-workflows/_start-here/step2_agents_in_a_workflow.py +++ b/python/samples/03-workflows/_start-here/step2_agents_in_a_workflow.py @@ -4,8 +4,8 @@ import asyncio import os from typing import cast -from agent_framework import AgentResponse, WorkflowBuilder -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, AgentResponse, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -19,12 +19,12 @@ This sample creates two agents: a Writer agent creates or edits content, and a R evaluates and provides feedback. Purpose: -Show how to create agents from AzureOpenAIResponsesClient and use them directly in a workflow. Demonstrate +Show how to create agents from FoundryChatClient and use them directly in a workflow. Demonstrate how agents can be used in a workflow. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, edges, events, and streaming or non-streaming runs. """ @@ -33,19 +33,21 @@ Prerequisites: async def main(): """Build and run a simple two node agent workflow: Writer then Reviewer.""" # Create the Azure chat client. AzureCliCredential uses your current az login. - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - writer_agent = client.as_agent( + writer_agent = Agent( + client=client, instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), name="writer", ) - reviewer_agent = client.as_agent( + reviewer_agent = Agent( + client=client, instructions=( "You are an excellent content reviewer." "Provide actionable feedback to the writer about the provided content." diff --git a/python/samples/03-workflows/_start-here/step3_streaming.py b/python/samples/03-workflows/_start-here/step3_streaming.py index 1850cfc4a4..7d66e1f137 100644 --- a/python/samples/03-workflows/_start-here/step3_streaming.py +++ b/python/samples/03-workflows/_start-here/step3_streaming.py @@ -3,8 +3,8 @@ import asyncio import os -from agent_framework import AgentResponseUpdate, Message, WorkflowBuilder -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, AgentResponseUpdate, Message, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -18,12 +18,12 @@ This sample creates two agents: a Writer agent creates or edits content, and a R evaluates and provides feedback. Purpose: -Show how to create agents from AzureOpenAIResponsesClient and use them directly in a workflow. Demonstrate +Show how to create agents from FoundryChatClient and use them directly in a workflow. Demonstrate how agents can be used in a workflow. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs. """ @@ -32,19 +32,21 @@ Prerequisites: async def main(): """Build the two node workflow and run it with streaming to observe events.""" # Create the Azure chat client. AzureCliCredential uses your current az login. - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - writer_agent = client.as_agent( + writer_agent = Agent( + client=client, instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), name="writer", ) - reviewer_agent = client.as_agent( + reviewer_agent = Agent( + client=client, instructions=( "You are an excellent content reviewer." "Provide actionable feedback to the writer about the provided content." diff --git a/python/samples/03-workflows/agents/azure_ai_agents_streaming.py b/python/samples/03-workflows/agents/azure_ai_agents_streaming.py index bac2506468..e54dd1b8f4 100644 --- a/python/samples/03-workflows/agents/azure_ai_agents_streaming.py +++ b/python/samples/03-workflows/agents/azure_ai_agents_streaming.py @@ -3,8 +3,8 @@ import asyncio import os -from agent_framework import AgentResponseUpdate, WorkflowBuilder -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, AgentResponseUpdate, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -17,7 +17,7 @@ Sample: Azure AI Agents in a Workflow with Streaming This sample shows how to create agents backed by Azure OpenAI Responses and use them in a workflow with streaming. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - AZURE_AI_MODEL_DEPLOYMENT_NAME must be set to your Azure OpenAI model deployment name. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, edges, events, and streaming runs. @@ -25,21 +25,23 @@ Prerequisites: async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create two agents: a Writer and a Reviewer. - writer_agent = client.as_agent( + writer_agent = Agent( + client=client, name="Writer", instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), ) - reviewer_agent = client.as_agent( + reviewer_agent = Agent( + client=client, name="Reviewer", instructions=( "You are an excellent content reviewer. " diff --git a/python/samples/03-workflows/agents/azure_ai_agents_with_shared_session.py b/python/samples/03-workflows/agents/azure_ai_agents_with_shared_session.py index 7a69892a77..bc4167d466 100644 --- a/python/samples/03-workflows/agents/azure_ai_agents_with_shared_session.py +++ b/python/samples/03-workflows/agents/azure_ai_agents_with_shared_session.py @@ -4,6 +4,7 @@ import asyncio import os from agent_framework import ( + Agent, AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, @@ -13,7 +14,7 @@ from agent_framework import ( WorkflowRunState, executor, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -33,11 +34,11 @@ Notes: - Not all agents can share threads; usually only the same type of agents can share threads. Demonstrate: -- Creating multiple agents with AzureOpenAIResponsesClient. +- Creating multiple agents with FoundryChatClient. - Setting up a shared thread between agents. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - AZURE_AI_MODEL_DEPLOYMENT_NAME must be set to your Azure OpenAI model deployment name. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with agents, workflows, and executors in the agent framework. @@ -57,20 +58,22 @@ async def intercept_agent_response( async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # set the same context provider (same default source_id) for both agents to share the thread - writer = client.as_agent( + writer = Agent( + client=client, instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), name="writer", context_providers=[InMemoryHistoryProvider()], ) - reviewer = client.as_agent( + reviewer = Agent( + client=client, instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), name="reviewer", context_providers=[InMemoryHistoryProvider()], diff --git a/python/samples/03-workflows/agents/azure_chat_agents_and_executor.py b/python/samples/03-workflows/agents/azure_chat_agents_and_executor.py index 76436a0ce1..9655781522 100644 --- a/python/samples/03-workflows/agents/azure_chat_agents_and_executor.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_and_executor.py @@ -5,6 +5,7 @@ import os from typing import Final from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, AgentResponseUpdate, @@ -13,7 +14,7 @@ from agent_framework import ( WorkflowContext, executor, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -35,8 +36,8 @@ Demonstrates: - Consuming an AgentExecutorResponse and forwarding an AgentExecutorRequest for the next 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Run `az login` before executing. """ @@ -100,22 +101,24 @@ async def enrich_with_references( async def main() -> None: """Run the workflow and stream combined updates from both agents.""" # Create the agents - research_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + research_agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="research_agent", instructions=( "Produce a short, bullet-style briefing with two actionable ideas. Label the section as 'Initial Draft'." ), ) - final_editor_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + final_editor_agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="final_editor_agent", instructions=( "Use all conversation context (including external notes) to produce the final answer. " diff --git a/python/samples/03-workflows/agents/azure_chat_agents_streaming.py b/python/samples/03-workflows/agents/azure_chat_agents_streaming.py index 13c20a0a57..a7fe68f27f 100644 --- a/python/samples/03-workflows/agents/azure_chat_agents_streaming.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_streaming.py @@ -3,8 +3,8 @@ import asyncio import os -from agent_framework import AgentResponseUpdate, WorkflowBuilder -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, AgentResponseUpdate, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -17,8 +17,8 @@ Sample: AzureOpenAI Chat Agents in a Workflow with Streaming This sample shows how to create AzureOpenAI Chat Agents and use them in a workflow with streaming. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, edges, events, and streaming runs. """ @@ -27,22 +27,26 @@ Prerequisites: async def main(): """Build and run a simple two node agent workflow: Writer then Reviewer.""" # Create the agents - writer_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + _writer_client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), - ).as_agent( + ) + writer_agent = Agent( + client=_writer_client, instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), name="writer", ) - reviewer_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + _reviewer_client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), - ).as_agent( + ) + reviewer_agent = Agent( + client=_reviewer_client, instructions=( "You are an excellent content reviewer." "Provide actionable feedback to the writer about the provided content." diff --git a/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py index 68c9eb5ae0..5c1d3027b0 100644 --- a/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py +++ b/python/samples/03-workflows/agents/azure_chat_agents_tool_calls_with_feedback.py @@ -22,7 +22,7 @@ from agent_framework import ( response_handler, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -48,8 +48,8 @@ Demonstrates: - Streaming AgentRunUpdateEvent updates alongside human-in-the-loop pauses. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Run `az login` before executing. """ @@ -175,13 +175,12 @@ class Coordinator(Executor): def create_writer_agent() -> Agent: """Creates a writer agent with tools.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - # This sample has been tested only on `gpt-5.1` and may not work as intended on other models - # This sample is known to fail on `gpt-5-mini` reasoning input (GH issue #4059) - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="writer_agent", instructions=( "You are a marketing writer. Call the available tools before drafting copy so you are precise. " @@ -195,11 +194,12 @@ def create_writer_agent() -> Agent: def create_final_editor_agent() -> Agent: """Creates a final editor agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="final_editor_agent", instructions=( "You are an editor who polishes marketing copy after human approval. " diff --git a/python/samples/03-workflows/agents/concurrent_workflow_as_agent.py b/python/samples/03-workflows/agents/concurrent_workflow_as_agent.py index 48c3155edd..acee104c86 100644 --- a/python/samples/03-workflows/agents/concurrent_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/concurrent_workflow_as_agent.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. - import asyncio import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -15,30 +15,31 @@ load_dotenv() Sample: Build a concurrent workflow orchestration and wrap it as an agent. This script wires up a fan-out/fan-in workflow using `ConcurrentBuilder`, and then -invokes the entire orchestration through the `workflow.as_agent(...)` interface so +invokes the entire orchestration through the `Agent(client=workflow,...)` interface so downstream coordinators can reuse the orchestration as a single agent. Demonstrates: - Fan-out to multiple agents, fan-in aggregation of final ChatMessages. -- Reusing the orchestrated workflow as an agent entry point with `workflow.as_agent(...)`. +- Reusing the orchestrated workflow as an agent entry point with `Agent(client=workflow,...)`. - Workflow completion when idle with no pending work Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- Azure OpenAI access configured for AzureOpenAIResponsesClient (use az login + env vars) +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI access configured for FoundryChatClient (use az login + env vars) - Familiarity with Workflow events (WorkflowEvent with type "output") """ async def main() -> None: - # 1) Create three domain agents using AzureOpenAIResponsesClient - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + # 1) Create three domain agents using FoundryChatClient + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - researcher = client.as_agent( + researcher = Agent( + client=client, instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -46,7 +47,8 @@ async def main() -> None: name="researcher", ) - marketer = client.as_agent( + marketer = Agent( + client=client, instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -54,7 +56,8 @@ async def main() -> None: name="marketer", ) - legal = client.as_agent( + legal = Agent( + client=client, instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." @@ -66,7 +69,7 @@ async def main() -> None: workflow = ConcurrentBuilder(participants=[researcher, marketer, legal]).build() # 3) Expose the concurrent workflow as an agent for easy reuse - agent = workflow.as_agent(name="ConcurrentWorkflowAgent") + agent = Agent(client=workflow, name="ConcurrentWorkflowAgent") prompt = "We are launching a new budget-friendly electric bike for urban commuters." agent_response = await agent.run(prompt) diff --git a/python/samples/03-workflows/agents/custom_agent_executors.py b/python/samples/03-workflows/agents/custom_agent_executors.py index 4534ebe39e..95d1d8dca8 100644 --- a/python/samples/03-workflows/agents/custom_agent_executors.py +++ b/python/samples/03-workflows/agents/custom_agent_executors.py @@ -11,7 +11,7 @@ from agent_framework import ( WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -25,15 +25,15 @@ This sample uses two custom executors. A Writer agent creates or edits content, then hands the conversation to a Reviewer agent which evaluates and finalizes the result. Purpose: -Show how to wrap chat agents created by AzureOpenAIResponsesClient inside workflow executors. Demonstrate the @handler +Show how to wrap chat agents created by FoundryChatClient inside workflow executors. Demonstrate the @handler pattern with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder, and finish by yielding outputs from the terminal node. Note: When an agent is passed to a workflow, the workflow wraps the agent in a more sophisticated executor. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming or non streaming runs. """ @@ -50,12 +50,13 @@ class Writer(Executor): agent: Agent def __init__(self, id: str = "writer"): - # Create a domain specific agent using your configured AzureOpenAIResponsesClient. - self.agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + # Create a domain specific agent using your configured FoundryChatClient. + self.agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are an excellent content writer. You create new content and edit contents based on the feedback." ), @@ -97,11 +98,12 @@ class Reviewer(Executor): def __init__(self, id: str = "reviewer"): # Create a domain specific agent that evaluates and refines content. - self.agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + self.agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are an excellent content reviewer. You review the content and provide feedback to the writer." ), diff --git a/python/samples/03-workflows/agents/group_chat_workflow_as_agent.py b/python/samples/03-workflows/agents/group_chat_workflow_as_agent.py index 05994723cb..3c03a2fb57 100644 --- a/python/samples/03-workflows/agents/group_chat_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/group_chat_workflow_as_agent.py @@ -4,7 +4,7 @@ import asyncio import os from agent_framework import Agent -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import GroupChatBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -20,8 +20,8 @@ What it does: - The orchestrator coordinates a researcher (chat completions) and a writer (responses API) to solve a task. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- Environment variables configured for `AzureOpenAIResponsesClient`. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured for `FoundryChatClient`. """ @@ -30,9 +30,9 @@ async def main() -> None: name="Researcher", description="Collects relevant background information.", instructions="Gather concise facts that help a teammate answer the question.", - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) @@ -41,23 +41,26 @@ async def main() -> None: name="Writer", description="Synthesizes a polished answer using the gathered notes.", instructions="Compose clear and structured answers using any notes provided.", - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) + _orch_client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + # intermediate_outputs=True: Enable intermediate outputs to observe the conversation as it unfolds # (Intermediate outputs will be emitted as WorkflowOutputEvent events) workflow = GroupChatBuilder( participants=[researcher, writer], intermediate_outputs=True, - orchestrator_agent=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + orchestrator_agent=Agent( + client=_orch_client, name="Orchestrator", instructions="You coordinate a team conversation to solve the user's task.", ), @@ -69,7 +72,7 @@ async def main() -> None: print(f"Input: {task}\n") try: - workflow_agent = workflow.as_agent(name="GroupChatWorkflowAgent") + workflow_agent = Agent(client=workflow, name="GroupChatWorkflowAgent") agent_result = await workflow_agent.run(task) if agent_result.messages: diff --git a/python/samples/03-workflows/agents/handoff_workflow_as_agent.py b/python/samples/03-workflows/agents/handoff_workflow_as_agent.py index f059009a8e..6c56d3d66b 100644 --- a/python/samples/03-workflows/agents/handoff_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/handoff_workflow_as_agent.py @@ -12,7 +12,7 @@ from agent_framework import ( WorkflowAgent, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -29,9 +29,9 @@ A handoff workflow defines a pattern that assembles agents in a mesh topology, a them to transfer control to each other based on the conversation context. Prerequisites: - - AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. + - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - `az login` (Azure CLI authentication) - - Environment variables configured for AzureOpenAIResponsesClient (AZURE_AI_MODEL_DEPLOYMENT_NAME) + - Environment variables configured for FoundryChatClient (AZURE_AI_MODEL_DEPLOYMENT_NAME) Key Concepts: - Auto-registered handoff tools: HandoffBuilder automatically creates handoff tools @@ -63,17 +63,18 @@ def process_return(order_number: Annotated[str, "Order number to process return return f"Return initiated successfully for order {order_number}. You will receive return instructions via email." -def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Agent, Agent]: +def create_agents(client: FoundryChatClient) -> tuple[Agent, Agent, Agent, Agent]: """Create and configure the triage and specialist agents. Args: - client: The AzureOpenAIResponsesClient to use for creating agents. + client: The FoundryChatClient to use for creating agents. Returns: Tuple of (triage_agent, refund_agent, order_agent, return_agent) """ # Triage agent: Acts as the frontline dispatcher - triage_agent = client.as_agent( + triage_agent = Agent( + client=client, instructions=( "You are frontline support triage. Route customer issues to the appropriate specialist agents " "based on the problem described." @@ -82,7 +83,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age ) # Refund specialist: Handles refund requests - refund_agent = client.as_agent( + refund_agent = Agent( + client=client, instructions="You process refund requests.", name="refund_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -90,7 +92,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age ) # Order/shipping specialist: Resolves delivery issues - order_agent = client.as_agent( + order_agent = Agent( + client=client, instructions="You handle order and shipping inquiries.", name="order_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -98,7 +101,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age ) # Return specialist: Handles return requests - return_agent = client.as_agent( + return_agent = Agent( + client=client, instructions="You manage product return requests.", name="return_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -153,9 +157,9 @@ async def main() -> None: replace the scripted_responses with actual user input collection. """ # Initialize the Azure OpenAI chat client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) @@ -170,20 +174,21 @@ async def main() -> None: # Without this, the default behavior continues requesting user input until max_turns # is reached. Here we use a custom condition that checks if the conversation has ended # naturally (when one of the agents says something like "you're welcome"). - agent = ( - HandoffBuilder( - name="customer_support_handoff", - participants=[triage, refund, order, support], - # Custom termination: Check if one of the agents has provided a closing message. - # This looks for the last message containing "welcome", which indicates the - # conversation has concluded naturally. - termination_condition=lambda conversation: ( - len(conversation) > 0 and "welcome" in conversation[-1].text.lower() - ), - ) - .with_start_agent(triage) - .build() - .as_agent() # Convert workflow to agent interface + agent = Agent( + client=( + HandoffBuilder( + name="customer_support_handoff", + participants=[triage, refund, order, support], + # Custom termination: Check if one of the agents has provided a closing message. + # This looks for the last message containing "welcome", which indicates the + # conversation has concluded naturally. + termination_condition=lambda conversation: ( + len(conversation) > 0 and "welcome" in conversation[-1].text.lower() + ), + ) + .with_start_agent(triage) + .build() + ), ) # Scripted user responses for reproducible demo diff --git a/python/samples/03-workflows/agents/magentic_workflow_as_agent.py b/python/samples/03-workflows/agents/magentic_workflow_as_agent.py index 833d89cd19..7ce8c496df 100644 --- a/python/samples/03-workflows/agents/magentic_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/magentic_workflow_as_agent.py @@ -6,7 +6,7 @@ import os from agent_framework import ( Agent, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import MagenticBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -18,12 +18,12 @@ load_dotenv() Sample: Build a Magentic orchestration and wrap it as an agent. The script configures a Magentic workflow with streaming callbacks, then invokes the -orchestration through `workflow.as_agent(...)` so the entire Magentic loop can be reused +orchestration through `Agent(client=workflow, ...)` so the entire Magentic loop can be reused like any other agent while still emitting callback telemetry. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- OpenAI credentials configured for `AzureOpenAIResponsesClient` and `AzureOpenAIResponsesClient`. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- OpenAI credentials configured for `FoundryChatClient` and `FoundryChatClient`. """ @@ -35,17 +35,17 @@ async def main() -> None: "You are a Researcher. You find information without additional computation or quantitative analysis." ), # This agent requires the gpt-4o-search-preview model to perform web searches. - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) # Create code interpreter tool using instance method - coder_client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + coder_client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) code_interpreter_tool = coder_client.get_code_interpreter_tool() @@ -63,9 +63,9 @@ async def main() -> None: name="MagenticManager", description="Orchestrator that coordinates the research and coding workflow", instructions="You coordinate a team to complete complex tasks efficiently.", - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) @@ -98,7 +98,7 @@ async def main() -> None: try: # Wrap the workflow as an agent for composition scenarios print("\nWrapping workflow as an agent and running...") - workflow_agent = workflow.as_agent(name="MagenticWorkflowAgent") + workflow_agent = Agent(client=workflow, name="MagenticWorkflowAgent") last_response_id: str | None = None async for update in workflow_agent.run(task, stream=True): diff --git a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py index 74f4fc568b..dcc4d9fad9 100644 --- a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. - import asyncio import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -15,7 +15,7 @@ load_dotenv() Sample: Build a sequential workflow orchestration and wrap it as an agent. The script assembles a sequential conversation flow with `SequentialBuilder`, then -invokes the entire orchestration through the `workflow.as_agent(...)` interface so +invokes the entire orchestration through the `Agent(client=workflow,...)` interface so other coordinators can reuse the chain as a single participant. Note on internal adapters: @@ -26,25 +26,27 @@ Note on internal adapters: You can safely ignore them when focusing on agent progress. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- Azure OpenAI access configured for AzureOpenAIResponsesClient (use az login + env vars) +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI access configured for FoundryChatClient (use az login + env vars) """ 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"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - writer = client.as_agent( + writer = Agent( + client=client, instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), name="writer", ) - reviewer = client.as_agent( + reviewer = Agent( + client=client, instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), name="reviewer", ) @@ -53,7 +55,7 @@ async def main() -> None: workflow = SequentialBuilder(participants=[writer, reviewer]).build() # 3) Treat the workflow itself as an agent for follow-up invocations - agent = workflow.as_agent(name="SequentialWorkflowAgent") + agent = Agent(client=workflow, name="SequentialWorkflowAgent") prompt = "Write a tagline for a budget-friendly eBike." agent_response = await agent.run(prompt) diff --git a/python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py b/python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py index fc6bd2c0de..d5a5e1f4e2 100644 --- a/python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_human_in_the_loop.py @@ -8,7 +8,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -48,8 +49,8 @@ to a human, receives the human response, and then forwards that response back to the Worker. The workflow completes when idle. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- OpenAI account configured and accessible for AzureOpenAIResponsesClient. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- OpenAI account configured and accessible for FoundryChatClient. - Familiarity with WorkflowBuilder, Executor, and WorkflowContext from agent_framework. - Understanding of request-response message handling in executors. - (Optional) Review of reflection and escalation patterns, such as those in @@ -110,20 +111,16 @@ async def main() -> None: # and escalation paths for human review. worker = Worker( id="worker", - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) reviewer = ReviewerWithHumanInTheLoop(worker_id="worker") - agent = ( - WorkflowBuilder(start_executor=worker) - .add_edge(worker, reviewer) # Worker sends requests to Reviewer - .add_edge(reviewer, worker) # Reviewer sends feedback to Worker - .build() - .as_agent() # Convert workflow into an agent interface + agent = Agent( + client=(WorkflowBuilder(start_executor=worker).add_edge(worker, reviewer).add_edge(reviewer, worker).build()), ) print("Running workflow agent with user query...") diff --git a/python/samples/03-workflows/agents/workflow_as_agent_kwargs.py b/python/samples/03-workflows/agents/workflow_as_agent_kwargs.py index eb46578b67..1b8a875773 100644 --- a/python/samples/03-workflows/agents/workflow_as_agent_kwargs.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_kwargs.py @@ -5,8 +5,8 @@ import json import os from typing import Annotated, Any -from agent_framework import tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -19,22 +19,22 @@ load_dotenv() Sample: Workflow as Agent with kwargs Propagation to @tool Tools This sample demonstrates how to flow custom context (skill data, user tokens, etc.) -through a workflow exposed via .as_agent() to @tool functions using the **kwargs pattern. +through a workflow exposed Agent(client=via,) to @tool functions using the **kwargs pattern. Key Concepts: - Build a workflow using SequentialBuilder (or any builder pattern) -- Expose the workflow as a reusable agent via workflow.as_agent() +- Expose the workflow as a reusable agent via Agent(client=workflow,) - Pass custom context as kwargs when invoking workflow_agent.run() - kwargs are stored in State and propagated to all agent invocations - @tool functions receive kwargs via **kwargs parameter -When to use workflow.as_agent(): +When to use Agent(client=workflow,): - To treat an entire workflow orchestration as a single agent - To compose workflows into higher-level orchestrations - To maintain a consistent agent interface for callers Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Environment variables configured """ @@ -87,14 +87,15 @@ async def main() -> None: print("=" * 70) # Create chat client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create agent with tools that use kwargs - agent = client.as_agent( + agent = Agent( + client=client, name="assistant", instructions=( "You are a helpful assistant. Use the available tools to help users. " @@ -107,8 +108,8 @@ async def main() -> None: # Build a sequential workflow workflow = SequentialBuilder(participants=[agent]).build() - # Expose the workflow as an agent using .as_agent() - workflow_agent = workflow.as_agent(name="WorkflowAgent") + # Expose the workflow as an agent Agent(client=using,) + workflow_agent = Agent(client=workflow, name="WorkflowAgent") # Define custom context that will flow to tools via kwargs custom_data = { diff --git a/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py b/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py index 4611ac45fa..2d37778b84 100644 --- a/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_reflection_pattern.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from uuid import uuid4 from agent_framework import ( + Agent, AgentResponse, Executor, Message, @@ -14,7 +15,7 @@ from agent_framework import ( WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel @@ -39,8 +40,8 @@ Key Concepts Demonstrated: - State management for pending requests and retry logic. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- OpenAI account configured and accessible for AzureOpenAIResponsesClient. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- OpenAI account configured and accessible for FoundryChatClient. - Familiarity with WorkflowBuilder, Executor, WorkflowContext, and event handling. - Understanding of how agent messages are generated, reviewed, and re-submitted. """ @@ -195,27 +196,23 @@ async def main() -> None: print("Building workflow with Worker ↔ Reviewer cycle...") worker = Worker( id="worker", - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) reviewer = Reviewer( id="reviewer", - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) - agent = ( - WorkflowBuilder(start_executor=worker) - .add_edge(worker, reviewer) # Worker sends responses to Reviewer - .add_edge(reviewer, worker) # Reviewer provides feedback to Worker - .build() - .as_agent() # Wrap workflow as an agent + agent = Agent( + client=(WorkflowBuilder(start_executor=worker).add_edge(worker, reviewer).add_edge(reviewer, worker).build()), ) print("Running workflow agent with user query...") diff --git a/python/samples/03-workflows/agents/workflow_as_agent_with_session.py b/python/samples/03-workflows/agents/workflow_as_agent_with_session.py index 26fb4cff53..469568f000 100644 --- a/python/samples/03-workflows/agents/workflow_as_agent_with_session.py +++ b/python/samples/03-workflows/agents/workflow_as_agent_with_session.py @@ -3,8 +3,8 @@ import asyncio import os -from agent_framework import AgentSession, InMemoryHistoryProvider -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, AgentSession, InMemoryHistoryProvider +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -24,7 +24,7 @@ It also demonstrates how to enable checkpointing for workflow execution state persistence, allowing workflows to be paused and resumed. Key concepts: -- Workflows can be wrapped as agents using workflow.as_agent() +- Workflows can be wrapped as agents using Agent(client=workflow,) - AgentSession preserves conversation history - Each call to agent.run() includes session history + new message - Participants in the workflow see the full conversation context @@ -37,20 +37,21 @@ Use cases: - Long-running workflows that need pause/resume capability Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- Environment variables configured for AzureOpenAIResponsesClient +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured for FoundryChatClient """ async def main() -> None: # Create a chat client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - assistant = client.as_agent( + assistant = Agent( + client=client, name="assistant", instructions=( "You are a helpful assistant. Answer questions based on the conversation " @@ -58,7 +59,8 @@ async def main() -> None: ), ) - summarizer = client.as_agent( + summarizer = Agent( + client=client, name="summarizer", instructions=( "You are a summarizer. After the assistant responds, provide a brief " @@ -70,7 +72,7 @@ async def main() -> None: workflow = SequentialBuilder(participants=[assistant, summarizer]).build() # Wrap the workflow as an agent - agent = workflow.as_agent(name="ConversationalWorkflowAgent") + agent = Agent(client=workflow, name="ConversationalWorkflowAgent") # Create a session to maintain history session = agent.create_session() @@ -129,19 +131,20 @@ async def demonstrate_session_serialization() -> None: This shows how conversation history can be persisted and restored, enabling long-running conversational workflows. """ - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - memory_assistant = client.as_agent( + memory_assistant = Agent( + client=client, name="memory_assistant", instructions="You are a helpful assistant with good memory. Remember details from our conversation.", ) workflow = SequentialBuilder(participants=[memory_assistant]).build() - agent = workflow.as_agent(name="MemoryWorkflowAgent") + agent = Agent(client=workflow, name="MemoryWorkflowAgent") # Create initial session and have a conversation session = agent.create_session() diff --git a/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py index ed0f46ee92..86c07cfa47 100644 --- a/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py +++ b/python/samples/03-workflows/checkpoint/checkpoint_with_human_in_the_loop.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any from agent_framework import ( + Agent, AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, @@ -21,7 +22,7 @@ from agent_framework import ( handler, response_handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -178,11 +179,12 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow: # Wire the workflow DAG. Edges mirror the numbered steps described in the # module docstring. Because `WorkflowBuilder` is declarative, reading these # edges is often the quickest way to understand execution order. - writer_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + writer_agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions="Write concise, warm release notes that sound human and helpful.", name="writer", ) diff --git a/python/samples/03-workflows/checkpoint/workflow_as_agent_checkpoint.py b/python/samples/03-workflows/checkpoint/workflow_as_agent_checkpoint.py index 82a0fc035e..bb50e9f0a6 100644 --- a/python/samples/03-workflows/checkpoint/workflow_as_agent_checkpoint.py +++ b/python/samples/03-workflows/checkpoint/workflow_as_agent_checkpoint.py @@ -20,18 +20,19 @@ Key concepts: - These are complementary: sessions track conversation, checkpoints track workflow state Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- Environment variables configured for AzureOpenAIResponsesClient +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Environment variables configured for FoundryChatClient """ import asyncio import os from agent_framework import ( + Agent, InMemoryCheckpointStorage, InMemoryHistoryProvider, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -47,24 +48,26 @@ async def basic_checkpointing() -> None: print("Basic Checkpointing with Workflow as Agent") print("=" * 60) - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - assistant = client.as_agent( + assistant = Agent( + client=client, name="assistant", instructions="You are a helpful assistant. Keep responses brief.", ) - reviewer = client.as_agent( + reviewer = Agent( + client=client, name="reviewer", instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.", ) workflow = SequentialBuilder(participants=[assistant, reviewer]).build() - agent = workflow.as_agent(name="CheckpointedAgent") + agent = Agent(client=workflow, name="CheckpointedAgent") # Create checkpoint storage checkpoint_storage = InMemoryCheckpointStorage() @@ -92,19 +95,20 @@ async def checkpointing_with_thread() -> None: print("Checkpointing with Thread Conversation History") print("=" * 60) - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - assistant = client.as_agent( + assistant = Agent( + client=client, name="memory_assistant", instructions="You are a helpful assistant with good memory. Reference previous conversation when relevant.", ) workflow = SequentialBuilder(participants=[assistant]).build() - agent = workflow.as_agent(name="MemoryAgent") + agent = Agent(client=workflow, name="MemoryAgent") # Create both session (for conversation) and checkpoint storage (for workflow state) session = agent.create_session() @@ -139,19 +143,20 @@ async def streaming_with_checkpoints() -> None: print("Streaming with Checkpointing") print("=" * 60) - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - assistant = client.as_agent( + assistant = Agent( + client=client, name="streaming_assistant", instructions="You are a helpful assistant.", ) workflow = SequentialBuilder(participants=[assistant]).build() - agent = workflow.as_agent(name="StreamingCheckpointAgent") + agent = Agent(client=workflow, name="StreamingCheckpointAgent") checkpoint_storage = InMemoryCheckpointStorage() diff --git a/python/samples/03-workflows/composition/sub_workflow_kwargs.py b/python/samples/03-workflows/composition/sub_workflow_kwargs.py index b002db1da1..4404cf7b13 100644 --- a/python/samples/03-workflows/composition/sub_workflow_kwargs.py +++ b/python/samples/03-workflows/composition/sub_workflow_kwargs.py @@ -6,11 +6,12 @@ import os from typing import Annotated, Any from agent_framework import ( + Agent, Message, WorkflowExecutor, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -32,7 +33,7 @@ Key Concepts: - Useful for passing authentication tokens, configuration, or request context Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Environment variables configured """ @@ -81,14 +82,15 @@ async def main() -> None: print("=" * 70) # Create chat client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create an agent with tools that use kwargs - inner_agent = client.as_agent( + inner_agent = Agent( + client=client, name="data_agent", instructions=( "You are a data access agent. Use the available tools to help users. " diff --git a/python/samples/03-workflows/control-flow/edge_condition.py b/python/samples/03-workflows/control-flow/edge_condition.py index 73476c61a8..89999ecaa3 100644 --- a/python/samples/03-workflows/control-flow/edge_condition.py +++ b/python/samples/03-workflows/control-flow/edge_condition.py @@ -14,7 +14,7 @@ from agent_framework import ( # Core chat primitives used to build requests WorkflowContext, # Per-run context and event bus executor, # Decorator to declare a Python function as a workflow executor ) -from agent_framework.azure import AzureOpenAIResponsesClient # Thin client wrapper for Azure OpenAI chat models +from agent_framework.foundry import FoundryChatClient # Thin client wrapper for Azure OpenAI chat models from azure.identity import AzureCliCredential # Uses your az CLI login for credentials from dotenv import load_dotenv from pydantic import BaseModel # Structured outputs for safer parsing @@ -36,10 +36,10 @@ Purpose: - Illustrate how to transform one agent's structured result into a new AgentExecutorRequest for a downstream agent. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - You understand the basics of WorkflowBuilder, executors, and events in this framework. - You know the concept of edge conditions and how they gate routes using a predicate function. -- Azure OpenAI access is configured for AzureOpenAIResponsesClient. You should be logged in with Azure CLI (AzureCliCredential) +- Azure OpenAI access is configured for FoundryChatClient. You should be logged in with Azure CLI (AzureCliCredential) and have the Foundry V2 Project environment variables set as documented in the getting started chat client README. - The sample email resource file exists at workflow/resources/email.txt. @@ -136,11 +136,12 @@ async def to_email_assistant_request( def create_spam_detector_agent() -> Agent: """Helper to create a spam detection agent.""" # AzureCliCredential uses your current az login. This avoids embedding secrets in code. - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are a spam detection assistant that identifies spam emails. " "Always return JSON with fields is_spam (bool), reason (string), and email_content (string). " @@ -154,11 +155,12 @@ def create_spam_detector_agent() -> Agent: def create_email_assistant_agent() -> Agent: """Helper to create an email assistant agent.""" # AzureCliCredential uses your current az login. This avoids embedding secrets in code. - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are an email assistant that helps users draft professional responses to emails. " "Your input may be a JSON object that includes 'email_content'; base your reply on that content. " diff --git a/python/samples/03-workflows/control-flow/multi_selection_edge_group.py b/python/samples/03-workflows/control-flow/multi_selection_edge_group.py index 0b3d4ae43f..a5a96ad14f 100644 --- a/python/samples/03-workflows/control-flow/multi_selection_edge_group.py +++ b/python/samples/03-workflows/control-flow/multi_selection_edge_group.py @@ -20,7 +20,7 @@ from agent_framework import ( WorkflowEvent, executor, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel @@ -47,7 +47,7 @@ Show how to: - Apply conditional persistence logic (short vs long emails). Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Familiarity with WorkflowBuilder, executors, edges, and events. - Understanding of multi-selection edge groups and how their selection function maps to target ids. - Experience with workflow state for persisting and reusing objects. @@ -188,11 +188,12 @@ async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[Never, def create_email_analysis_agent() -> Agent: """Creates the email analysis agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are a spam detection assistant that identifies spam emails. " "Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) " @@ -205,11 +206,12 @@ def create_email_analysis_agent() -> Agent: def create_email_assistant_agent() -> Agent: """Creates the email assistant agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=("You are an email assistant that helps users draft responses to emails with professionalism."), name="email_assistant_agent", default_options={"response_format": EmailResponse}, @@ -218,11 +220,12 @@ def create_email_assistant_agent() -> Agent: def create_email_summary_agent() -> Agent: """Creates the email summary agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=("You are an assistant that helps users summarize emails."), name="email_summary_agent", default_options={"response_format": EmailSummaryModel}, diff --git a/python/samples/03-workflows/control-flow/simple_loop.py b/python/samples/03-workflows/control-flow/simple_loop.py index 23bd3f2c70..3adc75625e 100644 --- a/python/samples/03-workflows/control-flow/simple_loop.py +++ b/python/samples/03-workflows/control-flow/simple_loop.py @@ -16,7 +16,7 @@ from agent_framework import ( WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -32,8 +32,8 @@ What it does: - The workflow completes when the correct number is guessed. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- Azure AI/ Azure OpenAI for `AzureOpenAIResponsesClient` agent. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure AI/ Azure OpenAI for `FoundryChatClient` agent. - Authentication via `azure-identity` — uses `AzureCliCredential()` (run `az login`). """ @@ -123,11 +123,12 @@ class ParseJudgeResponse(Executor): def create_judge_agent() -> Agent: """Create a judge agent that evaluates guesses.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=("You strictly respond with one of: MATCHED, ABOVE, BELOW based on the given target and guess."), name="judge_agent", ) diff --git a/python/samples/03-workflows/control-flow/switch_case_edge_group.py b/python/samples/03-workflows/control-flow/switch_case_edge_group.py index ccc8c57aca..b7c1ece95e 100644 --- a/python/samples/03-workflows/control-flow/switch_case_edge_group.py +++ b/python/samples/03-workflows/control-flow/switch_case_edge_group.py @@ -18,7 +18,7 @@ from agent_framework import ( # Core chat primitives used to form LLM requests WorkflowContext, # Per-run context and event bus executor, # Decorator to turn a function into a workflow executor ) -from agent_framework.azure import AzureOpenAIResponsesClient # Thin client for Azure OpenAI chat models +from agent_framework.foundry import FoundryChatClient # Thin client for Azure OpenAI chat models from azure.identity import AzureCliCredential # Uses your az CLI login for credentials from dotenv import load_dotenv from pydantic import BaseModel # Structured outputs with validation @@ -43,10 +43,10 @@ on that type. - Use ctx.yield_output() to provide workflow results - the workflow completes when idle with no pending work. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Familiarity with WorkflowBuilder, executors, edges, and events. - Understanding of switch-case edge groups and how Case and Default are evaluated in order. -- Working Azure OpenAI configuration for AzureOpenAIResponsesClient, with Azure CLI login and required environment variables. +- Working Azure OpenAI configuration for FoundryChatClient, with Azure CLI login and required environment variables. - Access to workflow/resources/ambiguous_email.txt, or accept the inline fallback string. """ @@ -159,11 +159,12 @@ async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[Neve def create_spam_detection_agent() -> Agent: """Create and return the spam detection agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are a spam detection assistant that identifies spam emails. " "Be less confident in your assessments. " @@ -177,11 +178,12 @@ def create_spam_detection_agent() -> Agent: def create_email_assistant_agent() -> Agent: """Create and return the email assistant agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=("You are an email assistant that helps users draft responses to emails with professionalism."), name="email_assistant_agent", default_options={"response_format": EmailResponse}, diff --git a/python/samples/03-workflows/declarative/agent_to_function_tool/main.py b/python/samples/03-workflows/declarative/agent_to_function_tool/main.py index d4346d826e..8f931d4bf5 100644 --- a/python/samples/03-workflows/declarative/agent_to_function_tool/main.py +++ b/python/samples/03-workflows/declarative/agent_to_function_tool/main.py @@ -22,11 +22,15 @@ import os from pathlib import Path from typing import Any -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent from agent_framework.declarative import WorkflowFactory +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from pydantic import BaseModel, Field +# Copyright (c) Microsoft. All rights reserved. + + # Pricing data for the order calculation ITEM_PRICES = { "pizza": {"small": 10.99, "medium": 14.99, "large": 18.99, "default": 14.99}, @@ -198,14 +202,15 @@ def format_order_confirmation(order_data: dict[str, Any], order_calculation: dic async def main(): """Run the agent to function tool workflow.""" # Create Azure OpenAI Responses client - chat_client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + chat_client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create the order analysis agent with structured output - order_analysis_agent = chat_client.as_agent( + order_analysis_agent = Agent( + client=chat_client, name="OrderAnalysisAgent", instructions=ORDER_ANALYSIS_INSTRUCTIONS, default_options={"response_format": OrderAnalysis}, diff --git a/python/samples/03-workflows/declarative/customer_support/main.py b/python/samples/03-workflows/declarative/customer_support/main.py index 5d38725040..03bd68cff5 100644 --- a/python/samples/03-workflows/declarative/customer_support/main.py +++ b/python/samples/03-workflows/declarative/customer_support/main.py @@ -27,12 +27,13 @@ import os import uuid from pathlib import Path -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent from agent_framework.declarative import ( AgentExternalInputRequest, AgentExternalInputResponse, WorkflowFactory, ) +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel, Field @@ -168,49 +169,55 @@ async def main() -> None: plugin = TicketingPlugin() # Create Azure OpenAI client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], # This sample has been tested only on `gpt-5.1` and may not work as intended on other models # This sample is known to fail on `gpt-5-mini` reasoning input (GH issue #4059) - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create agents with structured outputs - self_service_agent = client.as_agent( + self_service_agent = Agent( + client=client, name="SelfServiceAgent", instructions=SELF_SERVICE_INSTRUCTIONS, default_options={"response_format": SelfServiceResponse}, ) - ticketing_agent = client.as_agent( + ticketing_agent = Agent( + client=client, name="TicketingAgent", instructions=TICKETING_INSTRUCTIONS, tools=plugin.get_functions(), default_options={"response_format": TicketingResponse}, ) - routing_agent = client.as_agent( + routing_agent = Agent( + client=client, name="TicketRoutingAgent", instructions=TICKET_ROUTING_INSTRUCTIONS, tools=[plugin.get_ticket], default_options={"response_format": RoutingResponse}, ) - windows_support_agent = client.as_agent( + windows_support_agent = Agent( + client=client, name="WindowsSupportAgent", instructions=WINDOWS_SUPPORT_INSTRUCTIONS, tools=[plugin.get_ticket], default_options={"response_format": SupportResponse}, ) - resolution_agent = client.as_agent( + resolution_agent = Agent( + client=client, name="TicketResolutionAgent", instructions=RESOLUTION_INSTRUCTIONS, tools=[plugin.resolve_ticket], ) - escalation_agent = client.as_agent( + escalation_agent = Agent( + client=client, name="TicketEscalationAgent", instructions=ESCALATION_INSTRUCTIONS, tools=[plugin.get_ticket, plugin.send_notification], diff --git a/python/samples/03-workflows/declarative/deep_research/main.py b/python/samples/03-workflows/declarative/deep_research/main.py index 62c6afc573..d6dbb4d65d 100644 --- a/python/samples/03-workflows/declarative/deep_research/main.py +++ b/python/samples/03-workflows/declarative/deep_research/main.py @@ -25,14 +25,14 @@ import asyncio import os from pathlib import Path -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent from agent_framework.declarative import WorkflowFactory +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv from pydantic import BaseModel, Field -# Load environment variables from .env file -load_dotenv() +# Copyright (c) Microsoft. All rights reserved. + # Agent Instructions RESEARCH_INSTRUCTIONS = """In order to help begin addressing the user request, please answer the following pre-survey to the best of your ability. @@ -124,45 +124,52 @@ class ManagerResponse(BaseModel): async def main() -> None: """Run the deep research workflow.""" # Create Azure OpenAI client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create agents - research_agent = client.as_agent( + research_agent = Agent( + client=client, name="ResearchAgent", instructions=RESEARCH_INSTRUCTIONS, ) - planner_agent = client.as_agent( + planner_agent = Agent( + client=client, name="PlannerAgent", instructions=PLANNER_INSTRUCTIONS, ) - manager_agent = client.as_agent( + manager_agent = Agent( + client=client, name="ManagerAgent", instructions=MANAGER_INSTRUCTIONS, default_options={"response_format": ManagerResponse}, ) - summary_agent = client.as_agent( + summary_agent = Agent( + client=client, name="SummaryAgent", instructions=SUMMARY_INSTRUCTIONS, ) - knowledge_agent = client.as_agent( + knowledge_agent = Agent( + client=client, name="KnowledgeAgent", instructions=KNOWLEDGE_INSTRUCTIONS, ) - coder_agent = client.as_agent( + coder_agent = Agent( + client=client, name="CoderAgent", instructions=CODER_INSTRUCTIONS, ) - weather_agent = client.as_agent( + weather_agent = Agent( + client=client, name="WeatherAgent", instructions=WEATHER_INSTRUCTIONS, ) diff --git a/python/samples/03-workflows/declarative/function_tools/main.py b/python/samples/03-workflows/declarative/function_tools/main.py index 4606afcefb..b8f4ec44f9 100644 --- a/python/samples/03-workflows/declarative/function_tools/main.py +++ b/python/samples/03-workflows/declarative/function_tools/main.py @@ -11,8 +11,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Annotated, Any -from agent_framework import FileCheckpointStorage, tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, FileCheckpointStorage, tool +from agent_framework.foundry import FoundryChatClient from agent_framework_declarative import ExternalInputRequest, ExternalInputResponse, WorkflowFactory from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -69,12 +69,13 @@ def get_item_price(name: Annotated[str, Field(description="Menu item name")]) -> async def main(): # Create agent with tools - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - menu_agent = client.as_agent( + menu_agent = Agent( + client=client, name="MenuAgent", instructions="Answer questions about menu items, specials, and prices.", tools=[get_menu, get_specials, get_item_price], diff --git a/python/samples/03-workflows/declarative/marketing/main.py b/python/samples/03-workflows/declarative/marketing/main.py index 26fcbd54dd..66a121e5b1 100644 --- a/python/samples/03-workflows/declarative/marketing/main.py +++ b/python/samples/03-workflows/declarative/marketing/main.py @@ -16,13 +16,13 @@ import asyncio import os from pathlib import Path -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent from agent_framework.declarative import WorkflowFactory +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() +# Copyright (c) Microsoft. All rights reserved. + ANALYST_INSTRUCTIONS = """You are a product analyst. Analyze the given product and identify: 1. Key features and benefits @@ -54,21 +54,24 @@ Return the final polished version.""" async def main() -> None: """Run the marketing workflow with real Azure AI agents.""" - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - analyst_agent = client.as_agent( + analyst_agent = Agent( + client=client, name="AnalystAgent", instructions=ANALYST_INSTRUCTIONS, ) - writer_agent = client.as_agent( + writer_agent = Agent( + client=client, name="WriterAgent", instructions=WRITER_INSTRUCTIONS, ) - editor_agent = client.as_agent( + editor_agent = Agent( + client=client, name="EditorAgent", instructions=EDITOR_INSTRUCTIONS, ) diff --git a/python/samples/03-workflows/declarative/simple_workflow/main.py b/python/samples/03-workflows/declarative/simple_workflow/main.py index 132a7a8a19..4deb1532f1 100644 --- a/python/samples/03-workflows/declarative/simple_workflow/main.py +++ b/python/samples/03-workflows/declarative/simple_workflow/main.py @@ -12,26 +12,19 @@ async def main() -> None: """Run the simple greeting workflow.""" # Create a workflow factory factory = WorkflowFactory() - # Load the workflow from YAML workflow_path = Path(__file__).parent / "workflow.yaml" workflow = factory.create_workflow_from_yaml_path(workflow_path) - print(f"Loaded workflow: {workflow.name}") print("-" * 40) - # Run with default name print("\nRunning with default name:") result = await workflow.run({}) for output in result.get_outputs(): print(f" Output: {output}") - # Run with a custom name print("\nRunning with custom name 'Alice':") result = await workflow.run({"name": "Alice"}) - for output in result.get_outputs(): - print(f" Output: {output}") - print("\n" + "-" * 40) print("Workflow completed!") diff --git a/python/samples/03-workflows/declarative/student_teacher/main.py b/python/samples/03-workflows/declarative/student_teacher/main.py index 815821d348..415625a300 100644 --- a/python/samples/03-workflows/declarative/student_teacher/main.py +++ b/python/samples/03-workflows/declarative/student_teacher/main.py @@ -15,7 +15,7 @@ The workflow loops until the teacher gives congratulations or max turns reached. Prerequisites: - Azure OpenAI deployment with chat completion capability - Environment variables: - AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry Agent Service (V2) project endpoint + FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry Agent Service (V2) project endpoint AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name """ @@ -23,13 +23,13 @@ import asyncio import os from pathlib import Path -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent from agent_framework.declarative import WorkflowFactory +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() +# Copyright (c) Microsoft. All rights reserved. + STUDENT_INSTRUCTIONS = """You are a curious math student working on understanding mathematical concepts. When given a problem: @@ -56,19 +56,21 @@ Focus on building understanding, not just getting the right answer.""" async def main() -> None: """Run the student-teacher workflow with real Azure AI agents.""" # Create chat client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create student and teacher agents - student_agent = client.as_agent( + student_agent = Agent( + client=client, name="StudentAgent", instructions=STUDENT_INSTRUCTIONS, ) - teacher_agent = client.as_agent( + teacher_agent = Agent( + client=client, name="TeacherAgent", instructions=TEACHER_INSTRUCTIONS, ) diff --git a/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py index b7e6046d40..42e58083c0 100644 --- a/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_HITL.py @@ -6,6 +6,7 @@ from collections.abc import AsyncIterable from dataclasses import dataclass, field from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, AgentResponse, @@ -18,7 +19,7 @@ from agent_framework import ( handler, response_handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from typing_extensions import Never @@ -42,8 +43,8 @@ Demonstrates: - Handling human feedback and routing it to the appropriate agents. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Run `az login` before executing. """ @@ -168,21 +169,23 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: """Run the workflow and bridge human feedback between two agents.""" # Create the agents - writer_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + writer_agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="writer_agent", instructions=("You are a marketing writer."), tool_choice="required", ) - final_editor_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + final_editor_agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="final_editor_agent", instructions=( "You are an editor who polishes marketing copy after human approval. " diff --git a/python/samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py b/python/samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py index 83b0632f88..85850e78ce 100644 --- a/python/samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_approval_requests.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Annotated from agent_framework import ( + Agent, AgentExecutorResponse, Content, Executor, @@ -16,7 +17,7 @@ from agent_framework import ( handler, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from typing_extensions import Never @@ -51,7 +52,7 @@ Demonstrate: - Handling approval requests during workflow execution. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Azure AI Agent Service configured, along with the required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, edges, events, request_info events (type='request_info'), and streaming runs. @@ -224,11 +225,12 @@ async def conclude_workflow( async def main() -> None: # Create agent - email_writer_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + email_writer_agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="EmailWriter", instructions=("You are an excellent email assistant. You respond to incoming emails."), # tools with `approval_mode="always_require"` will trigger approval requests diff --git a/python/samples/03-workflows/human-in-the-loop/agents_with_declaration_only_tools.py b/python/samples/03-workflows/human-in-the-loop/agents_with_declaration_only_tools.py index 46cec3977e..1ab0ea81e7 100644 --- a/python/samples/03-workflows/human-in-the-loop/agents_with_declaration_only_tools.py +++ b/python/samples/03-workflows/human-in-the-loop/agents_with_declaration_only_tools.py @@ -16,7 +16,7 @@ Flow: 4. The workflow resumes — the agent sees the tool result and finishes. Prerequisites: - - AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. + - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Azure OpenAI endpoint configured via environment variables. - `az login` for AzureCliCredential. """ @@ -26,8 +26,8 @@ import json import os from typing import Any -from agent_framework import Content, FunctionTool, WorkflowBuilder -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, Content, FunctionTool, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -51,11 +51,13 @@ get_user_location = FunctionTool( async def main() -> None: - agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), - ).as_agent( + ) + agent = Agent( + client=_client, name="WeatherBot", instructions=( "You are a helpful weather assistant. " diff --git a/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py b/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py index 74e059b9f8..ea5717f337 100644 --- a/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py +++ b/python/samples/03-workflows/human-in-the-loop/concurrent_request_info.py @@ -17,8 +17,8 @@ Demonstrate: - Injecting human guidance for specific agents before aggregation 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 +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables - Authentication via azure-identity (run az login before executing) """ @@ -28,11 +28,12 @@ from collections.abc import AsyncIterable from typing import Any from agent_framework import ( + Agent, AgentExecutorResponse, Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import AgentRequestInfoResponse, ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -41,7 +42,7 @@ from dotenv import load_dotenv load_dotenv() # Store chat client at module level for aggregator access -_chat_client: AzureOpenAIResponsesClient | None = None +_chat_client: FoundryChatClient | None = None async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any: @@ -148,14 +149,15 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: global _chat_client - _chat_client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + _chat_client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create agents that analyze from different perspectives - technical_analyst = _chat_client.as_agent( + technical_analyst = Agent( + client=_chat_client, name="technical_analyst", instructions=( "You are a technical analyst. When given a topic, provide a technical " @@ -164,7 +166,8 @@ async def main() -> None: ), ) - business_analyst = _chat_client.as_agent( + business_analyst = Agent( + client=_chat_client, name="business_analyst", instructions=( "You are a business analyst. When given a topic, provide a business " @@ -173,7 +176,8 @@ async def main() -> None: ), ) - user_experience_analyst = _chat_client.as_agent( + user_experience_analyst = Agent( + client=_chat_client, name="ux_analyst", instructions=( "You are a UX analyst. When given a topic, provide a user experience " diff --git a/python/samples/03-workflows/human-in-the-loop/group_chat_request_info.py b/python/samples/03-workflows/human-in-the-loop/group_chat_request_info.py index 8d1e4a0192..c5364d8d47 100644 --- a/python/samples/03-workflows/human-in-the-loop/group_chat_request_info.py +++ b/python/samples/03-workflows/human-in-the-loop/group_chat_request_info.py @@ -18,8 +18,8 @@ Demonstrate: - Steering agent behavior with pre-agent human input 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 +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables - Authentication via azure-identity (run az login before executing) """ @@ -29,11 +29,12 @@ from collections.abc import AsyncIterable from typing import cast from agent_framework import ( + Agent, AgentExecutorResponse, Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import AgentRequestInfoResponse, GroupChatBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -96,14 +97,15 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create agents for a group discussion - optimist = client.as_agent( + optimist = Agent( + client=client, name="optimist", instructions=( "You are an optimistic team member. You see opportunities and potential " @@ -112,7 +114,8 @@ async def main() -> None: ), ) - pragmatist = client.as_agent( + pragmatist = Agent( + client=client, name="pragmatist", instructions=( "You are a pragmatic team member. You focus on practical implementation " @@ -121,7 +124,8 @@ async def main() -> None: ), ) - creative = client.as_agent( + creative = Agent( + client=client, name="creative", instructions=( "You are a creative team member. You propose innovative solutions and " @@ -131,7 +135,8 @@ async def main() -> None: ) # Orchestrator coordinates the discussion - orchestrator = client.as_agent( + orchestrator = Agent( + client=client, name="orchestrator", instructions=( "You are a discussion manager coordinating a team conversation between participants. " diff --git a/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py b/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py index f764de6cb7..801d95c8fa 100644 --- a/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py +++ b/python/samples/03-workflows/human-in-the-loop/guessing_game_with_human_input.py @@ -6,6 +6,7 @@ from collections.abc import AsyncIterable from dataclasses import dataclass from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, AgentResponseUpdate, @@ -17,7 +18,7 @@ from agent_framework import ( handler, response_handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel @@ -42,8 +43,8 @@ Demonstrate: - Driving the loop in application code with run and responses parameter. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs. """ @@ -196,11 +197,12 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: """Run the human-in-the-loop guessing game workflow.""" # Create agent and executor - guessing_agent = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + guessing_agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), name="GuessingAgent", instructions=( "You guess a number between 1 and 10. " diff --git a/python/samples/03-workflows/human-in-the-loop/sequential_request_info.py b/python/samples/03-workflows/human-in-the-loop/sequential_request_info.py index cfee77276d..d5294afe01 100644 --- a/python/samples/03-workflows/human-in-the-loop/sequential_request_info.py +++ b/python/samples/03-workflows/human-in-the-loop/sequential_request_info.py @@ -17,8 +17,8 @@ Demonstrate: - Injecting responses back into the workflow via run(responses=..., stream=True) 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 +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables - Authentication via azure-identity (run az login before executing) """ @@ -28,11 +28,12 @@ from collections.abc import AsyncIterable from typing import cast from agent_framework import ( + Agent, AgentExecutorResponse, Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import AgentRequestInfoResponse, SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -93,19 +94,21 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create agents for a sequential document review workflow - drafter = client.as_agent( + drafter = Agent( + client=client, name="drafter", instructions=("You are a document drafter. When given a topic, create a brief draft (2-3 sentences)."), ) - editor = client.as_agent( + editor = Agent( + client=client, name="editor", instructions=( "You are an editor. Review the draft and make improvements. " @@ -113,7 +116,8 @@ async def main() -> None: ), ) - finalizer = client.as_agent( + finalizer = Agent( + client=client, name="finalizer", instructions=( "You are a finalizer. Take the edited content and create a polished final version. " diff --git a/python/samples/03-workflows/orchestrations/concurrent_agents.py b/python/samples/03-workflows/orchestrations/concurrent_agents.py index 78e56b38bf..74e53f3970 100644 --- a/python/samples/03-workflows/orchestrations/concurrent_agents.py +++ b/python/samples/03-workflows/orchestrations/concurrent_agents.py @@ -4,8 +4,8 @@ import asyncio import os from typing import Any -from agent_framework import Message -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, Message +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -27,22 +27,23 @@ Demonstrates: - Workflow completion when idle with no pending work 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Familiarity with Workflow events (WorkflowEvent) """ async def main() -> None: - # 1) Create three domain agents using AzureOpenAIResponsesClient - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + # 1) Create three domain agents using FoundryChatClient + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - researcher = client.as_agent( + researcher = Agent( + client=client, instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -50,7 +51,8 @@ async def main() -> None: name="researcher", ) - marketer = client.as_agent( + marketer = Agent( + client=client, instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -58,7 +60,8 @@ async def main() -> None: name="marketer", ) - legal = client.as_agent( + legal = Agent( + client=client, instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." diff --git a/python/samples/03-workflows/orchestrations/concurrent_custom_agent_executors.py b/python/samples/03-workflows/orchestrations/concurrent_custom_agent_executors.py index 3e0a9b63c6..968247f850 100644 --- a/python/samples/03-workflows/orchestrations/concurrent_custom_agent_executors.py +++ b/python/samples/03-workflows/orchestrations/concurrent_custom_agent_executors.py @@ -13,7 +13,7 @@ from agent_framework import ( WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -30,15 +30,15 @@ and emit AgentExecutorResponse outputs, which allows reuse of the high-level ConcurrentBuilder API and the default aggregator. Demonstrates: -- Executors that create their Agent in __init__ (via AzureOpenAIResponsesClient) +- Executors that create their Agent in __init__ (via FoundryChatClient) - A @handler that converts AgentExecutorRequest -> AgentExecutorResponse - ConcurrentBuilder(participants=[...]) to build fan-out/fan-in - Default aggregator returning list[Message] (one user + one assistant per agent) - Workflow completion when all participants become idle 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -46,8 +46,9 @@ Prerequisites: class ResearcherExec(Executor): agent: Agent - def __init__(self, client: AzureOpenAIResponsesClient, id: str = "researcher"): - self.agent = client.as_agent( + def __init__(self, client: FoundryChatClient, id: str = "researcher"): + self.agent = Agent( + client=client, instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -66,8 +67,9 @@ class ResearcherExec(Executor): class MarketerExec(Executor): agent: Agent - def __init__(self, client: AzureOpenAIResponsesClient, id: str = "marketer"): - self.agent = client.as_agent( + def __init__(self, client: FoundryChatClient, id: str = "marketer"): + self.agent = Agent( + client=client, instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -86,8 +88,9 @@ class MarketerExec(Executor): class LegalExec(Executor): agent: Agent - def __init__(self, client: AzureOpenAIResponsesClient, id: str = "legal"): - self.agent = client.as_agent( + def __init__(self, client: FoundryChatClient, id: str = "legal"): + self.agent = Agent( + client=client, instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." @@ -104,9 +107,9 @@ class LegalExec(Executor): async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py b/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py index b622e0f6b7..ad3c849afe 100644 --- a/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py +++ b/python/samples/03-workflows/orchestrations/concurrent_custom_aggregator.py @@ -4,8 +4,8 @@ import asyncio import os from typing import Any -from agent_framework import Message -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, Message +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -18,7 +18,7 @@ Sample: Concurrent Orchestration with Custom Aggregator Build a concurrent workflow with ConcurrentBuilder that fans out one prompt to multiple domain agents and fans in their responses. Override the default -aggregator with a custom async callback that uses AzureOpenAIResponsesClient.get_response() +aggregator with a custom async callback that uses FoundryChatClient.get_response() to synthesize a concise, consolidated summary from the experts' outputs. The workflow completes when all participants become idle. @@ -29,34 +29,37 @@ Demonstrates: - Workflow output yielded with the synthesized summary string 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - researcher = client.as_agent( + researcher = Agent( + client=client, instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." ), name="researcher", ) - marketer = client.as_agent( + marketer = Agent( + client=client, instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." ), name="marketer", ) - legal = client.as_agent( + legal = Agent( + client=client, instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." diff --git a/python/samples/03-workflows/orchestrations/group_chat_agent_manager.py b/python/samples/03-workflows/orchestrations/group_chat_agent_manager.py index 2fecc34282..c1929f8fb1 100644 --- a/python/samples/03-workflows/orchestrations/group_chat_agent_manager.py +++ b/python/samples/03-workflows/orchestrations/group_chat_agent_manager.py @@ -9,7 +9,7 @@ from agent_framework import ( AgentResponseUpdate, Message, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import GroupChatBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -26,8 +26,8 @@ What it does: - Coordinates a researcher and writer agent to solve tasks collaboratively 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -43,9 +43,9 @@ Guidelines: async def main() -> None: # Create a Responses client using Azure OpenAI and Azure CLI credentials for all agents - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/03-workflows/orchestrations/group_chat_philosophical_debate.py b/python/samples/03-workflows/orchestrations/group_chat_philosophical_debate.py index a8dd2aebfe..902bd271c6 100644 --- a/python/samples/03-workflows/orchestrations/group_chat_philosophical_debate.py +++ b/python/samples/03-workflows/orchestrations/group_chat_philosophical_debate.py @@ -10,7 +10,7 @@ from agent_framework import ( AgentResponseUpdate, Message, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import GroupChatBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -39,8 +39,8 @@ Participants represent: - Doctor from Scandinavia (public health, equity, societal support) 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -48,10 +48,10 @@ Prerequisites: load_dotenv() -def _get_chat_client() -> AzureOpenAIResponsesClient: - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], +def _get_chat_client() -> FoundryChatClient: + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/03-workflows/orchestrations/group_chat_simple_selector.py b/python/samples/03-workflows/orchestrations/group_chat_simple_selector.py index 984b46c6a4..99d7e1a963 100644 --- a/python/samples/03-workflows/orchestrations/group_chat_simple_selector.py +++ b/python/samples/03-workflows/orchestrations/group_chat_simple_selector.py @@ -9,7 +9,7 @@ from agent_framework import ( AgentResponseUpdate, Message, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import GroupChatBuilder, GroupChatState from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -25,8 +25,8 @@ What it does: - Uses a pure Python function to control speaker selection based on conversation state 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -40,9 +40,9 @@ def round_robin_selector(state: GroupChatState) -> str: async def main() -> None: # Create a Responses client using Azure OpenAI and Azure CLI credentials for all agents - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/03-workflows/orchestrations/handoff_autonomous.py b/python/samples/03-workflows/orchestrations/handoff_autonomous.py index 7b86e73cf8..7fcb8842f2 100644 --- a/python/samples/03-workflows/orchestrations/handoff_autonomous.py +++ b/python/samples/03-workflows/orchestrations/handoff_autonomous.py @@ -11,7 +11,7 @@ from agent_framework import ( Message, resolve_agent_id, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -29,8 +29,8 @@ Routing Pattern: User -> Coordinator -> Specialist (iterates N times) -> Handoff -> Final Output 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. + - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. + - Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run `az login` before executing the sample. Key Concepts: @@ -43,10 +43,11 @@ load_dotenv() def create_agents( - client: AzureOpenAIResponsesClient, + client: FoundryChatClient, ) -> tuple[Agent, Agent, Agent]: """Create coordinator and specialists for autonomous iteration.""" - coordinator = client.as_agent( + coordinator = Agent( + client=client, instructions=( "You are a coordinator. You break down a user query into a research task and a summary task. " "Assign the two tasks to the appropriate specialists, one after the other." @@ -54,7 +55,8 @@ def create_agents( name="coordinator", ) - research_agent = client.as_agent( + research_agent = Agent( + client=client, instructions=( "You are a research specialist that explores topics thoroughly using web search. " "When given a research task, break it down into multiple aspects and explore each one. " @@ -66,7 +68,8 @@ def create_agents( name="research_agent", ) - summary_agent = client.as_agent( + summary_agent = Agent( + client=client, instructions=( "You summarize research findings. Provide a concise, well-organized summary. When done, return " "control to the coordinator." @@ -79,9 +82,9 @@ def create_agents( async def main() -> None: """Run an autonomous handoff workflow with specialist iteration enabled.""" - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) coordinator, research_agent, summary_agent = create_agents(client) diff --git a/python/samples/03-workflows/orchestrations/handoff_simple.py b/python/samples/03-workflows/orchestrations/handoff_simple.py index 1b4820ccbe..d288b51c0d 100644 --- a/python/samples/03-workflows/orchestrations/handoff_simple.py +++ b/python/samples/03-workflows/orchestrations/handoff_simple.py @@ -12,7 +12,7 @@ from agent_framework import ( WorkflowRunState, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -26,8 +26,8 @@ A handoff workflow defines a pattern that assembles agents in a mesh topology, a them to transfer control to each other based on the conversation context. 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. + - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. + - Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run `az login` before executing the sample. Key Concepts: @@ -60,17 +60,18 @@ def process_return(order_number: Annotated[str, "Order number to process return return f"Return initiated successfully for order {order_number}. You will receive return instructions via email." -def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Agent, Agent]: +def create_agents(client: FoundryChatClient) -> tuple[Agent, Agent, Agent, Agent]: """Create and configure the triage and specialist agents. Args: - client: The AzureOpenAIResponsesClient to use for creating agents. + client: The FoundryChatClient to use for creating agents. Returns: Tuple of (triage_agent, refund_agent, order_agent, return_agent) """ # Triage agent: Acts as the frontline dispatcher - triage_agent = client.as_agent( + triage_agent = Agent( + client=client, instructions=( "You are frontline support triage. Route customer issues to the appropriate specialist agents " "based on the problem described." @@ -79,7 +80,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age ) # Refund specialist: Handles refund requests - refund_agent = client.as_agent( + refund_agent = Agent( + client=client, instructions="You process refund requests.", name="refund_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -87,7 +89,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age ) # Order/shipping specialist: Resolves delivery issues - order_agent = client.as_agent( + order_agent = Agent( + client=client, instructions="You handle order and shipping inquiries.", name="order_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -95,7 +98,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age ) # Return specialist: Handles return requests - return_agent = client.as_agent( + return_agent = Agent( + client=client, instructions="You manage product return requests.", name="return_agent", # In a real application, an agent can have multiple tools; here we keep it simple @@ -195,9 +199,9 @@ async def main() -> None: replace the scripted_responses with actual user input collection. """ # Initialize the Azure OpenAI Responses client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/03-workflows/orchestrations/handoff_with_code_interpreter_file.py b/python/samples/03-workflows/orchestrations/handoff_with_code_interpreter_file.py index 627033e26d..d6de4efcb5 100644 --- a/python/samples/03-workflows/orchestrations/handoff_with_code_interpreter_file.py +++ b/python/samples/03-workflows/orchestrations/handoff_with_code_interpreter_file.py @@ -12,7 +12,7 @@ Verifies GitHub issue #2718: files generated by code interpreter in HandoffBuilder workflows can be properly retrieved. Prerequisites: - - AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. + - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - `az login` (Azure CLI authentication) - AZURE_AI_MODEL_DEPLOYMENT_NAME """ @@ -23,12 +23,13 @@ from collections.abc import AsyncIterable from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, Message, WorkflowEvent, WorkflowRunState, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -90,13 +91,14 @@ async def main() -> None: """Run a simple handoff workflow with code interpreter file generation.""" print("=== Handoff Workflow with Code Interpreter File Generation ===\n") - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - triage = client.as_agent( + triage = Agent( + client=client, name="triage_agent", instructions=( "You are a triage agent. Route code-related requests to the code_specialist. " @@ -107,7 +109,8 @@ async def main() -> None: code_interpreter_tool = client.get_code_interpreter_tool() - code_specialist = client.as_agent( + code_specialist = Agent( + client=client, name="code_specialist", instructions=( "You are a Python code specialist. Use the code interpreter to execute Python code " diff --git a/python/samples/03-workflows/orchestrations/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/03-workflows/orchestrations/handoff_with_tool_approval_checkpoint_resume.py index 1c47b2d1f5..e1e01c0415 100644 --- a/python/samples/03-workflows/orchestrations/handoff_with_tool_approval_checkpoint_resume.py +++ b/python/samples/03-workflows/orchestrations/handoff_with_tool_approval_checkpoint_resume.py @@ -14,7 +14,7 @@ from agent_framework import ( WorkflowEvent, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -45,9 +45,9 @@ Pattern: workflow.run(stream=True, checkpoint_id=..., responses=responses).) Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Azure CLI authentication (az login). -- Environment variables configured for AzureOpenAIResponsesClient. +- Environment variables configured for FoundryChatClient. """ CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints" @@ -60,10 +60,11 @@ def submit_refund(refund_description: str, amount: str, order_id: str) -> str: return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}" -def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Agent]: +def create_agents(client: FoundryChatClient) -> tuple[Agent, Agent, Agent]: """Create a simple handoff scenario: triage, refund, and order specialists.""" - triage = client.as_agent( + triage = Agent( + client=client, name="triage_agent", instructions=( "You are a customer service triage agent. Listen to customer issues and determine " @@ -72,7 +73,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age ), ) - refund = client.as_agent( + refund = Agent( + client=client, name="refund_agent", instructions=( "You are a refund specialist. Help customers with refund requests. " @@ -83,7 +85,8 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age tools=[submit_refund], ) - order = client.as_agent( + order = Agent( + client=client, name="order_agent", instructions=( "You are an order tracking specialist. Help customers track their orders. " @@ -97,9 +100,9 @@ def create_agents(client: AzureOpenAIResponsesClient) -> tuple[Agent, Agent, Age def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow: """Build the handoff workflow with checkpointing enabled.""" - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) triage, refund, order = create_agents(client) diff --git a/python/samples/03-workflows/orchestrations/magentic.py b/python/samples/03-workflows/orchestrations/magentic.py index b412fd0b9d..07ca80a61d 100644 --- a/python/samples/03-workflows/orchestrations/magentic.py +++ b/python/samples/03-workflows/orchestrations/magentic.py @@ -12,7 +12,7 @@ from agent_framework import ( Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import GroupChatRequestSentEvent, MagenticBuilder, MagenticProgressLedger from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -42,8 +42,8 @@ energy efficiency and CO2 emissions of several ML models, streams intermediate events, and prints the final answer. The workflow completes when idle. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -52,9 +52,9 @@ load_dotenv() async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/03-workflows/orchestrations/magentic_checkpoint.py b/python/samples/03-workflows/orchestrations/magentic_checkpoint.py index ab3da11c1d..fba3da09d1 100644 --- a/python/samples/03-workflows/orchestrations/magentic_checkpoint.py +++ b/python/samples/03-workflows/orchestrations/magentic_checkpoint.py @@ -15,7 +15,7 @@ from agent_framework import ( WorkflowEvent, WorkflowRunState, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import MagenticBuilder, MagenticPlanReviewRequest from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -39,8 +39,8 @@ Concepts highlighted here: `responses` mapping so we can inject the stored human reply during restoration. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -64,9 +64,9 @@ def build_workflow(checkpoint_storage: FileCheckpointStorage): name="ResearcherAgent", description="Collects background facts and references for the project.", instructions=("You are the research lead. Gather crisp bullet points the team should know."), - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) @@ -75,9 +75,9 @@ def build_workflow(checkpoint_storage: FileCheckpointStorage): name="WriterAgent", description="Synthesizes the final brief for stakeholders.", instructions=("You convert the research notes into a structured brief with milestones and risks."), - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) @@ -87,9 +87,9 @@ def build_workflow(checkpoint_storage: FileCheckpointStorage): name="MagenticManager", description="Orchestrator that coordinates the research and writing workflow", instructions="You coordinate a team to complete complex tasks efficiently.", - client=AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ), ) diff --git a/python/samples/03-workflows/orchestrations/magentic_human_plan_review.py b/python/samples/03-workflows/orchestrations/magentic_human_plan_review.py index 61f1cd412d..acfe43a750 100644 --- a/python/samples/03-workflows/orchestrations/magentic_human_plan_review.py +++ b/python/samples/03-workflows/orchestrations/magentic_human_plan_review.py @@ -12,7 +12,7 @@ from agent_framework import ( Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import MagenticBuilder, MagenticPlanReviewRequest, MagenticPlanReviewResponse from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -37,8 +37,8 @@ Plan review options: - revise(feedback): Provide textual feedback to modify the plan 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -100,9 +100,9 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/03-workflows/orchestrations/sequential_agents.py b/python/samples/03-workflows/orchestrations/sequential_agents.py index 916ecbee9c..8a64b22368 100644 --- a/python/samples/03-workflows/orchestrations/sequential_agents.py +++ b/python/samples/03-workflows/orchestrations/sequential_agents.py @@ -4,8 +4,8 @@ import asyncio import os from typing import cast -from agent_framework import Message -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, Message +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -29,26 +29,28 @@ Note on internal adapters: You can safely ignore them when focusing on agent progress. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ 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"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - writer = client.as_agent( + writer = Agent( + client=client, instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), name="writer", ) - reviewer = client.as_agent( + reviewer = Agent( + client=client, instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), name="reviewer", ) 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()) diff --git a/python/samples/03-workflows/orchestrations/sequential_custom_executors.py b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py index b46971cffe..79823ea643 100644 --- a/python/samples/03-workflows/orchestrations/sequential_custom_executors.py +++ b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py @@ -5,13 +5,14 @@ import os from typing import Any from agent_framework import ( + Agent, AgentExecutorResponse, Executor, Message, WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -33,8 +34,8 @@ Custom executor contract: - Emit the updated conversation via ctx.send_message([...]) 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. """ @@ -65,12 +66,13 @@ class Summarizer(Executor): async def main() -> None: # 1) Create a content agent - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - content = client.as_agent( + content = Agent( + client=client, instructions="Produce a concise paragraph answering the user's request.", name="content", ) diff --git a/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py b/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py index 0e45e70ada..3eaeb21ea0 100644 --- a/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py +++ b/python/samples/03-workflows/parallelism/fan_out_fan_in_edges.py @@ -5,6 +5,7 @@ import os from dataclasses import dataclass from agent_framework import ( + Agent, AgentExecutor, # Wraps a ChatAgent as an Executor for use in workflows AgentExecutorRequest, # The message bundle sent to an AgentExecutor AgentExecutorResponse, # The structured result returned by an AgentExecutor @@ -15,7 +16,7 @@ from agent_framework import ( WorkflowContext, # Per run context and event bus handler, # Decorator to mark an Executor method as invokable ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential # Uses your az CLI login for credentials from dotenv import load_dotenv from typing_extensions import Never @@ -35,9 +36,9 @@ Show how to construct a parallel branch pattern in workflows. Demonstrate: - Fan in by collecting a list of AgentExecutorResponse objects and reducing them to a single result. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Familiarity with WorkflowBuilder, executors, edges, events, and streaming runs. -- Azure OpenAI access configured for AzureOpenAIResponsesClient. Log in with Azure CLI and set any required environment variables. +- Azure OpenAI access configured for FoundryChatClient. Log in with Azure CLI and set any required environment variables. - Comfort reading AgentExecutorResponse.agent_response.text for assistant output aggregation. """ @@ -114,11 +115,12 @@ async def main() -> None: aggregator = AggregateInsights(id="aggregator") researcher = AgentExecutor( - AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -127,11 +129,12 @@ async def main() -> None: ) ) marketer = AgentExecutor( - AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -140,11 +143,12 @@ async def main() -> None: ) ) legal = AgentExecutor( - AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." diff --git a/python/samples/03-workflows/state-management/state_with_agents.py b/python/samples/03-workflows/state-management/state_with_agents.py index ad2fb7112d..b9e800ba04 100644 --- a/python/samples/03-workflows/state-management/state_with_agents.py +++ b/python/samples/03-workflows/state-management/state_with_agents.py @@ -16,7 +16,7 @@ from agent_framework import ( WorkflowContext, executor, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import BaseModel @@ -39,8 +39,8 @@ Show how to: - Compose agent backed executors with function style executors and yield the final output when the workflow completes. 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. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for FoundryChatClient with required environment variables. - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. - Familiarity with WorkflowBuilder, executors, conditional edges, and streaming runs. """ @@ -162,11 +162,12 @@ async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, st def create_spam_detection_agent() -> Agent: """Creates a spam detection agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are a spam detection assistant that identifies spam emails. " "Always return JSON with fields is_spam (bool) and reason (string)." @@ -179,11 +180,12 @@ def create_spam_detection_agent() -> Agent: def create_email_assistant_agent() -> Agent: """Creates an email assistant agent.""" - return AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You are an email assistant that helps users draft responses to emails with professionalism. " "Return JSON with a single field 'response' containing the drafted reply." diff --git a/python/samples/03-workflows/state-management/workflow_kwargs.py b/python/samples/03-workflows/state-management/workflow_kwargs.py index 12ed57b628..630eaafc52 100644 --- a/python/samples/03-workflows/state-management/workflow_kwargs.py +++ b/python/samples/03-workflows/state-management/workflow_kwargs.py @@ -5,8 +5,8 @@ import json import os from typing import Annotated, Any, cast -from agent_framework import Message, tool -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent, Message, tool +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -28,7 +28,7 @@ Key Concepts: - Works with Sequential, Concurrent, GroupChat, Handoff, and Magentic patterns Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - Environment variables configured """ @@ -81,14 +81,15 @@ async def main() -> None: print("=" * 70) # Create chat client - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) # Create agent with tools that use kwargs - agent = client.as_agent( + agent = Agent( + client=client, name="assistant", instructions=( "You are a helpful assistant. Use the available tools to help users. " diff --git a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py index c6a83c93a6..d11e4d3525 100644 --- a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py @@ -6,12 +6,13 @@ from collections.abc import AsyncIterable from typing import Annotated from agent_framework import ( + Agent, Content, Message, WorkflowEvent, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -44,7 +45,7 @@ Demonstrate: - Understanding that approval pauses only the agent that triggered it, not all agents. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - OpenAI or Azure OpenAI configured with the required environment variables. - Basic familiarity with ConcurrentBuilder and streaming workflow events. """ @@ -133,13 +134,14 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: # 3. Create two agents focused on different stocks but with the same tool sets - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - microsoft_agent = client.as_agent( + microsoft_agent = Agent( + client=client, name="MicrosoftAgent", instructions=( "You are a personal trading assistant focused on Microsoft (MSFT). " @@ -148,7 +150,8 @@ async def main() -> None: tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade], ) - google_agent = client.as_agent( + google_agent = Agent( + client=client, name="GoogleAgent", instructions=( "You are a personal trading assistant focused on Google (GOOGL). " diff --git a/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py index 7f384bb4cd..8fff4b7dd3 100644 --- a/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py @@ -6,12 +6,13 @@ from collections.abc import AsyncIterable from typing import Annotated, cast from agent_framework import ( + Agent, Content, Message, WorkflowEvent, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import GroupChatBuilder, GroupChatState from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -43,7 +44,7 @@ Demonstrate: - Multi-round group chat with tool approval interruption and resumption. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - OpenAI or Azure OpenAI configured with the required environment variables. - Basic familiarity with GroupChatBuilder and streaming workflow events. """ @@ -133,13 +134,14 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: # 3. Create specialized agents - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - qa_engineer = client.as_agent( + qa_engineer = Agent( + client=client, name="QAEngineer", instructions=( "You are a QA engineer responsible for running tests before deployment. " @@ -148,7 +150,8 @@ async def main() -> None: tools=[run_tests], ) - devops_engineer = client.as_agent( + devops_engineer = Agent( + client=client, name="DevOpsEngineer", instructions=( "You are a DevOps engineer responsible for deployments. First check staging " diff --git a/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py index c3ad0cf011..a6272b196c 100644 --- a/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py @@ -6,12 +6,13 @@ from collections.abc import AsyncIterable from typing import Annotated, cast from agent_framework import ( + Agent, Content, Message, WorkflowEvent, tool, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -44,7 +45,7 @@ Demonstrate: - Resuming workflow execution after approval via run(responses=..., stream=True). Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. - OpenAI or Azure OpenAI configured with the required environment variables. - Basic familiarity with SequentialBuilder and streaming workflow events. """ @@ -106,12 +107,13 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str async def main() -> None: # 2. Create the agent with tools (approval mode is set per-tool via decorator) - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) - database_agent = client.as_agent( + database_agent = Agent( + client=client, name="DatabaseAgent", instructions=( "You are a database assistant. You can view the database schema and execute " diff --git a/python/samples/03-workflows/visualization/concurrent_with_visualization.py b/python/samples/03-workflows/visualization/concurrent_with_visualization.py index 2786e792a2..f11b8d291b 100644 --- a/python/samples/03-workflows/visualization/concurrent_with_visualization.py +++ b/python/samples/03-workflows/visualization/concurrent_with_visualization.py @@ -5,6 +5,7 @@ import os from dataclasses import dataclass from agent_framework import ( + Agent, AgentExecutor, AgentExecutorRequest, AgentExecutorResponse, @@ -15,7 +16,7 @@ from agent_framework import ( WorkflowViz, handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv from typing_extensions import Never @@ -32,8 +33,8 @@ What it does: - Visualization: generate Mermaid and GraphViz representations via `WorkflowViz` and optionally export SVG. Prerequisites: -- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- Azure AI/ Azure OpenAI for `AzureOpenAIResponsesClient` agents. +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure AI/ Azure OpenAI for `FoundryChatClient` agents. - Authentication via `azure-identity` — uses `AzureCliCredential()` (run `az login`). - For visualization export: `pip install graphviz>=0.20.0` and install GraphViz binaries. """ @@ -96,11 +97,12 @@ async def main() -> None: # Create agent instances researcher = AgentExecutor( - AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," " opportunities, and risks." @@ -110,11 +112,12 @@ async def main() -> None: ) marketer = AgentExecutor( - AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You're a creative marketing strategist. Craft compelling value propositions and target messaging" " aligned to the prompt." @@ -124,11 +127,12 @@ async def main() -> None: ) legal = AgentExecutor( - AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=AzureCliCredential(), - ).as_agent( + Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ), instructions=( "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" " based on the prompt." diff --git a/python/samples/04-hosting/a2a/a2a_server.py b/python/samples/04-hosting/a2a/a2a_server.py index d797bef95d..de3f361457 100644 --- a/python/samples/04-hosting/a2a/a2a_server.py +++ b/python/samples/04-hosting/a2a/a2a_server.py @@ -10,7 +10,7 @@ from a2a.server.request_handlers.default_request_handler import DefaultRequestHa from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore from agent_definitions import AGENT_CARD_FACTORIES, AGENT_FACTORIES from agent_executor import AgentFrameworkExecutor -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -35,8 +35,8 @@ Usage: uv run python a2a_server.py --agent-type logistics --port 5002 Environment variables: - AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o) + FOUNDRY_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4o) """ @@ -66,21 +66,21 @@ def main() -> None: args = parse_args() # Validate environment - project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT") - deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + deployment_name = os.getenv("FOUNDRY_MODEL") if not project_endpoint: - print("Error: AZURE_AI_PROJECT_ENDPOINT environment variable is not set.") + print("Error: FOUNDRY_PROJECT_ENDPOINT environment variable is not set.") sys.exit(1) if not deployment_name: - print("Error: AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME environment variable is not set.") + print("Error: FOUNDRY_MODEL environment variable is not set.") sys.exit(1) # Create the LLM client credential = AzureCliCredential() - client = AzureOpenAIResponsesClient( + client = FoundryChatClient( project_endpoint=project_endpoint, - deployment_name=deployment_name, + model=deployment_name, credential=credential, ) diff --git a/python/samples/04-hosting/a2a/agent_definitions.py b/python/samples/04-hosting/a2a/agent_definitions.py index b0e87e485f..e66e84cd7d 100644 --- a/python/samples/04-hosting/a2a/agent_definitions.py +++ b/python/samples/04-hosting/a2a/agent_definitions.py @@ -15,7 +15,7 @@ from invoice_data import query_by_invoice_id, query_by_transaction_id, query_inv if TYPE_CHECKING: from agent_framework import Agent - from agent_framework.azure import AzureOpenAIResponsesClient + from agent_framework.foundry import FoundryChatClient # --------------------------------------------------------------------------- @@ -54,26 +54,29 @@ Quantity: 900 # --------------------------------------------------------------------------- -def create_invoice_agent(client: AzureOpenAIResponsesClient) -> Agent: +def create_invoice_agent(client: FoundryChatClient) -> Agent: """Create an invoice agent backed by the given client with query tools.""" - return client.as_agent( + return Agent( + client=client, name="InvoiceAgent", instructions=INVOICE_INSTRUCTIONS, tools=[query_invoices, query_by_transaction_id, query_by_invoice_id], ) -def create_policy_agent(client: AzureOpenAIResponsesClient) -> Agent: +def create_policy_agent(client: FoundryChatClient) -> Agent: """Create a policy agent backed by the given client.""" - return client.as_agent( + return Agent( + client=client, name="PolicyAgent", instructions=POLICY_INSTRUCTIONS, ) -def create_logistics_agent(client: AzureOpenAIResponsesClient) -> Agent: +def create_logistics_agent(client: FoundryChatClient) -> Agent: """Create a logistics agent backed by the given client.""" - return client.as_agent( + return Agent( + client=client, name="LogisticsAgent", instructions=LOGISTICS_INSTRUCTIONS, ) diff --git a/python/samples/04-hosting/a2a/invoice_data.py b/python/samples/04-hosting/a2a/invoice_data.py index 877a00b4d2..d37cde84c8 100644 --- a/python/samples/04-hosting/a2a/invoice_data.py +++ b/python/samples/04-hosting/a2a/invoice_data.py @@ -72,56 +72,116 @@ def _random_date_within_last_two_months() -> datetime: def _build_invoices() -> list[Invoice]: """Build 10 mock invoices.""" return [ - Invoice("TICKET-XYZ987", "INV789", "Contoso", _random_date_within_last_two_months(), [ - Product("T-Shirts", 150, 10.00), - Product("Hats", 200, 15.00), - Product("Glasses", 300, 5.00), - ]), - Invoice("TICKET-XYZ111", "INV111", "XStore", _random_date_within_last_two_months(), [ - Product("T-Shirts", 2500, 12.00), - Product("Hats", 1500, 8.00), - Product("Glasses", 200, 20.00), - ]), - Invoice("TICKET-XYZ222", "INV222", "Cymbal Direct", _random_date_within_last_two_months(), [ - Product("T-Shirts", 1200, 14.00), - Product("Hats", 800, 7.00), - Product("Glasses", 500, 25.00), - ]), - Invoice("TICKET-XYZ333", "INV333", "Contoso", _random_date_within_last_two_months(), [ - Product("T-Shirts", 400, 11.00), - Product("Hats", 600, 15.00), - Product("Glasses", 700, 5.00), - ]), - Invoice("TICKET-XYZ444", "INV444", "XStore", _random_date_within_last_two_months(), [ - Product("T-Shirts", 800, 10.00), - Product("Hats", 500, 18.00), - Product("Glasses", 300, 22.00), - ]), - Invoice("TICKET-XYZ555", "INV555", "Cymbal Direct", _random_date_within_last_two_months(), [ - Product("T-Shirts", 1100, 9.00), - Product("Hats", 900, 12.00), - Product("Glasses", 1200, 15.00), - ]), - Invoice("TICKET-XYZ666", "INV666", "Contoso", _random_date_within_last_two_months(), [ - Product("T-Shirts", 2500, 8.00), - Product("Hats", 1200, 10.00), - Product("Glasses", 1000, 6.00), - ]), - Invoice("TICKET-XYZ777", "INV777", "XStore", _random_date_within_last_two_months(), [ - Product("T-Shirts", 1900, 13.00), - Product("Hats", 1300, 16.00), - Product("Glasses", 800, 19.00), - ]), - Invoice("TICKET-XYZ888", "INV888", "Cymbal Direct", _random_date_within_last_two_months(), [ - Product("T-Shirts", 2200, 11.00), - Product("Hats", 1700, 8.50), - Product("Glasses", 600, 21.00), - ]), - Invoice("TICKET-XYZ999", "INV999", "Contoso", _random_date_within_last_two_months(), [ - Product("T-Shirts", 1400, 10.50), - Product("Hats", 1100, 9.00), - Product("Glasses", 950, 12.00), - ]), + Invoice( + "TICKET-XYZ987", + "INV789", + "Contoso", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 150, 10.00), + Product("Hats", 200, 15.00), + Product("Glasses", 300, 5.00), + ], + ), + Invoice( + "TICKET-XYZ111", + "INV111", + "XStore", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 2500, 12.00), + Product("Hats", 1500, 8.00), + Product("Glasses", 200, 20.00), + ], + ), + Invoice( + "TICKET-XYZ222", + "INV222", + "Cymbal Direct", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 1200, 14.00), + Product("Hats", 800, 7.00), + Product("Glasses", 500, 25.00), + ], + ), + Invoice( + "TICKET-XYZ333", + "INV333", + "Contoso", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 400, 11.00), + Product("Hats", 600, 15.00), + Product("Glasses", 700, 5.00), + ], + ), + Invoice( + "TICKET-XYZ444", + "INV444", + "XStore", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 800, 10.00), + Product("Hats", 500, 18.00), + Product("Glasses", 300, 22.00), + ], + ), + Invoice( + "TICKET-XYZ555", + "INV555", + "Cymbal Direct", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 1100, 9.00), + Product("Hats", 900, 12.00), + Product("Glasses", 1200, 15.00), + ], + ), + Invoice( + "TICKET-XYZ666", + "INV666", + "Contoso", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 2500, 8.00), + Product("Hats", 1200, 10.00), + Product("Glasses", 1000, 6.00), + ], + ), + Invoice( + "TICKET-XYZ777", + "INV777", + "XStore", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 1900, 13.00), + Product("Hats", 1300, 16.00), + Product("Glasses", 800, 19.00), + ], + ), + Invoice( + "TICKET-XYZ888", + "INV888", + "Cymbal Direct", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 2200, 11.00), + Product("Hats", 1700, 8.50), + Product("Glasses", 600, 21.00), + ], + ), + Invoice( + "TICKET-XYZ999", + "INV999", + "Contoso", + _random_date_within_last_two_months(), + [ + Product("T-Shirts", 1400, 10.50), + Product("Hats", 1100, 9.00), + Product("Glasses", 950, 12.00), + ], + ), ] diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py b/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py index 03e4a4d20e..9f0d21bffa 100644 --- a/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py +++ b/python/samples/04-hosting/azure_functions/01_single_agent/function_app.py @@ -1,27 +1,35 @@ # Copyright (c) Microsoft. All rights reserved. -"""Host a single Azure OpenAI-powered agent inside Azure Functions. +"""Host a single Foundry-powered agent inside Azure Functions. Components used in this sample: -- AzureOpenAIChatClient to call the Azure OpenAI chat deployment. +- FoundryChatClient to call the Foundry deployment. - AgentFunctionApp to expose HTTP endpoints via the Durable Functions extension. -Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` (plus `AZURE_OPENAI_API_KEY` or Azure CLI authentication) before starting the Functions host.""" +Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in +with Azure CLI before starting the Functions host.""" +import os from typing import Any -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient -from azure.identity import AzureCliCredential +from agent_framework import Agent +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() # 1. Instantiate the agent with the chosen deployment and instructions. def _create_agent() -> Any: """Create the Joker agent.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ), name="Joker", instructions="You are good at telling jokes.", ) diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template b/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template index 7d6ef15f82..1d8bc82e39 100644 --- a/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/01_single_agent/local.settings.json.template @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt b/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt index fc4ff0244e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt +++ b/python/samples/04-hosting/azure_functions/01_single_agent/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py index 2e805e43b2..eb978d3993 100644 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py @@ -1,21 +1,22 @@ # Copyright (c) Microsoft. All rights reserved. -"""Host multiple Azure OpenAI agents inside a single Azure Functions app. +"""Host multiple Foundry-powered agents inside a single Azure Functions app. Components used in this sample: -- AzureOpenAIChatClient to create agents bound to a shared Azure OpenAI deployment. +- FoundryChatClient to create agents bound to a shared Foundry deployment. - AgentFunctionApp to register multiple agents and expose dedicated HTTP endpoints. - Custom tool functions to demonstrate tool invocation from different agents. -Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, plus either -`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host.""" +Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host.""" import logging +import os from typing import Any -from agent_framework import tool -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient -from azure.identity import AzureCliCredential +from agent_framework import Agent, tool +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -59,15 +60,21 @@ def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, # 1. Create multiple agents, each with its own instruction set and tools. -client = AzureOpenAIChatClient(credential=AzureCliCredential()) +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), +) -weather_agent = client.as_agent( +weather_agent = Agent( + client=client, name="WeatherAgent", instructions="You are a helpful weather assistant. Provide current weather information.", tools=[get_weather], ) -math_agent = client.as_agent( +math_agent = Agent( + client=client, name="MathAgent", instructions="You are a helpful math assistant. Help users with calculations like tip calculations.", tools=[calculate_tip], diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template b/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template index 7d6ef15f82..1d8bc82e39 100644 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/local.settings.json.template @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt b/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt index fc4ff0244e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py b/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py index 61eda7d6c0..f74f081aad 100644 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py +++ b/python/samples/04-hosting/azure_functions/03_reliable_streaming/function_app.py @@ -5,12 +5,13 @@ This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams. Components used in this sample: -- AzureOpenAIChatClient to create the travel planner agent with tools. +- FoundryChatClient to create the travel planner agent with tools. - AgentFunctionApp with a Redis-based callback for persistent streaming. - Custom HTTP endpoint to resume streaming from any point using cursor-based pagination. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI (`az login`) for `AzureCliCredential` - Redis running (docker run -d --name redis -p 6379:6379 redis:latest) - DTS and Azurite running (see parent README) """ @@ -21,14 +22,14 @@ from datetime import timedelta import azure.functions as func import redis.asyncio as aioredis -from agent_framework import AgentResponseUpdate +from agent_framework import Agent, AgentResponseUpdate from agent_framework.azure import ( AgentCallbackContext, AgentFunctionApp, AgentResponseCallbackProtocol, - AzureOpenAIChatClient, ) -from azure.identity import AzureCliCredential +from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv from redis_stream_response_handler import RedisStreamResponseHandler, StreamChunk from tools import get_local_events, get_weather_forecast @@ -155,7 +156,12 @@ redis_callback = RedisStreamCallback() # Create the travel planner agent def create_travel_agent(): """Create the TravelPlanner agent with tools.""" - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ), name="TravelPlanner", instructions="""You are an expert travel planner who creates detailed, personalized travel itineraries. When asked to plan a trip, you should: diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template b/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template index b87786468e..a41192bda2 100644 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/03_reliable_streaming/local.settings.json.template @@ -5,10 +5,9 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "", "REDIS_CONNECTION_STRING": "redis://localhost:6379", - "REDIS_STREAM_TTL_MINUTES": "10" + "REDIS_STREAM_TTL_MINUTES": "10", + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt b/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt index c5fc4f2ec6..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt +++ b/python/samples/04-hosting/azure_functions/03_reliable_streaming/requirements.txt @@ -1,16 +1,15 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample # Azure authentication azure-identity - -# Redis client -redis diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py index 6418cd7180..c3bdcf963f 100644 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py +++ b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/function_app.py @@ -3,26 +3,24 @@ """Chain two runs of a single agent inside a Durable Functions orchestration. Components used in this sample: -- AzureOpenAIChatClient to construct the writer agent hosted by Agent Framework. +- FoundryChatClient to construct the writer agent hosted by Agent Framework. - AgentFunctionApp to surface HTTP and orchestration triggers via the Azure Functions extension. - Durable Functions orchestration to run sequential agent invocations on the same conversation session. -Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and either -`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host.""" +Prerequisites: configure `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host.""" import json import logging +import os from collections.abc import Generator from typing import Any import azure.functions as func -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +from agent_framework import Agent +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() +from azure.identity.aio import AzureCliCredential logger = logging.getLogger(__name__) @@ -38,7 +36,13 @@ def _create_writer_agent() -> Any: "when given an improved sentence you polish it further." ) - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + return Agent( + client=_client, name=WRITER_AGENT_NAME, instructions=instructions, ) diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template index 7d6ef15f82..1d8bc82e39 100644 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/local.settings.json.template @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt index fc4ff0244e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt +++ b/python/samples/04-hosting/azure_functions/04_single_agent_orchestration_chaining/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py index 3a64fa545a..4f68c5237f 100644 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py +++ b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/function_app.py @@ -3,23 +3,24 @@ """Fan out concurrent runs across two agents inside a Durable Functions orchestration. Components used in this sample: -- AzureOpenAIChatClient to create domain-specific agents hosted by Agent Framework. +- FoundryChatClient to create domain-specific agents hosted by Agent Framework. - AgentFunctionApp to expose orchestration and HTTP triggers. - Durable Functions orchestration that executes agent calls in parallel and aggregates results. -Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and either -`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host.""" +Prerequisites: configure `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host.""" import json import logging +import os from collections.abc import Generator from typing import Any, cast import azure.functions as func -from agent_framework import AgentResponse -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +from agent_framework import Agent, AgentResponse +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -34,14 +35,22 @@ CHEMIST_AGENT_NAME = "ChemistAgent" # 2. Instantiate both agents that the orchestration will run concurrently. def _create_agents() -> list[Any]: - client = AzureOpenAIChatClient(credential=AzureCliCredential()) - - physicist = client.as_agent( + physicist = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ), name=PHYSICIST_AGENT_NAME, instructions="You are an expert in physics. You answer questions from a physics perspective.", ) - chemist = client.as_agent( + chemist = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ), name=CHEMIST_AGENT_NAME, instructions="You are an expert in chemistry. You answer questions from a chemistry perspective.", ) diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template index 7d6ef15f82..1d8bc82e39 100644 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/local.settings.json.template @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt index fc4ff0244e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt +++ b/python/samples/04-hosting/azure_functions/05_multi_agent_orchestration_concurrency/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py index 64a071ca99..76fbec16be 100644 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py +++ b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/function_app.py @@ -3,29 +3,27 @@ """Route email requests through conditional orchestration with two agents. Components used in this sample: -- AzureOpenAIChatClient agents for spam detection and email drafting. +- FoundryChatClient agents for spam detection and email drafting. - AgentFunctionApp with Durable orchestration, activity, and HTTP triggers. - Pydantic models that validate payloads and agent JSON responses. -Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, -and either `AZURE_OPENAI_API_KEY` or sign in with Azure CLI before running the +Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before running the Functions host.""" import json import logging +import os from collections.abc import Generator, Mapping from typing import Any import azure.functions as func -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +from agent_framework import Agent +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity import AzureCliCredential -from dotenv import load_dotenv +from azure.identity.aio import AzureCliCredential from pydantic import BaseModel, ValidationError -# Load environment variables from .env file -load_dotenv() - logger = logging.getLogger(__name__) # 1. Define agent names shared across the orchestration. @@ -49,14 +47,20 @@ class EmailPayload(BaseModel): # 2. Instantiate both agents so they can be registered with AgentFunctionApp. def _create_agents() -> list[Any]: - client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) - spam_agent = client.as_agent( + spam_agent = Agent( + client=client, name=SPAM_AGENT_NAME, instructions="You are a spam detection assistant that identifies spam emails.", ) - email_agent = client.as_agent( + email_agent = Agent( + client=client, name=EMAIL_AGENT_NAME, instructions="You are an email assistant that helps users draft responses to emails with professionalism.", ) diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template index 7d6ef15f82..1d8bc82e39 100644 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/local.settings.json.template @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt index fc4ff0244e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt +++ b/python/samples/04-hosting/azure_functions/06_multi_agent_orchestration_conditionals/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md index 96174d80ef..4e90f3fb26 100644 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md +++ b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/README.md @@ -6,11 +6,11 @@ output or a maximum number of attempts is reached. ## Prerequisites -Complete the common setup instructions in `../README.md` to prepare the virtual environment, install dependencies, and configure Azure OpenAI and storage settings. +Complete the common setup instructions in `../README.md` to prepare the virtual environment, install dependencies, and configure Foundry and storage settings. ## What It Shows -- Identical environment variable usage (`AZURE_OPENAI_ENDPOINT`, - `AZURE_OPENAI_DEPLOYMENT`) and HTTP surface area (`/api/hitl/...`). +- Identical environment variable usage (`FOUNDRY_PROJECT_ENDPOINT`, + `FOUNDRY_MODEL`) and HTTP surface area (`/api/hitl/...`). - Durable orchestrations that pause for external events while maintaining deterministic state (`context.wait_for_external_event` + timed cancellation). - Activity functions that encapsulate the out-of-band operations such as notifying diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py index ecdc5ca1c5..f3e77db390 100644 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py +++ b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/function_app.py @@ -3,29 +3,27 @@ """Iterate on generated content with a human-in-the-loop Durable orchestration. Components used in this sample: -- AzureOpenAIChatClient for a single writer agent that emits structured JSON. +- FoundryChatClient for a single writer agent that emits structured JSON. - AgentFunctionApp with Durable orchestration, HTTP triggers, and activity triggers. - External events that pause the workflow until a human decision arrives or times out. -Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and -either `AZURE_OPENAI_API_KEY` or sign in with Azure CLI before running `func start`.""" +Prerequisites: configure `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before running `func start`.""" import json import logging +import os from collections.abc import Generator, Mapping from datetime import timedelta from typing import Any import azure.functions as func -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +from agent_framework import Agent +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext -from azure.identity import AzureCliCredential -from dotenv import load_dotenv +from azure.identity.aio import AzureCliCredential from pydantic import BaseModel, ValidationError -# Load environment variables from .env file -load_dotenv() - logger = logging.getLogger(__name__) # 1. Define orchestration constants used throughout the workflow. @@ -57,7 +55,13 @@ def _create_writer_agent() -> Any: "Return your response as JSON with 'title' and 'content' fields." ) - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + return Agent( + client=_client, name=WRITER_AGENT_NAME, instructions=instructions, ) diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template index 7d6ef15f82..1d8bc82e39 100644 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/local.settings.json.template @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt index fc4ff0244e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt +++ b/python/samples/04-hosting/azure_functions/07_single_agent_orchestration_hitl/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/README.md b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md index 80fb2370b8..ec085a8582 100644 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/README.md +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md @@ -30,14 +30,13 @@ See the [README.md](../README.md) file in the parent directory for complete setu ## Configuration -Update your `local.settings.json` with your Azure OpenAI credentials: +Update your `local.settings.json` with your Foundry project settings: ```json { "Values": { - "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "your-deployment-name", - "AZURE_OPENAI_KEY": "your-api-key-if-not-using-rbac" + "FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project", + "FOUNDRY_MODEL": "your-deployment-name" } } ``` diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py b/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py index a4580fa23c..2271aeb96b 100644 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/function_app.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -""" -Example showing how to configure AI agents with different trigger configurations. +"""Example showing how to configure AI agents with different trigger configurations. This sample demonstrates how to configure agents to be accessible as both HTTP endpoints and Model Context Protocol (MCP) tools, enabling flexible integration patterns for AI agent @@ -18,37 +17,48 @@ This sample creates three agents with different trigger configurations: - PlantAdvisor: Both HTTP and MCP tool triggers enabled Required environment variables: -- AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint -- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: Your Azure OpenAI deployment name +- FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint +- FOUNDRY_MODEL: Your Azure AI Foundry deployment name Authentication uses AzureCliCredential (Azure Identity). """ -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +import os + +from agent_framework import Agent +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() -# Create Azure OpenAI Chat Client +# Create Foundry chat client # This uses AzureCliCredential for authentication (requires 'az login') -client = AzureOpenAIChatClient() +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), +) # Define three AI agents with different roles # Agent 1: Joker - HTTP trigger only (default) -agent1 = client.as_agent( +agent1 = Agent( + client=client, name="Joker", instructions="You are good at telling jokes.", ) # Agent 2: StockAdvisor - MCP tool trigger only -agent2 = client.as_agent( +agent2 = Agent( + client=client, name="StockAdvisor", instructions="Check stock prices.", ) # Agent 3: PlantAdvisor - Both HTTP and MCP tool triggers -agent3 = client.as_agent( +agent3 = Agent( + client=client, name="PlantAdvisor", instructions="Recommend plants.", description="Get plant recommendations.", diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template b/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template index 6c98a7d1cb..f9d63b3273 100644 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/local.settings.json.template @@ -4,7 +4,7 @@ "FUNCTIONS_WORKER_RUNTIME": "python", "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt b/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt index fc4ff0244e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-azurefunctions # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions -e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/README.md b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/README.md index 8e3593b6d0..4486673973 100644 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/README.md +++ b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/README.md @@ -42,8 +42,8 @@ SharedState allows executors to pass large payloads (like email content) by refe ```json { "Values": { - "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "gpt-4o" + "FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project", + "FOUNDRY_MODEL": "gpt-4o" } } ``` diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py index 7b2317c58e..16f535af0c 100644 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py +++ b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py @@ -13,8 +13,8 @@ Show how to: - Compose agent backed executors with function style executors and yield the final output when the workflow completes. Prerequisites: -- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. -- Authentication via azure-identity. Use DefaultAzureCredential and run az login before executing the sample. +- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` for FoundryChatClient. +- Authentication uses `AzureCliCredential`; run `az login` before executing the sample. - Familiarity with WorkflowBuilder, executors, conditional edges, and streaming runs. """ @@ -25,6 +25,7 @@ from typing import Any from uuid import uuid4 from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, Message, @@ -33,18 +34,17 @@ from agent_framework import ( WorkflowContext, executor, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from agent_framework_azurefunctions import AgentFunctionApp -from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential from pydantic import BaseModel, ValidationError from typing_extensions import Never logger = logging.getLogger(__name__) # Environment variable names -AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" -AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" -AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" +FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT" +AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL" EMAIL_STATE_PREFIX = "email:" CURRENT_EMAIL_ID_KEY = "current_email_id" @@ -172,35 +172,29 @@ async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, st def _build_client_kwargs() -> dict[str, Any]: - """Build Azure OpenAI client configuration from environment variables.""" - endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) - if not endpoint: - raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") + """Build Foundry chat client configuration from environment variables.""" + project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV) + if not project_endpoint: + raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.") - deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) - if not deployment: + model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) + if not model: raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") - client_kwargs: dict[str, Any] = { - "endpoint": endpoint, - "deployment_name": deployment, + return { + "project_endpoint": project_endpoint, + "model": model, + "credential": AzureCliCredential(), } - api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) - if api_key: - client_kwargs["api_key"] = api_key - else: - client_kwargs["credential"] = AzureCliCredential() - - return client_kwargs - def _create_workflow() -> Workflow: """Create the email classification workflow with conditional routing.""" client_kwargs = _build_client_kwargs() - chat_client = AzureOpenAIChatClient(**client_kwargs) + chat_client = FoundryChatClient(**client_kwargs) - spam_detection_agent = chat_client.as_agent( + spam_detection_agent = Agent( + client=chat_client, instructions=( "You are a spam detection assistant that identifies spam emails. " "Always return JSON with fields is_spam (bool) and reason (string)." @@ -209,7 +203,8 @@ def _create_workflow() -> Workflow: name="spam_detection_agent", ) - email_assistant_agent = chat_client.as_agent( + email_assistant_agent = Agent( + client=chat_client, instructions=( "You are an email assistant that helps users draft responses to emails with professionalism. " "Return JSON with a single field 'response' containing the drafted reply." diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/local.settings.json.sample b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/local.settings.json.sample index 69c08a3386..eed2a5b6c0 100644 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/local.settings.json.sample +++ b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/local.settings.json.sample @@ -5,7 +5,7 @@ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", "FUNCTIONS_WORKER_RUNTIME": "python", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt index 39ad8a124f..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt +++ b/python/samples/04-hosting/azure_functions/09_workflow_shared_state/requirements.txt @@ -1,2 +1,15 @@ -agent-framework-azurefunctions +# Agent Framework packages +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry +# agent-framework-azurefunctions + +# Local installation (for development and testing) +# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. +# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. +-e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples +-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions +-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample + +# Azure authentication azure-identity diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.env.sample b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.env.sample index cf8fe3d05c..53a691a97a 100644 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.env.sample +++ b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/.env.sample @@ -1,4 +1,3 @@ -# Azure OpenAI Configuration -AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME= -AZURE_OPENAI_API_KEY= +# Foundry Configuration +FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/api/projects/your-project +FOUNDRY_MODEL= diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py index 831d860806..435502e753 100644 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py +++ b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py @@ -14,6 +14,11 @@ Key architectural points: This approach allows using the rich structure of `WorkflowBuilder` while leveraging the statefulness and durability of `DurableAIAgent`s. + +Prerequisites: +- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` +- Sign in with Azure CLI (`az login`) for `AzureCliCredential` +- Ensure Azurite and the Durable Task Scheduler emulator are running """ import logging @@ -22,6 +27,7 @@ from pathlib import Path from typing import Any from agent_framework import ( + Agent, AgentExecutorResponse, Case, Default, @@ -31,17 +37,16 @@ from agent_framework import ( WorkflowContext, handler, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from agent_framework_azurefunctions import AgentFunctionApp -from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential from pydantic import BaseModel, ValidationError from typing_extensions import Never logger = logging.getLogger(__name__) -AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" -AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" -AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" +FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT" +AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL" SPAM_AGENT_NAME = "SpamDetectionAgent" EMAIL_AGENT_NAME = "EmailAssistantAgent" @@ -87,27 +92,21 @@ class EmailPayload(BaseModel): def _build_client_kwargs() -> dict[str, Any]: - endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) - if not endpoint: - raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") + """Build Foundry chat client configuration from environment variables.""" + project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV) + if not project_endpoint: + raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.") - deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) - if not deployment: + model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) + if not model: raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") - client_kwargs: dict[str, Any] = { - "endpoint": endpoint, - "deployment_name": deployment, + return { + "project_endpoint": project_endpoint, + "model": model, + "credential": AzureCliCredential(), } - api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) - if api_key: - client_kwargs["api_key"] = api_key - else: - client_kwargs["credential"] = AzureCliCredential() - - return client_kwargs - # Executors for non-AI activities (defined at module level) class SpamHandlerExecutor(Executor): @@ -165,15 +164,17 @@ def is_spam_detected(message: Any) -> bool: def _create_workflow() -> Workflow: """Create the workflow definition.""" client_kwargs = _build_client_kwargs() - chat_client = AzureOpenAIChatClient(**client_kwargs) + chat_client = FoundryChatClient(**client_kwargs) - spam_agent = chat_client.as_agent( + spam_agent = Agent( + client=chat_client, name=SPAM_AGENT_NAME, instructions=SPAM_DETECTION_INSTRUCTIONS, default_options={"response_format": SpamDetectionResult}, ) - email_agent = chat_client.as_agent( + email_agent = Agent( + client=chat_client, name=EMAIL_AGENT_NAME, instructions=EMAIL_ASSISTANT_INSTRUCTIONS, default_options={"response_format": EmailResponse}, diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/local.settings.json.sample b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/local.settings.json.sample index 30edea6c08..1d8bc82e39 100644 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/local.settings.json.sample +++ b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/local.settings.json.sample @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "https://.openai.azure.com/", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/requirements.txt b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/requirements.txt index 792ae4864e..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/requirements.txt +++ b/python/samples/04-hosting/azure_functions/10_workflow_no_shared_state/requirements.txt @@ -1,3 +1,15 @@ -agent-framework-azurefunctions -agent-framework +# Agent Framework packages +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry +# agent-framework-azurefunctions + +# Local installation (for development and testing) +# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. +# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. +-e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples +-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions +-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample + +# Azure authentication azure-identity diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template b/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template index 1ef634f442..bfea3f5279 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/.env.template @@ -10,5 +10,4 @@ TASKHUB_NAME=default # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name -AZURE_OPENAI_API_KEY=your-api-key +AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md b/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md index 9d0c8a1878..99afd35edb 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/README.md @@ -98,7 +98,8 @@ The sample can run locally without Azure Functions infrastructure using DevUI: cp .env.template .env ``` -2. Configure `.env` with your Azure OpenAI credentials +2. Configure `.env` with your Azure OpenAI credentials (`AZURE_OPENAI_ENDPOINT` and + `AZURE_OPENAI_DEPLOYMENT_NAME`) 3. Install dependencies: ```bash diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py b/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py index d9d3ff6324..2ea10e8ce7 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py @@ -19,6 +19,11 @@ Key architectural points: - Different agents run in parallel when they're in the same iteration - Activities (executors) also run in parallel when pending together - Mixed agent/executor fan-outs execute concurrently + +Prerequisites: +- Configure `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME` +- Sign in with Azure CLI (`az login`) for `AzureCliCredential` +- Ensure Azurite and the Durable Task Scheduler emulator are running """ import json @@ -28,6 +33,7 @@ from dataclasses import dataclass from typing import Any from agent_framework import ( + Agent, AgentExecutorResponse, Executor, Workflow, @@ -36,18 +42,14 @@ from agent_framework import ( executor, handler, ) -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework_azurefunctions import AgentFunctionApp -from azure.identity import AzureCliCredential +from agent_framework.azure import AgentFunctionApp +from agent_framework.openai import OpenAIChatCompletionClient +from azure.identity.aio import AzureCliCredential, get_bearer_token_provider from pydantic import BaseModel from typing_extensions import Never logger = logging.getLogger(__name__) -AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" -AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" -AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" - # Agent names SENTIMENT_AGENT_NAME = "SentimentAnalysisAgent" KEYWORD_AGENT_NAME = "KeywordExtractionAgent" @@ -334,30 +336,6 @@ class MixedResultCollector(Executor): # ============================================================================ -def _build_client_kwargs() -> dict[str, Any]: - """Build Azure OpenAI client kwargs from environment variables.""" - endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) - if not endpoint: - raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") - - deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) - if not deployment: - raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") - - client_kwargs: dict[str, Any] = { - "endpoint": endpoint, - "deployment_name": deployment, - } - - api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) - if api_key: - client_kwargs["api_key"] = api_key - else: - client_kwargs["credential"] = AzureCliCredential() - - return client_kwargs - - def _create_workflow() -> Workflow: """Create the parallel workflow definition. @@ -381,11 +359,16 @@ def _create_workflow() -> Workflow: └─> statistics_processor ─┤ └──> final_report """ - client_kwargs = _build_client_kwargs() - chat_client = AzureOpenAIChatClient(**client_kwargs) + credential = AzureCliCredential() + + chat_client = OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default"), + ) # Create agents for parallel analysis - sentiment_agent = chat_client.as_agent( + sentiment_agent = Agent( + client=chat_client, name=SENTIMENT_AGENT_NAME, instructions=( "You are a sentiment analysis expert. Analyze the sentiment of the given text. " @@ -395,7 +378,8 @@ def _create_workflow() -> Workflow: default_options={"response_format": SentimentResult}, ) - keyword_agent = chat_client.as_agent( + keyword_agent = Agent( + client=chat_client, name=KEYWORD_AGENT_NAME, instructions=( "You are a keyword extraction expert. Extract important keywords and categories " @@ -406,7 +390,8 @@ def _create_workflow() -> Workflow: ) # Create summary agent for Pattern 3 (mixed parallel) - summary_agent = chat_client.as_agent( + summary_agent = Agent( + client=chat_client, name=SUMMARY_AGENT_NAME, instructions=( "You are a summarization expert. Given analysis results (sentiment and keywords), " diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample b/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample index 30edea6c08..23140d3eec 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/local.settings.json.sample @@ -5,8 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", - "AZURE_OPENAI_ENDPOINT": "https://.openai.azure.com/", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", - "AZURE_OPENAI_API_KEY": "" + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_DEPLOYMENT_NAME": "" } } diff --git a/python/samples/04-hosting/azure_functions/11_workflow_parallel/requirements.txt b/python/samples/04-hosting/azure_functions/11_workflow_parallel/requirements.txt index 792ae4864e..b40fb78706 100644 --- a/python/samples/04-hosting/azure_functions/11_workflow_parallel/requirements.txt +++ b/python/samples/04-hosting/azure_functions/11_workflow_parallel/requirements.txt @@ -1,3 +1,15 @@ -agent-framework-azurefunctions -agent-framework +# Agent Framework packages +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-openai +# agent-framework-azurefunctions + +# Local installation (for development and testing) +# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. +# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. +-e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/openai # OpenAI support - dependency for Azure OpenAI chat samples +-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions +-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample + +# Azure authentication azure-identity diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md b/python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md index 1b9f9eff87..68850bea64 100644 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md +++ b/python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md @@ -88,12 +88,12 @@ When running on Durable Functions, the HITL pattern maps to: cp local.settings.json.sample local.settings.json ``` -2. Update `local.settings.json` with your Azure OpenAI credentials: +2. Update `local.settings.json` with your Foundry project settings: ```json { "Values": { - "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "gpt-4o" + "FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project", + "FOUNDRY_MODEL": "gpt-4o" } } ``` diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py b/python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py index 11d11b4a92..3680df202f 100644 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py +++ b/python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py @@ -18,7 +18,7 @@ Key architectural points: - Durable Functions provides durability while waiting for human input Prerequisites: -- Azure OpenAI configured with required environment variables +- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` - Durable Task Scheduler connection string - Authentication via Azure CLI (az login) """ @@ -30,6 +30,7 @@ from dataclasses import dataclass from typing import Any from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, Executor, @@ -40,18 +41,17 @@ from agent_framework import ( handler, response_handler, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from agent_framework_azurefunctions import AgentFunctionApp -from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential from pydantic import BaseModel, ValidationError from typing_extensions import Never logger = logging.getLogger(__name__) # Environment variable names -AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" -AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" -AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" +FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT" +AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL" # Agent names CONTENT_ANALYZER_AGENT_NAME = "ContentAnalyzerAgent" @@ -307,28 +307,21 @@ class PublishExecutor(Executor): def _build_client_kwargs() -> dict[str, Any]: - """Build Azure OpenAI client configuration from environment variables.""" - endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) - if not endpoint: - raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") + """Build Foundry chat client configuration from environment variables.""" + project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV) + if not project_endpoint: + raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.") - deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) - if not deployment: + model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) + if not model: raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") - client_kwargs: dict[str, Any] = { - "endpoint": endpoint, - "deployment_name": deployment, + return { + "project_endpoint": project_endpoint, + "model": model, + "credential": AzureCliCredential(), } - api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) - if api_key: - client_kwargs["api_key"] = api_key - else: - client_kwargs["credential"] = AzureCliCredential() - - return client_kwargs - class InputRouterExecutor(Executor): """Routes incoming content submission to the analysis agent.""" @@ -379,10 +372,11 @@ class InputRouterExecutor(Executor): def _create_workflow() -> Workflow: """Create the content moderation workflow with HITL.""" client_kwargs = _build_client_kwargs() - chat_client = AzureOpenAIChatClient(**client_kwargs) + chat_client = FoundryChatClient(**client_kwargs) # Create the content analysis agent - content_analyzer_agent = chat_client.as_agent( + content_analyzer_agent = Agent( + client=chat_client, name=CONTENT_ANALYZER_AGENT_NAME, instructions=CONTENT_ANALYZER_INSTRUCTIONS, default_options={"response_format": ContentAnalysisResult}, diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/local.settings.json.sample b/python/samples/04-hosting/azure_functions/12_workflow_hitl/local.settings.json.sample index 69c08a3386..eed2a5b6c0 100644 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/local.settings.json.sample +++ b/python/samples/04-hosting/azure_functions/12_workflow_hitl/local.settings.json.sample @@ -5,7 +5,7 @@ "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", "TASKHUB_NAME": "default", "FUNCTIONS_WORKER_RUNTIME": "python", - "AZURE_OPENAI_ENDPOINT": "", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "" + "FOUNDRY_PROJECT_ENDPOINT": "", + "FOUNDRY_MODEL": "" } } diff --git a/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt b/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt index 39ad8a124f..c11cbc3552 100644 --- a/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt +++ b/python/samples/04-hosting/azure_functions/12_workflow_hitl/requirements.txt @@ -1,2 +1,15 @@ -agent-framework-azurefunctions +# Agent Framework packages +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry +# agent-framework-azurefunctions + +# Local installation (for development and testing) +# Each package must be listed explicitly because pip doesn't resolve uv workspace sources. +# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. +-e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples +-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions +-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample + +# Azure authentication azure-identity diff --git a/python/samples/04-hosting/azure_functions/README.md b/python/samples/04-hosting/azure_functions/README.md index 25e0f308d5..cd410771c6 100644 --- a/python/samples/04-hosting/azure_functions/README.md +++ b/python/samples/04-hosting/azure_functions/README.md @@ -11,7 +11,7 @@ All of these samples are set up to run in Azure Functions. Azure Functions has a - Install [Azurite storage emulator](https://learn.microsoft.com/en-us/azure/storage/common/storage-install-azurite?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&bc=%2Fazure%2Fstorage%2Fblobs%2Fbreadcrumb%2Ftoc.json&tabs=visual-studio%2Cblob-storage) -- Create an [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-foundry/models/openai) resource. Note the Azure OpenAI endpoint, deployment name, and the key (or ensure you can authenticate with `AzureCliCredential`). +- Create an [Azure AI Foundry project](https://learn.microsoft.com/azure/ai-foundry/) with an OpenAI model deployment. Note the Foundry project endpoint and deployment name, and ensure you can authenticate with `AzureCliCredential`. - Install a tool to execute HTTP calls, for example the [REST Client extension](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) @@ -39,8 +39,7 @@ source .venv/bin/activate - Install Python dependencies – from the sample directory, run `pip install -r requirements.txt` (or the equivalent in your active virtual environment). - - Copy `local.settings.json.template` to `local.settings.json`, then update `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` for Azure OpenAI authentication. The samples use `AzureCliCredential` by default, so ensure you're logged in via `az login`. - - Alternatively, you can use API key authentication by setting `AZURE_OPENAI_API_KEY` and updating the code to use `AzureOpenAIChatClient()` without the credential parameter. + - Copy `local.settings.json.template` to `local.settings.json`, then update `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`. The samples use `AzureCliCredential`, so ensure you're logged in via `az login`. - Keep `TASKHUB_NAME` set to `default` unless you plan to change the durable task hub name. - Run the command `func start` from the root of the sample diff --git a/python/samples/04-hosting/durabletask/01_single_agent/client.py b/python/samples/04-hosting/durabletask/01_single_agent/client.py index 917a53a74a..deb270e8db 100644 --- a/python/samples/04-hosting/durabletask/01_single_agent/client.py +++ b/python/samples/04-hosting/durabletask/01_single_agent/client.py @@ -7,8 +7,8 @@ registered agents, demonstrating how to interact with agents from external proce Prerequisites: - The worker must be running with the agent registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ @@ -17,7 +17,7 @@ import logging import os from agent_framework.azure import DurableAIAgentClient -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.client import DurableTaskSchedulerClient @@ -48,7 +48,7 @@ def get_client( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() dts_client = DurableTaskSchedulerClient( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt b/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt index 09ed7d18ad..860b74ae33 100644 --- a/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt +++ b/python/samples/04-hosting/durabletask/01_single_agent/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-durabletask # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - the main package for this sample # Azure authentication diff --git a/python/samples/04-hosting/durabletask/01_single_agent/sample.py b/python/samples/04-hosting/durabletask/01_single_agent/sample.py index 22d22927fd..86a74c73e2 100644 --- a/python/samples/04-hosting/durabletask/01_single_agent/sample.py +++ b/python/samples/04-hosting/durabletask/01_single_agent/sample.py @@ -1,16 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. """Single Agent Sample - Durable Task Integration (Combined Worker + Client) - This sample demonstrates running both the worker and client in a single process. The worker is started first to register the agent, then client operations are performed against the running worker. - Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) - To run this sample: python sample.py """ @@ -30,27 +27,21 @@ logger = logging.getLogger(__name__) def main(): """Main entry point - runs both worker and client in single process.""" logger.debug("Starting Durable Task Agent Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager with get_worker(log_handler=silent_handler) as dts_worker: # Register agents using helper function setup_worker(dts_worker) - # Start the worker dts_worker.start() logger.debug("Worker started and listening for requests...") - # Create the client using helper function agent_client = get_client(log_handler=silent_handler) - try: # Run client interactions using helper function run_client(agent_client) except Exception as e: logger.exception(f"Error during agent interaction: {e}") - logger.debug("Sample completed. Worker shutting down...") diff --git a/python/samples/04-hosting/durabletask/01_single_agent/worker.py b/python/samples/04-hosting/durabletask/01_single_agent/worker.py index 535b5d6fb2..b48c67db8c 100644 --- a/python/samples/04-hosting/durabletask/01_single_agent/worker.py +++ b/python/samples/04-hosting/durabletask/01_single_agent/worker.py @@ -6,8 +6,8 @@ This worker registers agents as durable entities and continuously listens for re The worker should run as a background service, processing incoming agent requests. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -16,8 +16,10 @@ import logging import os from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentWorker -from azure.identity import AzureCliCredential, DefaultAzureCredential +from agent_framework.azure import DurableAIAgentWorker +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -35,7 +37,12 @@ def create_joker_agent() -> Agent: Returns: Agent: The configured Joker agent """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ), name="Joker", instructions="You are good at telling jokes.", ) @@ -60,7 +67,7 @@ def get_worker( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerWorker( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/client.py b/python/samples/04-hosting/durabletask/02_multi_agent/client.py index df8c9c8e1a..5f69e875ed 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/client.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/client.py @@ -8,8 +8,8 @@ each with their own specialized capabilities and tools. Prerequisites: - The worker must be running with both agents registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ @@ -18,7 +18,7 @@ import logging import os from agent_framework.azure import DurableAIAgentClient -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.client import DurableTaskSchedulerClient @@ -49,7 +49,7 @@ def get_client( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() dts_client = DurableTaskSchedulerClient( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt b/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt index 09ed7d18ad..860b74ae33 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt +++ b/python/samples/04-hosting/durabletask/02_multi_agent/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-durabletask # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - the main package for this sample # Azure authentication diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py index 6357c145a2..47be55a6d9 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py @@ -1,16 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. """Multi-Agent Sample - Durable Task Integration (Combined Worker + Client) - This sample demonstrates running both the worker and client in a single process for multiple agents with different tools. The worker registers two agents (WeatherAgent and MathAgent), each with their own specialized capabilities. - Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) - To run this sample: python sample.py """ @@ -30,26 +27,21 @@ logger = logging.getLogger(__name__) def main(): """Main entry point - runs both worker and client in single process.""" logger.debug("Starting Durable Task Multi-Agent Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() # Create and start the worker using helper function and context manager with get_worker(log_handler=silent_handler) as dts_worker: # Register agents using helper function setup_worker(dts_worker) - # Start the worker dts_worker.start() logger.debug("Worker started and listening for requests...") - # Create the client using helper function agent_client = get_client(log_handler=silent_handler) - try: # Run client interactions using helper function run_client(agent_client) except Exception as e: logger.exception(f"Error during agent interaction: {e}") - logger.debug("Sample completed. Worker shutting down...") diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py index 1b7dc91c1a..ab27cb4edb 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py @@ -7,8 +7,8 @@ with their own specialized tools. This demonstrates how to host multiple agents with different capabilities in a single worker process. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -17,9 +17,11 @@ import logging import os from typing import Any -from agent_framework import tool -from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentWorker -from azure.identity import AzureCliCredential, DefaultAzureCredential +from agent_framework import Agent, tool +from agent_framework.azure import DurableAIAgentWorker +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker @@ -71,7 +73,13 @@ def create_weather_agent(): Returns: Agent: The configured Weather agent with weather tool """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ) + return Agent( + client=_client, name=WEATHER_AGENT_NAME, instructions="You are a helpful weather assistant. Provide current weather information.", tools=[get_weather], @@ -84,7 +92,13 @@ def create_math_agent(): Returns: Agent: The configured Math agent with calculation tools """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ) + return Agent( + client=_client, name=MATH_AGENT_NAME, instructions="You are a helpful math assistant. Help users with calculations like tip calculations.", tools=[calculate_tip], @@ -110,7 +124,7 @@ def get_worker( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerWorker( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py index 883ebeb483..caded2a17f 100644 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py +++ b/python/samples/04-hosting/durabletask/03_single_agent_streaming/client.py @@ -9,7 +9,7 @@ This client demonstrates: Prerequisites: - The worker must be running with the TravelPlanner agent registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL - Redis must be running - Durable Task Scheduler must be running """ @@ -21,7 +21,7 @@ from datetime import timedelta import redis.asyncio as aioredis from agent_framework.azure import DurableAIAgentClient -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.client import DurableTaskSchedulerClient from redis_stream_response_handler import RedisStreamResponseHandler @@ -76,7 +76,7 @@ def get_client( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() dts_client = DurableTaskSchedulerClient( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt b/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt index f8843a12ba..860b74ae33 100644 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt +++ b/python/samples/04-hosting/durabletask/03_single_agent_streaming/requirements.txt @@ -1,15 +1,14 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-durabletask # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - the main package for this sample # Azure authentication azure-identity - -# Redis client -redis diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py index 800f3597c5..0c6865b012 100644 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py +++ b/python/samples/04-hosting/durabletask/03_single_agent_streaming/sample.py @@ -1,19 +1,15 @@ # Copyright (c) Microsoft. All rights reserved. """Single Agent Streaming Sample - Durable Task Integration (Combined Worker + Client) - This sample demonstrates running both the worker and client in a single process with reliable Redis-based streaming for agent responses. - The worker is started first to register the TravelPlanner agent with Redis streaming callback, then client operations are performed against the running worker. - Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) - Redis must be running (e.g., docker run -d --name redis -p 6379:6379 redis:latest) - To run this sample: python sample.py """ @@ -33,27 +29,21 @@ logger = logging.getLogger(__name__) def main(): """Main entry point - runs both worker and client in single process.""" logger.debug("Starting Durable Task Agent Sample with Redis Streaming...") - silent_handler = logging.NullHandler() - # Create and start the worker using helper function and context manager with get_worker(log_handler=silent_handler) as dts_worker: # Register agents and callbacks using helper function setup_worker(dts_worker) - # Start the worker dts_worker.start() logger.debug("Worker started and listening for requests...") - # Create the client using helper function agent_client = get_client(log_handler=silent_handler) - try: # Run client interactions using helper function run_client(agent_client) except Exception as e: logger.exception(f"Error during agent interaction: {e}") - logger.debug("Sample completed. Worker shutting down...") diff --git a/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py b/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py index 983156147a..fdb4e1b0c1 100644 --- a/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py +++ b/python/samples/04-hosting/durabletask/03_single_agent_streaming/worker.py @@ -6,8 +6,8 @@ This worker registers the TravelPlanner agent with the Durable Task Scheduler and uses RedisStreamCallback to persist streaming responses to Redis for reliable delivery. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) - Start Redis (e.g., docker run -d --name redis -p 6379:6379 redis:latest) """ @@ -22,10 +22,11 @@ from agent_framework import Agent, AgentResponseUpdate from agent_framework.azure import ( AgentCallbackContext, AgentResponseCallbackProtocol, - AzureOpenAIChatClient, DurableAIAgentWorker, ) -from azure.identity import AzureCliCredential, DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker from redis_stream_response_handler import RedisStreamResponseHandler @@ -153,7 +154,13 @@ def create_travel_agent() -> "Agent": Returns: Agent: The configured TravelPlanner agent with travel planning tools. """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ) + return Agent( + client=_client, name="TravelPlanner", instructions="""You are an expert travel planner who creates detailed, personalized travel itineraries. When asked to plan a trip, you should: @@ -191,7 +198,7 @@ def get_worker( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerWorker( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py index 573683fca1..2d7c385793 100644 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py +++ b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/client.py @@ -8,8 +8,8 @@ how conversation context is maintained across multiple agent invocations. Prerequisites: - The worker must be running with the writer agent and orchestration registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ @@ -18,7 +18,7 @@ import json import logging import os -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from durabletask.azuremanaged.client import DurableTaskSchedulerClient # Configure logging @@ -45,7 +45,7 @@ def get_client( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerClient( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt index 09ed7d18ad..860b74ae33 100644 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt +++ b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-durabletask # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - the main package for this sample # Azure authentication diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py index 44b20c2265..529052315d 100644 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py +++ b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/sample.py @@ -1,22 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. """Single Agent Orchestration Chaining Sample - Durable Task Integration - This sample demonstrates chaining two invocations of the same agent inside a Durable Task orchestration while preserving the conversation state between runs. The orchestration runs the writer agent sequentially on a shared thread to refine text iteratively. - Components used: -- AzureOpenAIChatClient to construct the writer agent +- FoundryChatClient to construct the writer agent - DurableTaskSchedulerWorker and DurableAIAgentWorker for agent hosting - DurableTaskSchedulerClient and orchestration for sequential agent invocations - Thread management to maintain conversation context across invocations - Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker emulator) - To run this sample: python sample.py """ @@ -36,22 +32,17 @@ logger = logging.getLogger(__name__) def main(): """Main entry point - runs both worker and client in single process.""" logger.debug("Starting Single Agent Orchestration Chaining Sample...") - silent_handler = logging.NullHandler() # Create and start the worker using helper function and context manager with get_worker(log_handler=silent_handler) as dts_worker: # Register agents and orchestrations using helper function setup_worker(dts_worker) - # Start the worker dts_worker.start() logger.debug("Worker started and listening for requests...") - # Create the client using helper function client = get_client(log_handler=silent_handler) - logger.debug("CLIENT: Starting orchestration...") - # Run the client in the same process try: run_client(client) @@ -61,7 +52,6 @@ def main(): logger.exception(f"Error during orchestration: {e}") finally: logger.debug("Worker stopping...") - logger.debug("") logger.debug("Sample completed") diff --git a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py index 86a3b259f7..6ade7df680 100644 --- a/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py +++ b/python/samples/04-hosting/durabletask/04_single_agent_orchestration_chaining/worker.py @@ -7,8 +7,8 @@ chaining behavior by running the agent twice sequentially on the same thread, preserving conversation context between invocations. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -18,8 +18,10 @@ import os from collections.abc import Generator from agent_framework import Agent, AgentResponse -from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from azure.identity import AzureCliCredential, DefaultAzureCredential +from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker from durabletask.task import OrchestrationContext, Task @@ -49,7 +51,13 @@ def create_writer_agent() -> "Agent": "when given an improved sentence you polish it further." ) - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ) + return Agent( + client=_client, name=WRITER_AGENT_NAME, instructions=instructions, ) @@ -139,7 +147,7 @@ def get_worker( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerWorker( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py index 473a176def..f507f956d0 100644 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py +++ b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/client.py @@ -8,8 +8,8 @@ displays the aggregated results. Prerequisites: - The worker must be running with both agents and orchestration registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ @@ -18,7 +18,7 @@ import json import logging import os -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from durabletask.azuremanaged.client import DurableTaskSchedulerClient # Configure logging @@ -45,7 +45,7 @@ def get_client( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerClient( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt index 09ed7d18ad..860b74ae33 100644 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt +++ b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-durabletask # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - the main package for this sample # Azure authentication diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py index 808a45e6ea..666c967bc7 100644 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py +++ b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/sample.py @@ -1,19 +1,15 @@ # Copyright (c) Microsoft. All rights reserved. """Multi-Agent Orchestration Sample - Durable Task Integration (Combined Worker + Client) - This sample demonstrates running both the worker and client in a single process for concurrent multi-agent orchestration. The worker registers two domain-specific agents (physicist and chemist) and an orchestration function that runs them in parallel. - The orchestration uses OrchestrationAgentExecutor to execute agents concurrently and aggregate their responses. - Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) - To run this sample: python sample.py """ @@ -33,30 +29,24 @@ logger = logging.getLogger(__name__) def main(): """Main entry point - runs both worker and client in single process.""" logger.debug("Starting Durable Task Multi-Agent Orchestration Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() # Create and start the worker using helper function and context manager with get_worker(log_handler=silent_handler) as dts_worker: # Register agents and orchestrations using helper function setup_worker(dts_worker) - # Start the worker dts_worker.start() logger.debug("Worker started and listening for requests...") - # Create the client using helper function client = get_client(log_handler=silent_handler) - # Define the prompt prompt = "What is temperature?" logger.debug("CLIENT: Starting orchestration...") - try: # Run the client to start the orchestration run_client(client, prompt) except Exception as e: logger.exception(f"Error during sample execution: {e}") - logger.debug("Sample completed. Worker shutting down...") diff --git a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py index 18ede13ead..2251799078 100644 --- a/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py +++ b/python/samples/04-hosting/durabletask/05_multi_agent_orchestration_concurrency/worker.py @@ -7,8 +7,8 @@ function that runs them concurrently. The orchestration uses OrchestrationAgentE to execute agents in parallel and aggregate their responses. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -19,8 +19,10 @@ from collections.abc import Generator from typing import Any from agent_framework import Agent, AgentResponse -from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from azure.identity import AzureCliCredential, DefaultAzureCredential +from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker from durabletask.task import OrchestrationContext, Task, when_all @@ -43,7 +45,13 @@ def create_physicist_agent() -> "Agent": Returns: Agent: The configured Physicist agent """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ) + return Agent( + client=_client, name=PHYSICIST_AGENT_NAME, instructions="You are an expert in physics. You answer questions from a physics perspective.", ) @@ -55,7 +63,13 @@ def create_chemist_agent() -> "Agent": Returns: Agent: The configured Chemist agent """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ) + return Agent( + client=_client, name=CHEMIST_AGENT_NAME, instructions="You are an expert in chemistry. You answer questions from a chemistry perspective.", ) @@ -138,7 +152,7 @@ def get_worker( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerWorker( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md index dcd4a617c3..caa3ca03f5 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/README.md @@ -14,6 +14,11 @@ This sample demonstrates conditional orchestration logic with two agents that an See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. +This sample uses Azure OpenAI credentials: + +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_DEPLOYMENT_NAME` + ## Running the Sample With the environment setup, you can run the sample using the combined approach or separate worker and client processes: diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py index 0202763e74..2eac694ed6 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/client.py @@ -7,8 +7,8 @@ that uses conditional logic to either handle spam emails or draft professional r Prerequisites: - The worker must be running with both agents, orchestration, and activities registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ @@ -16,7 +16,7 @@ import asyncio import logging import os -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from durabletask.azuremanaged.client import DurableTaskSchedulerClient # Configure logging @@ -43,7 +43,7 @@ def get_client( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerClient( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt index 09ed7d18ad..4319e9bd74 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-openai # agent-framework-durabletask # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/openai # OpenAI support - dependency for Azure OpenAI chat samples -e ../../../../packages/durabletask # Durable Task support - the main package for this sample # Azure authentication diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py index 930c3b1f9d..a00e075d4a 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/sample.py @@ -10,8 +10,8 @@ The orchestration branches based on spam detection results, calling different activity functions to handle spam or send legitimate email responses. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) To run this sample: diff --git a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py index 28a3d54749..e9cda191f4 100644 --- a/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py +++ b/python/samples/04-hosting/durabletask/06_multi_agent_orchestration_conditionals/worker.py @@ -7,8 +7,8 @@ orchestration function that routes execution based on spam detection results. Ac handle side effects (spam handling and email sending). Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME +- Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -19,8 +19,11 @@ from collections.abc import Generator from typing import Any, cast from agent_framework import Agent, AgentResponse -from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from azure.identity import AzureCliCredential, DefaultAzureCredential +from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker +from agent_framework.openai import OpenAIChatCompletionClient +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential +from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider from dotenv import load_dotenv from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker from durabletask.task import ActivityContext, OrchestrationContext, Task @@ -64,7 +67,13 @@ def create_spam_agent() -> "Agent": Returns: Agent: The configured Spam Detection agent """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return Agent( + client=OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + api_key=get_async_bearer_token_provider( + AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" + ), + ), name=SPAM_AGENT_NAME, instructions="You are a spam detection assistant that identifies spam emails.", ) @@ -76,7 +85,13 @@ def create_email_agent() -> "Agent": Returns: Agent: The configured Email Assistant agent """ - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + return Agent( + client=OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + api_key=get_async_bearer_token_provider( + AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default" + ), + ), name=EMAIL_AGENT_NAME, instructions="You are an email assistant that helps users draft responses to emails with professionalism.", ) @@ -220,7 +235,7 @@ def get_worker( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerWorker( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py index cf29e31f65..0a4efc12e2 100644 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py +++ b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/client.py @@ -7,8 +7,8 @@ by starting an orchestration, sending approval/rejection events, and monitoring Prerequisites: - The worker must be running with the agent, orchestration, and activities registered -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ @@ -18,7 +18,7 @@ import logging import os import time -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from durabletask.azuremanaged.client import DurableTaskSchedulerClient from durabletask.client import OrchestrationState @@ -49,7 +49,7 @@ def get_client( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerClient( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt index 09ed7d18ad..860b74ae33 100644 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt +++ b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/requirements.txt @@ -1,11 +1,13 @@ # Agent Framework packages -# To use the deployed version, uncomment the line below and comment out the local installation lines +# To use the deployed version, uncomment the lines below and comment out the local installation lines +# agent-framework-foundry # agent-framework-durabletask # Local installation (for development and testing) # Each package must be listed explicitly because pip doesn't resolve uv workspace sources. # Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source. -e ../../../../packages/core # Core framework - base dependency for all packages +-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples -e ../../../../packages/durabletask # Durable Task support - the main package for this sample # Azure authentication diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py index d90d6c1aea..c99daf06f9 100644 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py +++ b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/sample.py @@ -1,19 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. """Human-in-the-Loop Orchestration Sample - Durable Task Integration - This sample demonstrates the HITL pattern with a WriterAgent that generates content and waits for human approval. The orchestration handles: - External event waiting (approval/rejection) - Timeout handling - Iterative refinement based on feedback - Activity functions for notifications and publishing - Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) - To run this sample: python sample.py """ @@ -32,28 +29,21 @@ logger = logging.getLogger() def main(): """Main entry point - runs both worker and client in single process.""" logger.debug("Starting Durable Task HITL Content Generation Sample (Combined Worker + Client)...") - silent_handler = logging.NullHandler() # Create and start the worker using helper function and context manager with get_worker(log_handler=silent_handler) as dts_worker: # Register agent, orchestration, and activities using helper function setup_worker(dts_worker) - # Start the worker dts_worker.start() logger.debug("Worker started and listening for requests...") - # Create the client using helper function client = get_client(log_handler=silent_handler) - try: logger.debug("CLIENT: Starting orchestration tests...") - run_interactive_client(client) - except Exception as e: logger.exception(f"Error during sample execution: {e}") - logger.debug("Sample completed. Worker shutting down...") diff --git a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py index fed74874f0..5f317b551b 100644 --- a/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py +++ b/python/samples/04-hosting/durabletask/07_single_agent_orchestration_hitl/worker.py @@ -7,8 +7,8 @@ a human-in-the-loop review workflow. The orchestration pauses for external event (human approval/rejection) with timeout handling, and iterates based on feedback. Prerequisites: -- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME - (plus AZURE_OPENAI_API_KEY or Azure CLI authentication) +- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -20,8 +20,10 @@ from datetime import timedelta from typing import Any, cast from agent_framework import Agent, AgentResponse -from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker -from azure.identity import AzureCliCredential, DefaultAzureCredential +from agent_framework.azure import DurableAIAgentOrchestrationContext, DurableAIAgentWorker +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker from durabletask.task import ActivityContext, OrchestrationContext, Task, when_any # type: ignore @@ -74,7 +76,13 @@ def create_writer_agent() -> "Agent": "Limit response to 300 words or less." ) - return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent( + _client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AsyncAzureCliCredential(), + ) + return Agent( + client=_client, name=WRITER_AGENT_NAME, instructions=instructions, ) @@ -298,7 +306,7 @@ def get_worker( logger.debug(f"Using taskhub: {taskhub_name}") logger.debug(f"Using endpoint: {endpoint_url}") - credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential() + credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential() return DurableTaskSchedulerWorker( host_address=endpoint_url, diff --git a/python/samples/04-hosting/durabletask/README.md b/python/samples/04-hosting/durabletask/README.md index fc38a1f19e..2f35ba65e2 100644 --- a/python/samples/04-hosting/durabletask/README.md +++ b/python/samples/04-hosting/durabletask/README.md @@ -55,22 +55,6 @@ az role assignment create ` More information on how to configure RBAC permissions for Azure OpenAI can be found in the [Azure OpenAI documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=cli). -### Setting an API key for the Azure OpenAI service - -As an alternative to configuring Azure RBAC permissions, you can set an API key for the Azure OpenAI service by setting the `AZURE_OPENAI_API_KEY` environment variable. - -Bash (Linux/macOS/WSL): - -```bash -export AZURE_OPENAI_API_KEY="your-api-key" -``` - -PowerShell: - -```powershell -$env:AZURE_OPENAI_API_KEY="your-api-key" -``` - ### Start Durable Task Scheduler Most samples use the Durable Task Scheduler (DTS) to support hosted agents and durable orchestrations. DTS also allows you to view the status of orchestrations and their inputs and outputs from a web UI. @@ -90,15 +74,15 @@ Each sample reads configuration from environment variables. You'll need to set t Bash (Linux/macOS/WSL): ```bash -export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" +export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" +export FOUNDRY_MODEL="your-deployment-name" ``` PowerShell: ```powershell -$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -$env:AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" +$env:FOUNDRY_MODEL="your-deployment-name" ``` ### Installing Dependencies diff --git a/python/samples/demos/ag_ui_workflow_handoff/README.md b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md similarity index 93% rename from python/samples/demos/ag_ui_workflow_handoff/README.md rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md index bd9a6b6a5f..51e1c9fc1c 100644 --- a/python/samples/demos/ag_ui_workflow_handoff/README.md +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md @@ -35,9 +35,9 @@ The backend uses Azure OpenAI responses and supports intent-driven, non-linear h From the Python repo root: ```bash -cd /Users/evmattso/git/agent-framework/python +cd python uv sync -uv run python samples/demos/ag_ui_workflow_handoff/backend/server.py +uv run python samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py ``` Backend default URL: @@ -47,8 +47,10 @@ Backend default URL: ## 2) Install Frontend Packages (npm) +From the `python/` directory (where Step 1 left you): + ```bash -cd /Users/evmattso/git/agent-framework/python/samples/demos/ag_ui_workflow_handoff/frontend +cd samples/05-end-to-end/ag_ui_workflow_handoff/frontend npm install ``` diff --git a/python/samples/demos/ag_ui_workflow_handoff/backend/server.py b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py similarity index 97% rename from python/samples/demos/ag_ui_workflow_handoff/backend/server.py rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py index 6fca903849..3a19b941fc 100644 --- a/python/samples/demos/ag_ui_workflow_handoff/backend/server.py +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py @@ -80,12 +80,12 @@ def lookup_order_details(order_id: str) -> dict[str, str]: def create_agents() -> tuple[Agent, Agent, Agent]: """Create triage, refund, and order agents for the handoff workflow.""" - from agent_framework.azure import AzureOpenAIResponsesClient + from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore new file mode 100644 index 0000000000..16c69217c0 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore @@ -0,0 +1,7 @@ +# dependencies +/node_modules + +# build artifacts +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/index.html b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/index.html rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts diff --git a/python/samples/05-end-to-end/chatkit-integration/app.py b/python/samples/05-end-to-end/chatkit-integration/app.py index d47ecc6160..b0084931c6 100644 --- a/python/samples/05-end-to-end/chatkit-integration/app.py +++ b/python/samples/05-end-to-end/chatkit-integration/app.py @@ -29,7 +29,7 @@ import uvicorn # Agent Framework imports from agent_framework import Agent, AgentResponseUpdate, FunctionResultContent, Message, Role, tool -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient # Agent Framework ChatKit integration from agent_framework_chatkit import ThreadItemConverter, stream_agent_response @@ -222,7 +222,7 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]): # For authentication, run `az login` command in terminal try: self.weather_agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=FoundryChatClient(credential=AzureCliCredential()), instructions=( "You are a helpful weather assistant with image analysis capabilities. " "You can provide weather information for any location, tell the current time, " diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py b/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py index a63912c615..d0edf632d8 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py +++ b/python/samples/05-end-to-end/evaluation/red_teaming/red_team_agent_sample.py @@ -15,8 +15,8 @@ import json import os from typing import Any -from agent_framework import Message -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent, Message +from agent_framework.foundry import FoundryChatClient from azure.ai.evaluation.red_team import AttackStrategy, RedTeam, RiskCategory from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -52,7 +52,8 @@ async def main() -> None: # Create the agent # Constructor automatically reads from environment variables: # AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT_NAME, AZURE_OPENAI_API_KEY - agent = AzureOpenAIChatClient(credential=credential).as_agent( + agent = Agent( + client=FoundryChatClient(credential=credential), name="FinancialAdvisor", instructions="""You are a professional financial advisor assistant. @@ -98,7 +99,7 @@ Your boundaries: # Create RedTeam instance red_team = RedTeam( - azure_ai_project=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + azure_ai_project=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential, risk_categories=[ RiskCategory.Violence, diff --git a/python/samples/05-end-to-end/evaluation/self_reflection/self_reflection.py b/python/samples/05-end-to-end/evaluation/self_reflection/self_reflection.py index d554531e35..8251e89e72 100644 --- a/python/samples/05-end-to-end/evaluation/self_reflection/self_reflection.py +++ b/python/samples/05-end-to-end/evaluation/self_reflection/self_reflection.py @@ -20,7 +20,7 @@ from typing import Any import openai import pandas as pd from agent_framework import Agent, Message -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.ai.projects import AIProjectClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -87,7 +87,7 @@ DEFAULT_JUDGE_MODEL = "gpt-5.2" def create_openai_client(): - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] credential = AzureCliCredential() project_client = AIProjectClient(endpoint=endpoint, credential=credential) return project_client.get_openai_client() @@ -97,7 +97,7 @@ def create_async_project_client(): from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential - return AsyncAIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=AsyncAzureCliCredential()) + return AsyncAIProjectClient(endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=AsyncAzureCliCredential()) def create_eval(client: openai.OpenAI, judge_model: str) -> openai.types.EvalCreateResponse: @@ -321,9 +321,9 @@ async def run_self_reflection_batch( load_dotenv(override=True) # Create agent, it loads environment variables AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT automatically - responses_client = AzureOpenAIResponsesClient( + responses_client = FoundryChatClient( project_client=project_client, - deployment_name=agent_model, + model=agent_model, ) # Load input data @@ -368,7 +368,7 @@ async def run_self_reflection_batch( try: result = await execute_query_with_self_reflection( client=client, - agent=responses_client.as_agent(instructions=row["system_instruction"]), + agent=Agent(client=responses_client, instructions=row["system_instruction"]), eval_object=eval_object, full_user_query=row["full_prompt"], context=row["context_document"], diff --git a/python/samples/05-end-to-end/hosted_agents/README.md b/python/samples/05-end-to-end/hosted_agents/README.md new file mode 100644 index 0000000000..4f067ee4ab --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/README.md @@ -0,0 +1,145 @@ +# Hosted Agent Samples + +These samples demonstrate how to build and host AI agents in Python using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) together with Microsoft Agent Framework. Each sample runs locally as a hosted agent and includes `Dockerfile` and `agent.yaml` assets for deployment to Microsoft Foundry. + +## Samples + +| Sample | Description | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| [`agent_with_hosted_mcp`](./agent_with_hosted_mcp/) | Hosted MCP tool that connects to Microsoft Learn via `https://learn.microsoft.com/api/mcp` | +| [`agent_with_text_search_rag`](./agent_with_text_search_rag/) | Retrieval-augmented generation using a custom `BaseContextProvider` with Contoso Outdoors sample data | +| [`agents_in_workflow`](./agents_in_workflow/) | Concurrent workflow that combines researcher, marketer, and legal specialist agents | +| [`agent_with_local_tools`](./agent_with_local_tools/) | Local Python tool execution for Seattle hotel search | +| [`writer_reviewer_agents_in_workflow`](./writer_reviewer_agents_in_workflow/) | Writer/Reviewer workflow using `AzureOpenAIResponsesClient` | + +## Common Prerequisites + +Before running any sample, ensure you have: + +1. Python 3.10 or later +2. [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed +3. An Azure OpenAI resource or a Microsoft Foundry project with a chat model deployment + +### Authenticate with Azure CLI + +All samples rely on Azure credentials. For local development, the simplest approach is Azure CLI authentication: + +```powershell +az login +az account show +``` + +## Running a Sample + +Each sample folder contains its own `requirements.txt`. Run commands from the specific sample directory you want to try. + +### Recommended: `uv` + +The sample dependencies include preview packages, so allow prerelease installs: + +```powershell +cd +uv venv .venv +uv pip install --prerelease=allow -r requirements.txt +uv run main.py +``` + +### Alternative: `venv` + +Windows PowerShell: + +```powershell +cd +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python main.py +``` + +macOS/Linux: + +```bash +cd +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +Each sample starts a hosted agent locally on `http://localhost:8088/`. + +## Environment Variable Setup + +You can either export variables in your shell or create a local `.env` file in the sample directory. + +Example `.env` for Azure OpenAI samples: + +```dotenv +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ +AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4.1 +``` + +Example `.env` for Foundry project samples: + +```dotenv +PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +MODEL_DEPLOYMENT_NAME=gpt-4.1 +``` + +## Interacting with the Agent + +After starting a sample, send requests to the Responses endpoint. + +PowerShell: + +```powershell +$body = @{ + input = "Your question here" + stream = $false +} | ConvertTo-Json + +Invoke-RestMethod -Uri "http://localhost:8088/responses" -Method Post -Body $body -ContentType "application/json" +``` + +curl: + +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{"input":"Your question here","stream":false}' +``` + +Example prompts by sample: + +| Sample | Example input | +| ------------------------------------ | ---------------------------------------------------------------------------- | +| `agent_with_hosted_mcp` | `What does Microsoft Learn say about managed identities in Azure?` | +| `agent_with_text_search_rag` | `What is Contoso Outdoors' return policy for refunds?` | +| `agents_in_workflow` | `Create a launch strategy for a budget-friendly electric SUV.` | +| `agent_with_local_tools` | `Find me Seattle hotels from 2025-03-15 to 2025-03-18 under $200 per night.` | +| `writer_reviewer_agents_in_workflow` | `Write a slogan for a new affordable electric SUV.` | + +## Deploying to Microsoft Foundry + +Each sample includes a `Dockerfile` and `agent.yaml` for deployment. For deployment steps, follow the hosted agents guidance in Microsoft Foundry: + +- [Hosted agents overview](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents) +- [Create a hosted agent with CLI](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?tabs=cli#create-a-hosted-agent) +- [Create a hosted agent in Visual Studio Code](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/vs-code-agents-workflow-pro-code?tabs=windows-powershell&pivots=python) + +## Troubleshooting + +### Missing Azure credentials + +If startup fails with authentication errors, run `az login` and verify the selected subscription with `az account show`. + +### Preview package install issues + +These samples depend on preview packages such as `azure-ai-agentserver-agentframework`. Use `uv pip install --prerelease=allow -r requirements.txt` or `pip install -r requirements.txt`. + +### ARM64 container images fail after deployment + +If you build images locally on ARM64 hardware such as Apple Silicon, build for `linux/amd64`: + +```bash +docker build --platform=linux/amd64 -t image . +``` diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py index 53ee10e6bf..fe6d4648c9 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py @@ -1,8 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from dotenv import load_dotenv # Load environment variables from .env file @@ -16,14 +17,13 @@ def main(): "server_label": "Microsoft_Learn_MCP", "server_url": "https://learn.microsoft.com/api/mcp", } - # Create an Agent using the Azure OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP - agent = AzureOpenAIChatClient(credential=DefaultAzureCredential()).as_agent( + agent = Agent( + client=FoundryChatClient(credential=AzureCliCredential()), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=mcp_tool, ) - # Run the agent as a hosted agent from_agent_framework(agent).run() diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.dockerignore b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.dockerignore new file mode 100644 index 0000000000..779bc67aae --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.dockerignore @@ -0,0 +1,66 @@ +# Virtual environments +.venv/ +venv/ +env/ +.python-version + +# Environment files with secrets +.env +.env.* +*.local + +# Python build artifacts +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Testing +.tox/ +.nox/ +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.mypy_cache/ + +# IDE and OS files +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Foundry config +.foundry/ +build-source-*/ + +# Git +.git/ +.gitignore + +# Docker +.dockerignore + +# Documentation +docs/ +*.md +!README.md +LICENSE diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample new file mode 100644 index 0000000000..7a7d4d5ec3 --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample @@ -0,0 +1,3 @@ +# IMPORTANT: Never commit .env to version control - add it to .gitignore +PROJECT_ENDPOINT= +MODEL_DEPLOYMENT_NAME= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/Dockerfile b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/Dockerfile new file mode 100644 index 0000000000..077fb78345 --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.14-slim + +WORKDIR /app + +COPY ./ . + +RUN pip install --upgrade pip && \ + if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md new file mode 100644 index 0000000000..41fa3660fa --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md @@ -0,0 +1,162 @@ +**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). + +Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. + +Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. + +Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. + +# What this sample demonstrates + +This sample demonstrates a **key advantage of code-based hosted agents**: + +- **Local Python tool execution** - Run custom Python functions as agent tools + +Code-based agents can execute **any Python code** you write. This sample includes a Seattle Hotel Agent with a `get_available_hotels` tool that searches for available hotels based on check-in/check-out dates and budget preferences. + +The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) and can be deployed to Microsoft Foundry using the Azure Developer CLI. + +## How It Works + +### Local Tools Integration + +In [main.py](main.py), the agent uses a local Python function (`get_available_hotels`) that simulates a hotel availability API. This demonstrates how code-based agents can execute custom server-side logic that prompt agents cannot access. + +The tool accepts: + +- **check_in_date** - Check-in date in YYYY-MM-DD format +- **check_out_date** - Check-out date in YYYY-MM-DD format +- **max_price** - Maximum price per night in USD (optional, defaults to $500) + +### Agent Hosting + +The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/), +which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +### Agent Deployment + +The hosted agent can be deployed to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Microsoft Foundry Project** + - Project created in [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry#microsoft-foundry-portals) + - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) + - Note your project endpoint URL and model deployment name + +2. **Azure CLI** + - Installed and authenticated + - Run `az login` and verify with `az account show` + +3. **Python 3.10 or higher** + - Verify your version: `python --version` + +### Environment Variables + +Set the following environment variables (matching `agent.yaml`): + +- `PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) +- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) + +This sample loads environment variables from a local `.env` file if present. + +Create a `.env` file in this directory with the following content: + +``` +PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +``` + +Or set them via PowerShell: + +```powershell +# Replace with your actual values +$env:PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +``` + +### Running the Sample + +**Recommended (`uv`):** + +We recommend using [uv](https://docs.astral.sh/uv/) to create and manage the virtual environment for this sample. + +```bash +uv venv .venv +uv pip install --prerelease=allow -r requirements.txt +uv run main.py +``` + +The sample depends on preview packages, so `--prerelease=allow` is required when installing with `uv`. + +**Alternative (`venv`):** + +If you do not have `uv` installed, you can use Python's built-in `venv` module instead: + +**Windows (PowerShell):** + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python main.py +``` + +**macOS/Linux:** + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +This will start the hosted agent locally on `http://localhost:8088/`. + +### Interacting with the Agent + +**PowerShell (Windows):** + +```powershell +$body = @{ + input = "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night" + stream = $false +} | ConvertTo-Json + +Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" +``` + +**Bash/curl (Linux/macOS):** + +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{"input": "Find me hotels in Seattle for March 20-23, 2025 under $200 per night","stream":false}' +``` + +The agent will use the `get_available_hotels` tool to search for available hotels matching your criteria. + +### Deploying the Agent to Microsoft Foundry + +To deploy your agent to Microsoft Foundry, follow the comprehensive deployment guide at https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend using `azd` cloud build**, which always builds images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds** + +Use this command to build the image locally: + +```shell +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml new file mode 100644 index 0000000000..159b31996d --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: agent-with-local-tools +# Brief description of what this agent does +description: > + A travel assistant agent that helps users find hotels in Seattle. + Demonstrates local Python tool execution - a key advantage of code-based + hosted agents over prompt agents. +metadata: + # Categorization tags for organizing and discovering agents + authors: + - Microsoft + tags: + - Azure AI AgentServer + - Microsoft Agent Framework + - Local Tools + - Travel Assistant + - Hotel Search +protocols: + - protocol: responses + version: v1 +environment_variables: + - name: PROJECT_ENDPOINT + value: ${PROJECT_ENDPOINT} + - name: MODEL_DEPLOYMENT_NAME + value: ${MODEL_DEPLOYMENT_NAME} \ No newline at end of file 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 new file mode 100644 index 0000000000..880162586b --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. +Uses Microsoft Agent Framework with Azure AI Foundry. +Ready for deployment to Foundry Hosted Agent service. +""" + +import asyncio +import os +from datetime import datetime +from typing import Annotated + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from azure.ai.agentserver.agentframework import from_agent_framework +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential + +# Configure these for your Foundry project +# Read the explicit variables present in the .env file +PROJECT_ENDPOINT = os.getenv("PROJECT_ENDPOINT") # e.g., "https://.services.ai.azure.com" +MODEL_DEPLOYMENT_NAME = os.getenv( + "MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini" +) # Your model deployment name e.g., "gpt-4.1-mini" + + +# Simulated hotel data for Seattle +SEATTLE_HOTELS = [ + { + "name": "Contoso Suites", + "price_per_night": 189, + "rating": 4.5, + "location": "Downtown", + }, + { + "name": "Fabrikam Residences", + "price_per_night": 159, + "rating": 4.2, + "location": "Pike Place Market", + }, + { + "name": "Alpine Ski House", + "price_per_night": 249, + "rating": 4.7, + "location": "Seattle Center", + }, + { + "name": "Margie's Travel Lodge", + "price_per_night": 219, + "rating": 4.4, + "location": "Waterfront", + }, + { + "name": "Northwind Inn", + "price_per_night": 139, + "rating": 4.0, + "location": "Capitol Hill", + }, + { + "name": "Relecloud Hotel", + "price_per_night": 99, + "rating": 3.8, + "location": "University District", + }, +] + + +def get_available_hotels( + check_in_date: Annotated[str, "Check-in date in YYYY-MM-DD format"], + check_out_date: Annotated[str, "Check-out date in YYYY-MM-DD format"], + max_price: Annotated[int, "Maximum price per night in USD (optional)"] = 500, +) -> str: + """ + Get available hotels in Seattle for the specified dates. + This simulates a call to a fake hotel availability API. + """ + try: + # Parse dates + check_in = datetime.strptime(check_in_date, "%Y-%m-%d") + check_out = datetime.strptime(check_out_date, "%Y-%m-%d") + + # Validate dates + if check_out <= check_in: + return "Error: Check-out date must be after check-in date." + + nights = (check_out - check_in).days + + # Filter hotels by price + available_hotels = [hotel for hotel in SEATTLE_HOTELS if hotel["price_per_night"] <= max_price] + + if not available_hotels: + return f"No hotels found in Seattle within your budget of ${max_price}/night." + + # Build response + result = f"Available hotels in Seattle from {check_in_date} to {check_out_date} ({nights} nights):\n\n" + + for hotel in available_hotels: + total_cost = hotel["price_per_night"] * nights + result += f"**{hotel['name']}**\n" + result += f" Location: {hotel['location']}\n" + result += f" Rating: {hotel['rating']}/5\n" + result += f" ${hotel['price_per_night']}/night (Total: ${total_cost})\n\n" + + return result + + except ValueError as e: + return f"Error parsing dates. Please use YYYY-MM-DD format. Details: {str(e)}" + + +def get_credential(): + """Will use Managed Identity when running in Azure, otherwise falls back to Azure CLI Credential.""" + return ManagedIdentityCredential() if os.getenv("MSI_ENDPOINT") else AzureCliCredential() + + +async def main(): + """Main function to run the agent as a web server.""" + async with get_credential() as credential: + client = FoundryChatClient( + project_endpoint=PROJECT_ENDPOINT, + model=MODEL_DEPLOYMENT_NAME, + credential=credential, + ) + agent = Agent( + client=client, + name="SeattleHotelAgent", + instructions="""You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. + +When a user asks about hotels in Seattle: +1. Ask for their check-in and check-out dates if not provided +2. Ask about their budget preferences if not mentioned +3. Use the get_available_hotels tool to find available options +4. Present the results in a friendly, informative way +5. Offer to help with additional questions about the hotels or Seattle + +Be conversational and helpful. If users ask about things outside of Seattle hotels, +politely let them know you specialize in Seattle hotel recommendations.""", + tools=[get_available_hotels], + ) + + print("Seattle Hotel Agent Server running on http://localhost:8088") + server = from_agent_framework(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/requirements.txt new file mode 100644 index 0000000000..247d88d7de --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/requirements.txt @@ -0,0 +1,2 @@ +azure-ai-agentserver-agentframework==1.0.0b16 +agent-framework-foundry \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py index 083da0d880..ef91d227f4 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py @@ -5,8 +5,8 @@ import sys from dataclasses import dataclass from typing import Any -from agent_framework import AgentSession, BaseContextProvider, Message, SessionContext -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent, AgentSession, BaseContextProvider, Message, SessionContext +from agent_framework.foundry import FoundryChatClient from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] from azure.identity import DefaultAzureCredential from dotenv import load_dotenv @@ -105,7 +105,8 @@ class TextSearchContextProvider(BaseContextProvider): def main(): # Create an Agent using the Azure OpenAI Chat Client - agent = AzureOpenAIChatClient(credential=DefaultAzureCredential()).as_agent( + agent = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), name="SupportSpecialist", instructions=( "You are a helpful support specialist for Contoso Outdoors. " diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py index 4afa83cd07..414cfea418 100644 --- a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from agent_framework_orchestrations import ConcurrentBuilder from azure.ai.agentserver.agentframework import from_agent_framework from azure.identity import DefaultAzureCredential # pyright: ignore[reportUnknownVariableType] @@ -12,21 +13,24 @@ load_dotenv() def main(): # Create agents - researcher = AzureOpenAIChatClient(credential=DefaultAzureCredential()).as_agent( + researcher = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), instructions=( "You're an expert market and product researcher. " "Given a prompt, provide concise, factual insights, opportunities, and risks." ), name="researcher", ) - marketer = AzureOpenAIChatClient(credential=DefaultAzureCredential()).as_agent( + marketer = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), instructions=( "You're a creative marketing strategist. " "Craft compelling value propositions and target messaging aligned to the prompt." ), name="marketer", ) - legal = AzureOpenAIChatClient(credential=DefaultAzureCredential()).as_agent( + legal = Agent( + client=FoundryChatClient(credential=DefaultAzureCredential()), instructions=( "You're a cautious legal/compliance reviewer. " "Highlight constraints, disclaimers, and policy concerns based on the prompt." @@ -38,7 +42,7 @@ def main(): workflow = ConcurrentBuilder(participants=[researcher, marketer, legal]).build() # Convert the workflow to an agent - workflow_agent = workflow.as_agent() + workflow_agent = Agent(client=workflow) # Run the agent as a hosted agent from_agent_framework(workflow_agent).run() diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.dockerignore b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.dockerignore new file mode 100644 index 0000000000..bdb7cbb34f --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.dockerignore @@ -0,0 +1,51 @@ +# Build artifacts +bin/ +obj/ + +# IDE and editor files +.vs/ +.vscode/ +*.user +*.suo +.foundry/ + +# Source control +.git/ + +# Documentation +README.md + +# Ignore files +.gitignore +.dockerignore + +# Logs +*.log + +# Temporary files +*.tmp +*.temp + +# OS files +.DS_Store +Thumbs.db + +# Package manager directories +node_modules/ +packages/ + +# Test results +TestResults/ +*.trx + +# Coverage reports +coverage/ +*.coverage +*.coveragexml + +# Local development config +appsettings.Development.json +.env + +.venv/ +__pycache__/ diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample new file mode 100644 index 0000000000..7a7d4d5ec3 --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample @@ -0,0 +1,3 @@ +# IMPORTANT: Never commit .env to version control - add it to .gitignore +PROJECT_ENDPOINT= +MODEL_DEPLOYMENT_NAME= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/Dockerfile b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/Dockerfile new file mode 100644 index 0000000000..077fb78345 --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.14-slim + +WORKDIR /app + +COPY ./ . + +RUN pip install --upgrade pip && \ + if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md new file mode 100644 index 0000000000..f4181f6e6b --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md @@ -0,0 +1,157 @@ +**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). + +Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. + +Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. + +Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. + +# What this sample demonstrates + +This sample demonstrates a **key advantage of code-based hosted agents**: + +- **Agents in Workflows** - Use AI agents as executors within a workflow pipeline + +Code-based agents can execute **any Python code** you write. This sample includes a multi-agent workflow where Writer and Reviewer agents collaborate to draft content and provide review feedback. + +The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) and can be deployed to Microsoft Foundry using the Azure Developer CLI. + +## How It Works + +### Agents in Workflows + +This sample demonstrates the integration of AI agents within a workflow pipeline. The workflow operates as follows: + +1. **Writer Agent** - Drafts content +2. **Reviewer Agent** - Reviews the draft and provides concise, actionable feedback + +### Agent Hosting + +The agent workflow is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/), +which provisions a REST API endpoint compatible with the OpenAI Responses protocol. + +### Agent Deployment + +The hosted agent workflow can be deployed to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Microsoft Foundry Project** + - Project created in [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry#microsoft-foundry-portals) + - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) + - Note your project endpoint URL and model deployment name + +2. **Azure CLI** + - Installed and authenticated + - Run `az login` and verify with `az account show` + +3. **Python 3.10 or higher** + - Verify your version: `python --version` + +### Environment Variables + +Set the following environment variables (matching `agent.yaml`): + +- `PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) +- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) + +This sample loads environment variables from a local `.env` file if present. + +Create a `.env` file in this directory with the following content: + +``` +PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +MODEL_DEPLOYMENT_NAME=gpt-4.1-mini +``` + +Or set them via PowerShell: + +```powershell +# Replace with your actual values +$env:PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +$env:MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +``` + +### Running the Sample + +**Recommended (`uv`):** + +We recommend using [uv](https://docs.astral.sh/uv/) to create and manage the virtual environment for this sample. + +```bash +uv venv .venv +uv pip install --prerelease=allow -r requirements.txt +uv run main.py +``` + +The sample depends on preview packages, so `--prerelease=allow` is required when installing with `uv`. + +**Alternative (`venv`):** + +If you do not have `uv` installed, you can use Python's built-in `venv` module instead: + +**Windows (PowerShell):** + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python main.py +``` + +**macOS/Linux:** + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +This will start the hosted agent locally on `http://localhost:8088/`. + +### Interacting with the Agent + +**PowerShell (Windows):** + +```powershell +$body = @{ + input = "Create a slogan for a new electric SUV that is affordable and fun to drive." + stream = $false +} | ConvertTo-Json + +Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" +``` + +**Bash/curl (Linux/macOS):** + +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{"input": "Create a slogan for a new electric SUV that is affordable and fun to drive.","stream":false}' +``` + +### Deploying the Agent to Microsoft Foundry + +To deploy your agent to Microsoft Foundry, follow the comprehensive deployment guide at https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend using `azd` cloud build**, which always builds images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds** + +Use this command to build the image locally: + +```shell +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml new file mode 100644 index 0000000000..9a5e63b83f --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml + +kind: hosted +name: writer-reviewer-agents-in-workflow +description: > + A multi-agent workflow featuring a Writer and Reviewer that collaborate + to create and refine content. +metadata: + authors: + - Microsoft + tags: + - Azure AI AgentServer + - Microsoft Agent Framework + - Multi-Agent Workflow + - Writer-Reviewer + - Content Creation +protocols: + - protocol: responses + version: v1 +environment_variables: + - name: PROJECT_ENDPOINT + value: ${PROJECT_ENDPOINT} + - name: MODEL_DEPLOYMENT_NAME + value: ${MODEL_DEPLOYMENT_NAME} \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py new file mode 100644 index 0000000000..757bad4f26 --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from contextlib import asynccontextmanager + +from agent_framework import Agent, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient +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 +# Read the explicit variables present in the .env file +PROJECT_ENDPOINT = os.getenv( + "PROJECT_ENDPOINT" +) # e.g., "https://.services.ai.azure.com/api/projects/" +MODEL_DEPLOYMENT_NAME = os.getenv( + "MODEL_DEPLOYMENT_NAME", "gpt-4.1-mini" +) # Your model deployment name e.g., "gpt-4.1-mini" + + +def get_credential(): + """Will use Managed Identity when running in Azure, otherwise falls back to Azure CLI Credential.""" + return ManagedIdentityCredential() if os.getenv("MSI_ENDPOINT") else AzureCliCredential() + + +@asynccontextmanager +async def create_agents(): + async with get_credential() as credential: + client = FoundryChatClient( + project_endpoint=PROJECT_ENDPOINT, + model=MODEL_DEPLOYMENT_NAME, + credential=credential, + ) + writer = Agent( + client=client, + name="Writer", + instructions="You are an excellent content writer. You create new content and edit contents based on the feedback.", + ) + reviewer = Agent( + client=client, + name="Reviewer", + instructions="You are an excellent content reviewer. Provide actionable feedback to the writer about the provided content in the most concise manner possible.", + ) + yield writer, reviewer + + +def create_workflow(writer, reviewer): + workflow = WorkflowBuilder(start_executor=writer).add_edge(writer, reviewer).build() + return Agent( + client=workflow, + ) + + +async def main() -> None: + """ + The writer and reviewer multi-agent workflow. + + Environment variables required: + - PROJECT_ENDPOINT: Your Microsoft Foundry project endpoint + - MODEL_DEPLOYMENT_NAME: Your Microsoft Foundry model deployment name + """ + + async with create_agents() as (writer, reviewer): + agent = create_workflow(writer, reviewer) + await from_agent_framework(agent).run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/requirements.txt b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/requirements.txt new file mode 100644 index 0000000000..515a4ec3f1 --- /dev/null +++ b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/requirements.txt @@ -0,0 +1,2 @@ +azure-ai-agentserver-agentframework==1.0.0b16 +agent-framework-foundry diff --git a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py index 8cd66d3dc1..189340d7ed 100644 --- a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py +++ b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py @@ -19,7 +19,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIChatClient +from agent_framework.foundry import FoundryChatClient from aiohttp import web from aiohttp.web_middlewares import middleware from dotenv import load_dotenv @@ -101,8 +101,9 @@ def get_weather( def build_agent() -> Agent: """Create and return the chat agent instance with weather tool registered.""" - return OpenAIChatClient().as_agent( - name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather + _client = FoundryChatClient() + return Agent( + client=_client, name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather ) diff --git a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py index 2e98f05b10..369e39655b 100644 --- a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py +++ b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py @@ -26,7 +26,7 @@ import os from typing import Any from agent_framework import Agent, AgentResponse, Message -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from agent_framework.microsoft import ( PurviewChatPolicyMiddleware, PurviewPolicyMiddleware, @@ -145,7 +145,7 @@ async def run_with_agent_middleware() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) purview_agent_middleware = PurviewPolicyMiddleware( build_credential(), @@ -182,8 +182,8 @@ async def run_with_chat_middleware() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", default="gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = AzureOpenAIChatClient( - deployment_name=deployment, + client = FoundryChatClient( + model=deployment, endpoint=endpoint, credential=AzureCliCredential(), middleware=[ @@ -231,7 +231,7 @@ async def run_with_custom_cache_provider() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) custom_cache = SimpleDictCacheProvider() @@ -271,7 +271,7 @@ async def run_with_custom_cache_provider() -> None: deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) # No cache_provider specified - uses default InMemoryCacheProvider purview_agent_middleware = PurviewPolicyMiddleware( diff --git a/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py index 12a4286de0..f0dbb504cc 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py +++ b/python/samples/05-end-to-end/workflow_evaluation/create_workflow.py @@ -46,6 +46,7 @@ from _tools import ( validate_payment_method, ) from agent_framework import ( + Agent, AgentExecutorResponse, AgentResponseUpdate, Executor, @@ -56,7 +57,7 @@ from agent_framework import ( executor, handler, ) -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import DefaultAzureCredential from dotenv import load_dotenv @@ -74,9 +75,10 @@ async def start_executor(input: str, ctx: WorkflowContext[list[Message]]) -> Non class ResearchLead(Executor): """Aggregates and summarizes travel planning findings from all specialized agents.""" - def __init__(self, client: AzureOpenAIResponsesClient, id: str = "travel-planning-coordinator"): + def __init__(self, client: FoundryChatClient, id: str = "travel-planning-coordinator"): # Use default_options to persist conversation history for evaluation. - self.agent = client.as_agent( + self.agent = Agent( + client=client, id="travel-planning-coordinator", instructions=( "You are the final coordinator. You will receive responses from multiple agents: " @@ -143,13 +145,13 @@ class ResearchLead(Executor): async def run_workflow_with_response_tracking( - query: str, client: AzureOpenAIResponsesClient | None = None, deployment_name: str | None = None + query: str, client: FoundryChatClient | None = None, deployment_name: str | None = None ) -> dict: """Run multi-agent workflow and track conversation IDs, response IDs, and interaction sequence. Args: query: The user query to process through the multi-agent workflow - client: Optional AzureOpenAIResponsesClient instance + client: Optional FoundryChatClient instance deployment_name: Optional model deployment name for the workflow agents Returns: @@ -159,12 +161,12 @@ async def run_workflow_with_response_tracking( try: async with DefaultAzureCredential() as credential: project_client = AIProjectClient( - endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential, ) async with project_client: - client = AzureOpenAIResponsesClient(project_client=project_client, deployment_name=deployment_name) + client = FoundryChatClient(project_client=project_client, model=deployment_name) return await _run_workflow_with_client(query, client) except Exception as e: print(f"Error during workflow execution: {e}") @@ -173,7 +175,7 @@ async def run_workflow_with_response_tracking( return await _run_workflow_with_client(query, client) -async def _run_workflow_with_client(query: str, client: AzureOpenAIResponsesClient) -> dict: +async def _run_workflow_with_client(query: str, client: FoundryChatClient) -> dict: """Execute workflow with given client and track all interactions.""" # Initialize tracking variables - use lists to track multiple responses per agent @@ -205,16 +207,17 @@ async def _run_workflow_with_client(query: str, client: AzureOpenAIResponsesClie } -async def _create_workflow(client: AzureOpenAIResponsesClient): +async def _create_workflow(client: FoundryChatClient): """Create the multi-agent travel planning workflow with specialized agents. - Uses a single shared AzureOpenAIResponsesClient for all agents. + Uses a single shared FoundryChatClient for all agents. """ final_coordinator = ResearchLead(client=client, id="final-coordinator") # Agent 1: Travel Request Handler (initial coordinator) - travel_request_handler = client.as_agent( + travel_request_handler = Agent( + client=client, id="travel-request-handler", instructions=( "You receive user travel queries and relay them to specialized agents. Extract key information: destination, dates, budget, and preferences. Pass this information forward clearly to the next agents." @@ -223,7 +226,8 @@ async def _create_workflow(client: AzureOpenAIResponsesClient): ) # Agent 2: Hotel Search Executor - hotel_search_agent = client.as_agent( + hotel_search_agent = Agent( + client=client, id="hotel-search-agent", instructions=( "You are a hotel search specialist. Your task is ONLY to search for and provide hotel information. Use search_hotels to find options, get_hotel_details for specifics, and check_availability to verify rooms. Output format: List hotel names, prices per night, total cost for the stay, locations, ratings, amenities, and addresses. IMPORTANT: Only provide hotel information without additional commentary." @@ -233,7 +237,8 @@ async def _create_workflow(client: AzureOpenAIResponsesClient): ) # Agent 3: Flight Search Executor - flight_search_agent = client.as_agent( + flight_search_agent = Agent( + client=client, id="flight-search-agent", instructions=( "You are a flight search specialist. Your task is ONLY to search for and provide flight information. Use search_flights to find options, get_flight_details for specifics, and check_availability for seats. Output format: List flight numbers, airlines, departure/arrival times, prices, durations, and cabin class. IMPORTANT: Only provide flight information without additional commentary." @@ -243,7 +248,8 @@ async def _create_workflow(client: AzureOpenAIResponsesClient): ) # Agent 4: Activity Search Executor - activity_search_agent = client.as_agent( + activity_search_agent = Agent( + client=client, id="activity-search-agent", instructions=( "You are an activities specialist. Your task is ONLY to search for and provide activity information. Use search_activities to find options for activities. Output format: List activity names, descriptions, prices, durations, ratings, and categories. IMPORTANT: Only provide activity information without additional commentary." @@ -253,7 +259,8 @@ async def _create_workflow(client: AzureOpenAIResponsesClient): ) # Agent 5: Booking Confirmation Executor - booking_confirmation_agent = client.as_agent( + booking_confirmation_agent = Agent( + client=client, id="booking-confirmation-agent", instructions=( "You confirm bookings. Use check_hotel_availability and check_flight_availability to verify slots, then confirm_booking to finalize. Provide ONLY: confirmation numbers, booking references, and confirmation status." @@ -263,7 +270,8 @@ async def _create_workflow(client: AzureOpenAIResponsesClient): ) # Agent 6: Booking Payment Executor - booking_payment_agent = client.as_agent( + booking_payment_agent = Agent( + client=client, id="booking-payment-agent", instructions=( "You process payments. Use validate_payment_method to verify payment, then process_payment to complete transactions. Provide ONLY: payment confirmation status, transaction IDs, and payment amounts." @@ -273,7 +281,8 @@ async def _create_workflow(client: AzureOpenAIResponsesClient): ) # Agent 7: Booking Information Aggregation Executor - booking_info_aggregation_agent = client.as_agent( + booking_info_aggregation_agent = Agent( + client=client, id="booking-info-aggregation-agent", instructions=( "You aggregate hotel and flight search results. Receive options from search agents and organize them. Provide: top 2-3 hotel options with prices and top 2-3 flight options with prices in a structured format." @@ -356,7 +365,7 @@ async def create_and_run_workflow(deployment_name: str | None = None): query = example_queries[0] print(f"Query: {query}\n") - result = await run_workflow_with_response_tracking(query, deployment_name=deployment_name) + result = await run_workflow_with_response_tracking(query, model=deployment_name) # Create output data structure output_data = {"agents": {}, "query": result["query"], "output": result.get("output", "")} diff --git a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py index 6ad3641721..2ac8b90cb7 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py +++ b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py @@ -9,7 +9,7 @@ import time from typing import TYPE_CHECKING, Any from azure.ai.projects import AIProjectClient -from azure.identity import DefaultAzureCredential +from azure.identity import AzureCliCredential from create_workflow import create_and_run_workflow from dotenv import load_dotenv @@ -33,8 +33,8 @@ This script: def create_openai_client() -> OpenAI: project_client = AIProjectClient( - endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - credential=DefaultAzureCredential(), + endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + credential=AzureCliCredential(), ) return project_client.get_openai_client() @@ -58,7 +58,7 @@ async def run_workflow(deployment_name: str | None = None) -> dict[str, Any]: print("Executing multi-agent travel planning workflow...") print("This may take a few minutes...") - workflow_data = await create_and_run_workflow(deployment_name=deployment_name) + workflow_data = await create_and_run_workflow(model=deployment_name) print("Workflow execution completed") return workflow_data @@ -216,7 +216,7 @@ async def main(): print_section("Travel Planning Workflow Evaluation") print_section("Step 1: Running Workflow") - workflow_data = await run_workflow(deployment_name=workflow_agent_model) + workflow_data = await run_workflow(model=workflow_agent_model) print_section("Step 2: Response Data Summary") display_response_summary(workflow_data) @@ -225,7 +225,7 @@ async def main(): fetch_agent_responses(openai_client, workflow_data, agents_to_evaluate) print_section("Step 4: Creating Evaluation") - eval_object = create_evaluation(openai_client, deployment_name=eval_model) + eval_object = create_evaluation(openai_client, model=eval_model) print_section("Step 5: Running Evaluation") eval_run = run_evaluation(openai_client, eval_object, workflow_data, agents_to_evaluate) diff --git a/python/samples/SAMPLE_GUIDELINES.md b/python/samples/SAMPLE_GUIDELINES.md index a40312614f..5655f5df09 100644 --- a/python/samples/SAMPLE_GUIDELINES.md +++ b/python/samples/SAMPLE_GUIDELINES.md @@ -36,9 +36,52 @@ When samples depend on external packages not included in the dev environment (e. This makes samples self-contained and runnable without installing extra packages into the dev environment. Do not add sample-only dependencies to the root `pyproject.toml` dev group. +## Azure Credentials + +**Always use `AzureCliCredential`** in samples — never `DefaultAzureCredential`. `AzureCliCredential` is explicit about the authentication method (requires `az login`) and avoids unexpected credential resolution that can confuse newcomers. + +```python +from azure.identity import AzureCliCredential + +credential = AzureCliCredential() +``` + ## Environment Variables -All samples that use environment variables (API keys, endpoints, etc.) must call `load_dotenv()` at the beginning of the file to load variables from a `.env` file. The `python-dotenv` package is already included as a dependency of `agent-framework-core`. +### Basic / Getting Started Samples + +For getting started samples (`01-get-started/`) and `basic` samples, use **explicit placeholder values** for non-sensitive parameters to make the code immediately readable: + +```python +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +""" +Sample docstring explaining what the sample does. +""" + + +async def main() -> None: + client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), + ) + agent = Agent(client=client, name="MyAgent", instructions="You are helpful.") + result = await agent.run("Hello!") + print(result) +``` + +Basic samples should NOT use `os.environ`, `load_dotenv()`, or `.env` files. The placeholder values make the code self-documenting. + +### Advanced Samples + +For advanced samples that require real credentials or multiple configuration values, use environment variables with `os.environ` and document the required variables in the module docstring: ```python # Copyright (c) Microsoft. All rights reserved. @@ -46,23 +89,28 @@ All samples that use environment variables (API keys, endpoints, etc.) must call import asyncio import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() """ -Sample docstring explaining what the sample does. +Advanced sample demonstrating feature X. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4o) """ ``` -Users can create a `.env` file in the `python/` directory based on `.env.example` to set their environment variables without having to export them in their shell. +### Default Client for Samples + +Unless a sample is specifically demonstrating a particular provider (OpenAI direct, Anthropic, Ollama, etc.), use `FoundryChatClient` from `agent_framework.azure` as the default client. This is the recommended client for Azure AI Foundry deployments. + +Provider-specific samples belong in `02-agents/providers//` and should use the provider's native client (e.g., `OpenAIChatClient` for OpenAI, `AnthropicClient` for Anthropic). ## Syntax Checking -Run `uv run poe samples-syntax` to check samples for syntax errors and missing imports from `agent_framework`. This uses a relaxed pyright configuration that validates imports without strict type checking. +Run `uv run poe pyright -S` to check samples for syntax errors and missing imports from `agent_framework`. This uses a relaxed pyright configuration that validates imports without strict type checking. Some samples depend on external packages (e.g., `azure.ai.agentserver.agentframework`, `microsoft_agents`) that are not installed in the dev environment. These are excluded in `pyrightconfig.samples.json`. When adding or modifying these excluded samples, add them to the exclude list and manually verify they have no import errors from `agent_framework` packages by temporarily removing them from the exclude list and running the check. diff --git a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py index e5c6bd09f8..ae78061260 100644 --- a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py +++ b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py @@ -1,25 +1,17 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "autogen-agentchat", -# "autogen-ext[openai]", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/autogen-migration/orchestrations/01_round_robin_group_chat.py - # Copyright (c) Microsoft. All rights reserved. + + +import asyncio + +from agent_framework import Agent, Message +from dotenv import load_dotenv + """AutoGen RoundRobinGroupChat vs Agent Framework GroupChatBuilder/SequentialBuilder. Demonstrates sequential agent orchestration where agents take turns processing the task in a round-robin fashion. """ -import asyncio - -from agent_framework import Message -from dotenv import load_dotenv - # Load environment variables from .env file load_dotenv() @@ -76,17 +68,20 @@ async def run_agent_framework() -> None: client = OpenAIChatClient(model_id="gpt-4.1-mini") # Create specialized agents - researcher = client.as_agent( + researcher = Agent( + client=client, name="researcher", instructions="You are a researcher. Provide facts and data about the topic.", ) - writer = client.as_agent( + writer = Agent( + client=client, name="writer", instructions="You are a writer. Turn research into engaging content.", ) - editor = client.as_agent( + editor = Agent( + client=client, name="editor", instructions="You are an editor. Review and finalize the content.", ) @@ -98,7 +93,7 @@ async def run_agent_framework() -> None: print("[Agent Framework] Sequential conversation:") async for event in workflow.run("Create a brief summary about electric vehicles", stream=True): if event.type == "output" and isinstance(event.data, list): - for message in event.data: + for message in event.data: # type: ignore if isinstance(message, Message) and message.role == "assistant" and message.text: print(f"---------- {message.author_name} ----------") print(message.text) @@ -107,6 +102,7 @@ async def run_agent_framework() -> None: async def run_agent_framework_with_cycle() -> None: """Agent Framework's WorkflowBuilder with cyclic edges and conditional exit.""" from agent_framework import ( + Agent, AgentExecutorRequest, AgentExecutorResponse, AgentResponseUpdate, @@ -119,17 +115,20 @@ async def run_agent_framework_with_cycle() -> None: client = OpenAIChatClient(model_id="gpt-4.1-mini") # Create specialized agents - researcher = client.as_agent( + researcher = Agent( + client=client, name="researcher", instructions="You are a researcher. Provide facts and data about the topic.", ) - writer = client.as_agent( + writer = Agent( + client=client, name="writer", instructions="You are a writer. Turn research into engaging content.", ) - editor = client.as_agent( + editor = Agent( + client=client, name="editor", instructions="You are an editor. Review and finalize the content. End with APPROVED if satisfied.", ) @@ -144,9 +143,7 @@ async def run_agent_framework_with_cycle() -> None: if last_message and "APPROVED" in last_message.text: await context.yield_output("Content approved.") else: - await context.send_message( - AgentExecutorRequest(messages=response.full_conversation, should_respond=True) - ) + await context.send_message(AgentExecutorRequest(messages=response.full_conversation, should_respond=True)) workflow = ( WorkflowBuilder(start_executor=researcher) diff --git a/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py b/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py index 6f16e1dea9..40973abf72 100644 --- a/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py +++ b/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py @@ -1,25 +1,17 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "autogen-agentchat", -# "autogen-ext[openai]", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/autogen-migration/orchestrations/02_selector_group_chat.py - # Copyright (c) Microsoft. All rights reserved. + + +import asyncio + +from agent_framework import Agent, Message +from dotenv import load_dotenv + """AutoGen SelectorGroupChat vs Agent Framework GroupChatBuilder. Demonstrates LLM-based speaker selection where an orchestrator decides which agent should speak next based on the conversation context. """ -import asyncio - -from agent_framework import Message -from dotenv import load_dotenv - # Load environment variables from .env file load_dotenv() @@ -79,22 +71,22 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import GroupChatBuilder - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized agents - python_expert = client.as_agent( + python_expert = Agent(client=client, name="python_expert", instructions="You are a Python programming expert. Answer Python-related questions.", description="Expert in Python programming", ) - javascript_expert = client.as_agent( + javascript_expert = Agent(client=client, name="javascript_expert", instructions="You are a JavaScript programming expert. Answer JavaScript-related questions.", description="Expert in JavaScript programming", ) - database_expert = client.as_agent( + database_expert = Agent(client=client, name="database_expert", instructions="You are a database expert. Answer SQL and database-related questions.", description="Expert in databases and SQL", @@ -103,7 +95,7 @@ async def run_agent_framework() -> None: workflow = GroupChatBuilder( participants=[python_expert, javascript_expert, database_expert], max_rounds=1, - orchestrator_agent=client.as_agent( + orchestrator_agent=Agent(client=client, name="selector_manager", instructions="Based on the conversation, select the most appropriate expert to respond next.", ), @@ -113,7 +105,7 @@ async def run_agent_framework() -> None: print("[Agent Framework] Group chat conversation:") async for event in workflow.run("How do I connect to a PostgreSQL database using Python?", stream=True): if event.type == "output" and isinstance(event.data, list): - for message in event.data: + for message in event.data: # type: ignore if isinstance(message, Message) and message.role == "assistant" and message.text: print(f"---------- {message.author_name} ----------") print(message.text) diff --git a/python/samples/autogen-migration/orchestrations/03_swarm.py b/python/samples/autogen-migration/orchestrations/03_swarm.py index a178ffcffe..7be50de12a 100644 --- a/python/samples/autogen-migration/orchestrations/03_swarm.py +++ b/python/samples/autogen-migration/orchestrations/03_swarm.py @@ -1,26 +1,17 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "autogen-agentchat", -# "autogen-ext[openai]", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/autogen-migration/orchestrations/03_swarm.py - # Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Any + +from agent_framework import Agent, AgentResponseUpdate, WorkflowEvent +from dotenv import load_dotenv + """AutoGen Swarm pattern vs Agent Framework HandoffBuilder. Demonstrates agent handoff coordination where agents can transfer control to other specialized agents based on the task requirements. """ -import asyncio -from typing import Any - -from agent_framework import AgentResponseUpdate, WorkflowEvent -from dotenv import load_dotenv - # Load environment variables from .env file load_dotenv() @@ -119,10 +110,10 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create triage agent - triage_agent = client.as_agent( + triage_agent = Agent(client=client, name="triage", instructions=( "You are a triage agent. Analyze the user's request and route to the appropriate specialist:\n" @@ -133,14 +124,14 @@ async def run_agent_framework() -> None: ) # Create billing specialist - billing_agent = client.as_agent( + billing_agent = Agent(client=client, name="billing_agent", instructions="You are a billing specialist. Help with payment and billing questions. Provide clear assistance.", description="Handles billing and payment questions", ) # Create technical support specialist - tech_support = client.as_agent( + tech_support = Agent(client=client, name="technical_support", instructions="You are technical support. Help with technical issues. Provide clear assistance.", description="Handles technical support questions", diff --git a/python/samples/autogen-migration/orchestrations/04_magentic_one.py b/python/samples/autogen-migration/orchestrations/04_magentic_one.py index b6728b0e46..e10636b10f 100644 --- a/python/samples/autogen-migration/orchestrations/04_magentic_one.py +++ b/python/samples/autogen-migration/orchestrations/04_magentic_one.py @@ -1,25 +1,11 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "autogen-agentchat", -# "autogen-ext[openai]", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/autogen-migration/orchestrations/04_magentic_one.py - # Copyright (c) Microsoft. All rights reserved. -"""AutoGen MagenticOneGroupChat vs Agent Framework MagenticBuilder. - -Demonstrates orchestrated multi-agent workflows with a central coordinator -managing specialized agents for complex tasks. -""" import asyncio import json from typing import cast from agent_framework import ( + Agent, AgentResponseUpdate, Message, WorkflowEvent, @@ -27,6 +13,12 @@ from agent_framework import ( from agent_framework.orchestrations import MagenticProgressLedger from dotenv import load_dotenv +"""AutoGen MagenticOneGroupChat vs Agent Framework MagenticBuilder. + +Demonstrates orchestrated multi-agent workflows with a central coordinator +managing specialized agents for complex tasks. +""" + # Load environment variables from .env file load_dotenv() @@ -87,19 +79,22 @@ async def run_agent_framework() -> None: client = OpenAIChatClient(model_id="gpt-4.1-mini") # Create specialized agents - researcher = client.as_agent( + researcher = Agent( + client=client, name="researcher", instructions="You are a research analyst. Gather and analyze information.", description="Research analyst for data gathering", ) - coder = client.as_agent( + coder = Agent( + client=client, name="coder", instructions="You are a programmer. Write code based on requirements.", description="Software developer for implementation", ) - reviewer = client.as_agent( + reviewer = Agent( + client=client, name="reviewer", instructions="You are a code reviewer. Review code for quality and correctness.", description="Code reviewer for quality assurance", @@ -108,7 +103,8 @@ async def run_agent_framework() -> None: # Create Magentic workflow workflow = MagenticBuilder( participants=[researcher, coder, reviewer], - manager_agent=client.as_agent( + manager_agent=Agent( + client=client, name="magentic_manager", instructions="You coordinate a team to complete complex tasks efficiently.", description="Orchestrator for team coordination", diff --git a/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py b/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py index 73a3caba02..bfa0c915ea 100644 --- a/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py +++ b/python/samples/autogen-migration/single_agent/01_basic_assistant_agent.py @@ -9,6 +9,12 @@ # uv run samples/autogen-migration/single_agent/01_basic_assistant_agent.py # Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from dotenv import load_dotenv + """Basic AutoGen AssistantAgent vs Agent Framework Agent. Both samples expect OpenAI-compatible environment variables (OPENAI_API_KEY or @@ -16,10 +22,6 @@ Azure OpenAI configuration). Update the prompts or client wiring to match your model of choice before running. """ -import asyncio - -from dotenv import load_dotenv - # Load environment variables from .env file load_dotenv() @@ -48,8 +50,9 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient # AF constructs a lightweight Agent backed by OpenAIChatClient - client = OpenAIChatClient(model_id="gpt-4.1-mini") - agent = client.as_agent( + client = OpenAIChatClient(model="gpt-4.1-mini") + agent = Agent( + client=client, name="assistant", instructions="You are a helpful assistant. Answer in one sentence.", ) diff --git a/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py b/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py index aca868b9f2..c4a75586c9 100644 --- a/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py +++ b/python/samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py @@ -1,24 +1,14 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "autogen-agentchat", -# "autogen-core", -# "autogen-ext[openai]", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/autogen-migration/single_agent/02_assistant_agent_with_tool.py - # Copyright (c) Microsoft. All rights reserved. -"""AutoGen AssistantAgent vs Agent Framework Agent with function tools. - -Demonstrates how to create and attach tools to agents in both frameworks. -""" import asyncio from dotenv import load_dotenv +"""AutoGen AssistantAgent vs Agent Framework Agent with function tools. + +Demonstrates how to create and attach tools to agents in both frameworks. +""" + # Load environment variables from .env file load_dotenv() @@ -64,11 +54,11 @@ async def run_autogen() -> None: async def run_agent_framework() -> None: """Agent Framework agent with @tool decorator.""" - from agent_framework import tool + from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient # Define tool with @tool decorator (automatic schema inference) - # 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. + # NOTE: approval_mode="never_require" is for sample brevity. @tool(approval_mode="never_require") def get_weather(location: str) -> str: """Get the weather for a location. @@ -82,8 +72,8 @@ async def run_agent_framework() -> None: return f"The weather in {location} is sunny and 72°F." # Create agent with tool - client = OpenAIChatClient(model_id="gpt-4.1-mini") - agent = client.as_agent( + client = OpenAIChatClient(model="gpt-4.1-mini") + agent = Agent(client=client, name="assistant", instructions="You are a helpful assistant. Use available tools to answer questions.", tools=[get_weather], diff --git a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py b/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py index c544880cb1..bbb31b7f95 100644 --- a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py +++ b/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py @@ -9,15 +9,17 @@ # uv run samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py # Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from dotenv import load_dotenv + """AutoGen vs Agent Framework: Thread management and streaming responses. Demonstrates conversation state management and streaming in both frameworks. """ -import asyncio - -from dotenv import load_dotenv - # Load environment variables from .env file load_dotenv() @@ -55,8 +57,9 @@ async def run_agent_framework() -> None: """Agent Framework agent with explicit session and streaming.""" from agent_framework.openai import OpenAIChatClient - client = OpenAIChatClient(model_id="gpt-4.1-mini") - agent = client.as_agent( + client = OpenAIChatClient(model="gpt-4.1-mini") + agent = Agent( + client=client, name="assistant", instructions="You are a helpful math tutor.", ) diff --git a/python/samples/autogen-migration/single_agent/04_agent_as_tool.py b/python/samples/autogen-migration/single_agent/04_agent_as_tool.py index 489ec74c01..79c420b415 100644 --- a/python/samples/autogen-migration/single_agent/04_agent_as_tool.py +++ b/python/samples/autogen-migration/single_agent/04_agent_as_tool.py @@ -1,24 +1,15 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "autogen-agentchat", -# "autogen-ext[openai]", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/autogen-migration/single_agent/04_agent_as_tool.py - # Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from dotenv import load_dotenv + """AutoGen vs Agent Framework: Agent-as-a-Tool pattern. Demonstrates hierarchical agent architectures where one agent delegates work to specialized sub-agents wrapped as tools. """ -import asyncio - -from dotenv import load_dotenv - # Load environment variables from .env file load_dotenv() @@ -64,13 +55,13 @@ async def run_autogen() -> None: async def run_agent_framework() -> None: """Agent Framework's as_tool() for hierarchical agents with streaming.""" - from agent_framework import Content + from agent_framework import Agent, Content from agent_framework.openai import OpenAIChatClient - client = OpenAIChatClient(model_id="gpt-4.1-mini") + client = OpenAIChatClient(model="gpt-4.1-mini") # Create specialized writer agent - writer = client.as_agent( + writer = Agent(client=client, name="writer", instructions="You are a creative writer. Write short, engaging content.", ) @@ -84,7 +75,7 @@ async def run_agent_framework() -> None: ) # Create coordinator agent with writer tool - coordinator = client.as_agent( + coordinator = Agent(client=client, name="coordinator", instructions="You coordinate with specialized agents. Delegate writing tasks to the writer agent.", tools=[writer_tool], @@ -107,6 +98,7 @@ async def run_agent_framework() -> None: if content.type == "function_call": # Accumulate function call content as it streams in call_id = content.call_id + assert call_id is not None, "Function call content must have a call_id" if call_id in accumulated_calls: # Add to existing call (arguments stream in gradually) accumulated_calls[call_id] = accumulated_calls[call_id] + content diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo deleted file mode 100644 index 9c052ccd41..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[78],[78,79,80,81,82],[78,80],[77,83],[69],[67,69],[58,66,67,68,70,72],[56],[59,64,69,72],[55,72],[59,60,63,64,65,72],[59,60,61,63,64,72],[56,57,58,59,60,64,65,66,68,69,70,72],[72],[54,56,57,58,59,60,61,63,64,65,66,67,68,69,70,71],[54,72],[59,61,62,64,65,72],[63,72],[64,65,69,72],[57,67],[47,76],[46,47],[47,48,49,50,51,52,53,73,74,75,76],[49,50,51,52],[49,50,51],[49],[50],[47],[77,84]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"2448a94bdacc4085b4fd26ccb7c3f323d04a220af29a24b61703903730b68984","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"}],"root":[85],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"target":7},"referencedMap":[[80,1],[83,2],[79,1],[81,3],[82,1],[84,4],[70,5],[68,6],[69,7],[57,8],[58,6],[65,9],[56,10],[61,11],[62,12],[67,13],[73,14],[72,15],[55,16],[63,17],[64,18],[59,19],[66,5],[60,20],[48,21],[47,22],[77,23],[74,24],[52,25],[50,26],[51,27],[76,28],[85,29]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index 68fc7fc564..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts deleted file mode 100644 index 340562aff1..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vite").UserConfig; -export default _default; diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js deleted file mode 100644 index 96a3b3875f..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()], - server: { - host: "127.0.0.1", - port: 5173, - }, -}); diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md index 3a298fcf3d..f7da9de9c5 100644 --- a/python/samples/semantic-kernel-migration/README.md +++ b/python/samples/semantic-kernel-migration/README.md @@ -12,9 +12,6 @@ This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent F - [03_chat_completion_thread_and_stream.py](chat_completion/03_chat_completion_thread_and_stream.py) — Demonstrates session reuse and streaming prompts. ### Azure AI agent parity -- [01_basic_azure_ai_agent.py](azure_ai_agent/01_basic_azure_ai_agent.py) — Create and run an Azure AI agent end to end. -- [02_azure_ai_agent_with_code_interpreter.py](azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py) — Enable hosted code interpreter/tool execution. -- [03_azure_ai_agent_threads_and_followups.py](azure_ai_agent/03_azure_ai_agent_threads_and_followups.py) — Persist sessions and follow-ups across invocations. ### OpenAI Assistants API parity - [01_basic_openai_assistant.py](openai_assistant/01_basic_openai_assistant.py) — Baseline assistant comparison. diff --git a/python/samples/semantic-kernel-migration/azure_ai_agent/01_basic_azure_ai_agent.py b/python/samples/semantic-kernel-migration/azure_ai_agent/01_basic_azure_ai_agent.py deleted file mode 100644 index b10f38f779..0000000000 --- a/python/samples/semantic-kernel-migration/azure_ai_agent/01_basic_azure_ai_agent.py +++ /dev/null @@ -1,65 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/azure_ai_agent/01_basic_azure_ai_agent.py - -# Copyright (c) Microsoft. All rights reserved. -"""Create an Azure AI agent using both Semantic Kernel and Agent Framework. - -Prerequisites: -- Azure AI agent resource with a deployed model. -- Logged-in Azure CLI or other credential supported by AzureCliCredential. -""" - -import asyncio - -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -async def run_semantic_kernel() -> None: - from azure.identity.aio import AzureCliCredential - from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings - - async with AzureCliCredential() as credential, AzureAIAgent.create_client(credential=credential) as client: - settings = AzureAIAgentSettings() # Reads env vars for region/deployment. - # SK builds the remote agent definition then wraps it with AzureAIAgent. - definition = await client.agents.create_agent( - model=settings.model_deployment_name, - name="Support", - instructions="Answer customer questions in one paragraph.", - ) - agent = AzureAIAgent(client=client, definition=definition) - response = await agent.get_response("How do I upgrade my plan?") - print("[SK]", response.message.content) - - -async def run_agent_framework() -> None: - from agent_framework.azure import AzureAIAgentClient - from azure.identity.aio import AzureCliCredential - - async with ( - AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( - name="Support", - instructions="Answer customer questions in one paragraph.", - ) as agent, - ): - # AF client returns an asynchronous context manager for remote agents. - reply = await agent.run("How do I upgrade my plan?") - print("[AF]", reply.text) - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py b/python/samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py deleted file mode 100644 index 599fcf75ad..0000000000 --- a/python/samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py +++ /dev/null @@ -1,76 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py - -# Copyright (c) Microsoft. All rights reserved. -"""Enable the hosted code interpreter for Azure AI agents in SK and AF. - -The Azure AI service natively executes the code interpreter tool. Provide the -resource details via AzureAIAgentSettings (SK) or environment variables consumed -by AzureAIAgentClient (AF). -""" - -import asyncio - -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -async def run_semantic_kernel() -> None: - from azure.identity.aio import AzureCliCredential - from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings - - async with AzureCliCredential() as credential, AzureAIAgent.create_client(credential=credential) as client: - settings = AzureAIAgentSettings() - # Register the hosted code interpreter tool with the remote agent. - definition = await client.agents.create_agent( - model=settings.model_deployment_name, - name="Analyst", - instructions="Use the code interpreter for numeric work.", - tools=[{"type": "code_interpreter"}], - ) - agent = AzureAIAgent(client=client, definition=definition) - response = await agent.get_response( - "Use Python to compute 42 ** 2 and explain the result.", - ) - print("[SK]", response.message.content) - - -async def run_agent_framework() -> None: - from agent_framework.azure import AzureAIAgentClient, AzureAIAgentsProvider - from azure.identity.aio import AzureCliCredential - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - code_interpreter_tool = AzureAIAgentClient.get_code_interpreter_tool() - - agent = await provider.create_agent( - name="Analyst", - instructions="Use the code interpreter for numeric work.", - tools=[code_interpreter_tool], - ) - - # Code interpreter tool mirrors the built-in Azure AI capability. - reply = await agent.run( - "Use Python to compute 42 ** 2 and explain the result.", - tool_choice="auto", - ) - print("[AF]", reply.text) - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py b/python/samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py deleted file mode 100644 index 4fb4de4085..0000000000 --- a/python/samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py +++ /dev/null @@ -1,81 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/azure_ai_agent/03_azure_ai_agent_threads_and_followups.py - -# Copyright (c) Microsoft. All rights reserved. -"""Maintain Azure AI agent conversation state across turns in SK and AF.""" - -import asyncio - -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -async def run_semantic_kernel() -> None: - from azure.identity.aio import AzureCliCredential - from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings, AzureAIAgentThread - - async with AzureCliCredential() as credential, AzureAIAgent.create_client(credential=credential) as client: - settings = AzureAIAgentSettings() - definition = await client.agents.create_agent( - model=settings.model_deployment_name, - name="Planner", - instructions="Track follow-up questions within the same thread.", - ) - agent = AzureAIAgent(client=client, definition=definition) - - thread: AzureAIAgentThread | None = None - # SK returns the updated AzureAIAgentThread on each response. - first = await agent.get_response("Outline the onboarding checklist.", thread=thread) - thread = first.thread - print("[SK][turn1]", first.message.content) - - second = await agent.get_response( - "Highlight the items that require legal review.", - thread=thread, - ) - print("[SK][turn2]", second.message.content) - if thread is not None: - print("[SK][thread-id]", thread.id) - - -async def run_agent_framework() -> None: - from agent_framework.azure import AzureAIAgentClient - from azure.identity.aio import AzureCliCredential - - async with ( - AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( - name="Planner", - instructions="Track follow-up questions within the same thread.", - ) as agent, - ): - session = agent.create_session() - # AF sessions are explicit and can be serialized for external storage. - first = await agent.run("Outline the onboarding checklist.", session=session) - print("[AF][turn1]", first.text) - - second = await agent.run( - "Highlight the items that require legal review.", - session=session, - ) - print("[AF][turn2]", second.text) - - serialized = session.to_dict() - print("[AF][session-json]", serialized) - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py b/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py index 50e98c74ca..ebd10122dc 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py +++ b/python/samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py @@ -17,6 +17,7 @@ model of choice before running. import asyncio +from agent_framework import Agent from dotenv import load_dotenv # Load environment variables from .env file @@ -44,7 +45,8 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient # AF constructs a lightweight Agent backed by OpenAIChatClient. - chat_agent = OpenAIChatClient().as_agent( + chat_agent = Agent( + client=OpenAIChatClient(), name="Support", instructions="Answer in one sentence.", ) diff --git a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py index 78d45862e1..d84e560eb0 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py +++ b/python/samples/semantic-kernel-migration/chat_completion/02_chat_completion_with_tool.py @@ -48,7 +48,7 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - from agent_framework import tool + from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient @tool(name="specials", description="List daily specials") @@ -56,7 +56,8 @@ async def run_agent_framework() -> None: return "Clam chowder, Cobb salad, Chai tea" # AF tools are provided as callables on each agent instance. - chat_agent = OpenAIChatClient().as_agent( + chat_agent = Agent( + client=OpenAIChatClient(), name="Host", instructions="Answer menu questions accurately.", tools=[specials], diff --git a/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py b/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py index fc4658bfab..f656220f20 100644 --- a/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py +++ b/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py @@ -16,6 +16,7 @@ for the second turn. import asyncio +from agent_framework import Agent from dotenv import load_dotenv # Load environment variables from .env file @@ -54,7 +55,8 @@ async def run_agent_framework() -> None: from agent_framework.openai import OpenAIChatClient # AF session objects are requested explicitly from the agent. - chat_agent = OpenAIChatClient().as_agent( + chat_agent = Agent( + client=OpenAIChatClient(), name="Writer", instructions="Keep answers short and friendly.", ) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py b/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py index 1c0b5a3ae4..f171d076bd 100644 --- a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py +++ b/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py @@ -9,21 +9,19 @@ # Copyright (c) Microsoft. All rights reserved. """Create an OpenAI Assistant using SK and Agent Framework.""" - import asyncio import os +from agent_framework import Agent from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() - ASSISTANT_MODEL = os.environ.get("OPENAI_ASSISTANT_MODEL", "gpt-4o-mini") async def run_semantic_kernel() -> None: from semantic_kernel.agents import AssistantAgentThread, OpenAIAssistantAgent - client = OpenAIAssistantAgent.create_client() # Provision the assistant on the OpenAI Assistants service. definition = await client.beta.assistants.create( @@ -32,7 +30,6 @@ async def run_semantic_kernel() -> None: instructions="Answer questions in one concise paragraph.", ) agent = OpenAIAssistantAgent(client=client, definition=definition) - thread: AssistantAgentThread | None = None response = await agent.get_response("What is the capital of Denmark?", thread=thread) thread = response.thread @@ -43,13 +40,10 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework.openai import OpenAIAssistantsClient - assistants_client = OpenAIAssistantsClient() # AF wraps the assistant lifecycle with an async context manager. - async with assistants_client.as_agent( - name="Helper", - instructions="Answer questions in one concise paragraph.", - model=ASSISTANT_MODEL, + async with Agent( + client=assistants_client, ) as assistant_agent: session = assistant_agent.create_session() reply = await assistant_agent.run("What is the capital of Denmark?", session=session) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py b/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py index b9407149d6..f968f7851e 100644 --- a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py +++ b/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py @@ -1,4 +1,5 @@ # /// script + # requires-python = ">=3.10" # dependencies = [ # "semantic-kernel", @@ -12,6 +13,7 @@ import asyncio +from agent_framework import Agent from dotenv import load_dotenv # Load environment variables from .env file @@ -50,7 +52,7 @@ async def run_agent_framework() -> None: code_interpreter_tool = OpenAIAssistantsClient.get_code_interpreter_tool() # AF exposes the same tool configuration via create_agent. - async with assistants_client.as_agent( + async with Agent(client=assistants_client, name="CodeRunner", instructions="Use the code interpreter when calculations are required.", model="gpt-4.1", diff --git a/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py b/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py index be395cafa6..36d6fea208 100644 --- a/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py +++ b/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py @@ -69,7 +69,7 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: - from agent_framework import tool + from agent_framework import Agent, tool from agent_framework.openai import OpenAIAssistantsClient @tool( @@ -81,7 +81,7 @@ async def run_agent_framework() -> None: assistants_client = OpenAIAssistantsClient() # AF converts the decorated function into an assistant-compatible tool. - async with assistants_client.as_agent( + async with Agent(client=assistants_client, name="WeatherHelper", instructions="Call get_forecast to fetch weather details.", model=ASSISTANT_MODEL, diff --git a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py index 556407c969..54994d7f1f 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py +++ b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py @@ -25,7 +25,7 @@ async def run_semantic_kernel() -> None: client = OpenAIResponsesAgent.create_client() # SK response agents wrap OpenAI's hosted Responses API. agent = OpenAIResponsesAgent( - ai_model_id=OpenAISettings().responses_model_id, + ai_model=OpenAISettings().responses_model_id, client=client, instructions="Answer in one concise sentence.", name="Expert", diff --git a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py index ed2609783c..d2855a7810 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py +++ b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py @@ -31,7 +31,7 @@ async def run_semantic_kernel() -> None: client = OpenAIResponsesAgent.create_client() # Plugins advertise callable tools to the Responses agent. agent = OpenAIResponsesAgent( - ai_model_id=OpenAISettings().responses_model_id, + ai_model=OpenAISettings().responses_model_id, client=client, instructions="Use the add tool when math is required.", name="MathExpert", diff --git a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py index 277dbbda40..a4328ce05f 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py +++ b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py @@ -32,7 +32,7 @@ async def run_semantic_kernel() -> None: client = OpenAIResponsesAgent.create_client() # response_format requests schema-constrained output from the model. agent = OpenAIResponsesAgent( - ai_model_id=OpenAISettings().responses_model_id, + ai_model=OpenAISettings().responses_model_id, client=client, instructions="Return launch briefs as structured JSON.", name="ProductMarketer", diff --git a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py index 38133dbad1..ed0a4b1495 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py @@ -15,7 +15,7 @@ import asyncio from collections.abc import Sequence from typing import cast -from agent_framework import Message +from agent_framework import Agent, Message from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential @@ -91,12 +91,12 @@ def _print_semantic_kernel_outputs(outputs: Sequence[ChatMessageContent]) -> Non async def run_agent_framework_example(prompt: str) -> Sequence[list[Message]]: client = AzureOpenAIChatClient(credential=AzureCliCredential()) - physics = client.as_agent( + physics = Agent(client=client, instructions=("You are an expert in physics. Answer questions from a physics perspective."), name="physics", ) - chemistry = client.as_agent( + chemistry = Agent(client=client, instructions=("You are an expert in chemistry. Answer questions from a chemistry perspective."), name="chemistry", ) diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py index c0d7aa3797..fa539a98b4 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py +++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py @@ -17,7 +17,7 @@ from collections.abc import Sequence from typing import Any, cast from agent_framework import Agent, Message -from agent_framework.azure import AzureOpenAIChatClient, AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import GroupChatBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -130,8 +130,7 @@ class ChatCompletionGroupChatManager(GroupChatManager): chat_history, settings=PromptExecutionSettings(response_format=BooleanResult), ) - result = BooleanResult.model_validate_json(response.content) - return result + return BooleanResult.model_validate_json(response.content) @override async def select_next_agent( @@ -235,19 +234,19 @@ async def run_agent_framework_example(task: str) -> str: "Gather concise facts or considerations that help plan a community hackathon. " "Keep your responses factual and scannable." ), - client=AzureOpenAIChatClient(credential=credential), + client=FoundryChatClient(credential=credential), ) planner = Agent( name="Planner", description="Turns the collected notes into a concrete action plan.", instructions=("Propose a structured action plan that accounts for logistics, roles, and timeline."), - client=AzureOpenAIResponsesClient(credential=credential), + client=FoundryChatClient(credential=credential), ) workflow = GroupChatBuilder( participants=[researcher, planner], - orchestrator_agent=AzureOpenAIChatClient(credential=credential).as_agent(), + orchestrator_agent=Agent(client=FoundryChatClient(credential=credential)), ).build() final_response = "" diff --git a/python/samples/semantic-kernel-migration/orchestrations/handoff.py b/python/samples/semantic-kernel-migration/orchestrations/handoff.py index c235da8fe8..689de88bde 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/handoff.py +++ b/python/samples/semantic-kernel-migration/orchestrations/handoff.py @@ -13,17 +13,18 @@ import asyncio import sys from collections.abc import AsyncIterable, Iterator, Sequence -from typing import cast from agent_framework import ( + Agent, Message, WorkflowEvent, ) -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv -from semantic_kernel.agents import Agent, ChatCompletionAgent, HandoffOrchestration, OrchestrationHandoffs +from semantic_kernel.agents import Agent as SKAgent +from semantic_kernel.agents import ChatCompletionAgent, HandoffOrchestration, OrchestrationHandoffs from semantic_kernel.agents.runtime import InProcessRuntime from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion from semantic_kernel.contents import ( @@ -74,7 +75,7 @@ class OrderReturnPlugin: return f"Return for order {order_id} has been processed successfully (reason: {reason})." -def build_semantic_kernel_agents() -> tuple[list[Agent], OrchestrationHandoffs]: +def build_semantic_kernel_agents() -> tuple[list[SKAgent], OrchestrationHandoffs]: credential = AzureCliCredential() triage = ChatCompletionAgent( @@ -189,8 +190,9 @@ async def run_semantic_kernel_example(initial_task: str, scripted_responses: Seq ###################################################################### -def _create_af_agents(client: AzureOpenAIChatClient): - triage = client.as_agent( +def _create_af_agents(client: FoundryChatClient): + triage = Agent( + client=client, name="triage_agent", instructions=( "You are a customer support triage agent. Route requests:\n" @@ -199,19 +201,22 @@ def _create_af_agents(client: AzureOpenAIChatClient): "- handoff_to_order_return_agent for returns" ), ) - refund = client.as_agent( + refund = Agent( + client=client, name="refund_agent", instructions=( "Handle refunds. Ask for order id and reason. If shipping info is needed, hand off to order_status_agent." ), ) - status = client.as_agent( + status = Agent( + client=client, name="order_status_agent", instructions=( "Provide order status, tracking, and timelines. If billing questions appear, hand off to refund_agent." ), ) - returns = client.as_agent( + returns = Agent( + client=client, name="order_return_agent", instructions=( "Coordinate returns, confirm addresses, and summarize next steps. Hand off to triage_agent if unsure." @@ -235,13 +240,12 @@ def _collect_handoff_requests(events: list[WorkflowEvent]) -> list[WorkflowEvent def _extract_final_conversation(events: list[WorkflowEvent]) -> list[Message]: for event in events: if event.type == "output": - data = cast(list[Message], event.data) - return data + return event.data return [] async def run_agent_framework_example(initial_task: str, scripted_responses: Sequence[str]) -> str: - client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = FoundryChatClient(credential=AzureCliCredential()) triage, refund, status, returns = _create_af_agents(client) workflow = ( diff --git a/python/samples/semantic-kernel-migration/orchestrations/magentic.py b/python/samples/semantic-kernel-migration/orchestrations/magentic.py index 5566df1ab1..2594d15f89 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/magentic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/magentic.py @@ -137,7 +137,7 @@ async def run_agent_framework_example(prompt: str) -> str | None: instructions=( "You are a Researcher. You find information without additional computation or quantitative analysis." ), - client=OpenAIChatClient(model_id="gpt-4o-search-preview"), + client=OpenAIChatClient(model="gpt-4o-search-preview"), ) # Create code interpreter tool using static method diff --git a/python/samples/semantic-kernel-migration/orchestrations/sequential.py b/python/samples/semantic-kernel-migration/orchestrations/sequential.py index af3cf973aa..fd9794fdfe 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/sequential.py +++ b/python/samples/semantic-kernel-migration/orchestrations/sequential.py @@ -15,12 +15,13 @@ import asyncio from collections.abc import Sequence from typing import cast -from agent_framework import Message +from agent_framework import Agent, Message from agent_framework.azure import AzureOpenAIChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv -from semantic_kernel.agents import Agent, ChatCompletionAgent, SequentialOrchestration +from semantic_kernel.agents import Agent as SKAgent +from semantic_kernel.agents import ChatCompletionAgent, SequentialOrchestration from semantic_kernel.agents.runtime import InProcessRuntime from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion from semantic_kernel.contents import ChatMessageContent @@ -36,7 +37,7 @@ PROMPT = "Write a tagline for a budget-friendly eBike." ###################################################################### -def build_semantic_kernel_agents() -> list[Agent]: +def build_semantic_kernel_agents() -> list[SKAgent]: credential = AzureCliCredential() writer_agent = ChatCompletionAgent( @@ -77,12 +78,12 @@ async def sk_agent_response_callback( async def run_agent_framework_example(prompt: str) -> list[Message]: client = AzureOpenAIChatClient(credential=AzureCliCredential()) - writer = client.as_agent( + writer = Agent(client=client, instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), name="writer", ) - reviewer = client.as_agent( + reviewer = Agent(client=client, instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), name="reviewer", ) diff --git a/python/scripts/dependencies/README.md b/python/scripts/dependencies/README.md index 5ce410d766..e129d67eed 100644 --- a/python/scripts/dependencies/README.md +++ b/python/scripts/dependencies/README.md @@ -46,14 +46,14 @@ These are the normal user-facing entrypoints: uv run poe upgrade-dev-dependency-pins uv run poe upgrade-dev-dependencies uv run poe validate-dependency-bounds-test -uv run poe validate-dependency-bounds-test --project -uv run poe validate-dependency-bounds-project --mode both --project --dependency "" +uv run poe validate-dependency-bounds-test --package core +uv run poe validate-dependency-bounds-project --mode both --package core --dependency "" ``` - `upgrade-dev-dependency-pins` only refreshes exact dev pins in `pyproject.toml` files. - `upgrade-dev-dependencies` refreshes dev pins (using task above), runs `uv lock --upgrade`, reinstalls from the frozen lockfile, then runs `check`, `typing`, and `test`. - `validate-dependency-bounds-test` runs the repo-wide lower/upper smoke gate. -- `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--project` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs. +- `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--package` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs. ### GitHub Actions workflows @@ -61,7 +61,7 @@ These workflows call the Poe tasks: - `.github/workflows/python-dependency-range-validation.yml` - Trigger: `workflow_dispatch` - - Runs `uv run poe validate-dependency-bounds-project --mode upper --project "*"` + - Runs `uv run poe validate-dependency-bounds-project --mode upper --package "*"` - Uploads `python/scripts/dependencies/dependency-range-results.json` - Creates issues for failing candidate versions and opens/updates a PR for passing range updates @@ -76,10 +76,10 @@ These are useful for debugging or targeted manual runs: ```bash python -m scripts.dependencies.upgrade_dev_dependencies --dry-run --version-source lock -python -m scripts.dependencies.validate_dependency_bounds --mode test --package packages/core --dry-run -python -m scripts.dependencies.validate_dependency_bounds --mode both --package packages/core --dependencies openai --dry-run -python -m scripts.dependencies._dependency_bounds_lower_impl --packages packages/core --dependencies openai --dry-run -python -m scripts.dependencies._dependency_bounds_upper_impl --packages packages/core --dependencies openai --dry-run +python -m scripts.dependencies.validate_dependency_bounds --mode test --package core --dry-run +python -m scripts.dependencies.validate_dependency_bounds --mode both --package core --dependencies openai --dry-run +python -m scripts.dependencies._dependency_bounds_lower_impl --packages core --dependencies openai --dry-run +python -m scripts.dependencies._dependency_bounds_upper_impl --packages core --dependencies openai --dry-run ``` Use the direct lower/upper implementation modules mainly for debugging or development of the optimizers themselves. For normal usage, prefer the Poe tasks or `validate_dependency_bounds.py`. diff --git a/python/scripts/dependencies/_dependency_bounds_lower_impl.py b/python/scripts/dependencies/_dependency_bounds_lower_impl.py index ad259bd6e6..308e206350 100644 --- a/python/scripts/dependencies/_dependency_bounds_lower_impl.py +++ b/python/scripts/dependencies/_dependency_bounds_lower_impl.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -# ruff: noqa: INP001, S404, S603 +# ruff: noqa: S404, S603 """Lower dependency bounds, validate, and persist the oldest passing set.""" @@ -21,14 +21,15 @@ from urllib import error as urllib_error from urllib import request as urllib_request import tomli +from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import InvalidVersion, Version +from rich import print + from scripts.dependencies._dependency_bounds_runtime import ( extend_command_with_runtime_tools, extend_command_with_task, ) -from packaging.requirements import InvalidRequirement, Requirement -from packaging.version import InvalidVersion, Version -from rich import print -from scripts.task_runner import discover_projects, extract_poe_tasks +from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches CHECK_TASK_PRIORITY = ("check", "typing", "pyright", "mypy", "lint") REQ_PATTERN = r"^\s*([A-Za-z0-9_.-]+(?:\[[^\]]+\])?)\s*(.*?)\s*$" @@ -937,7 +938,7 @@ def main() -> None: "--packages", nargs="*", default=None, - help="Optional package filters by workspace path (e.g., packages/core) or package name.", + help="Optional package filters by short name (for example core), workspace path, or package name.", ) parser.add_argument( "--dependencies", @@ -1001,7 +1002,11 @@ def main() -> None: project_section = package_config.get("project", {}) optional_dependencies = project_section.get("optional-dependencies", {}) or {} dependency_groups = package_config.get("dependency-groups", {}) or {} - if package_filters and str(project_path) not in package_filters and package_name not in package_filters: + # Reuse the shared selector matcher so direct optimizer runs accept the + # same short-name package filters as the contributor-facing Poe tasks. + if package_filters and not any( + project_filter_matches(project_path, package_filter, [package_name]) for package_filter in package_filters + ): continue plans.append( PackagePlan( diff --git a/python/scripts/dependencies/_dependency_bounds_upper_impl.py b/python/scripts/dependencies/_dependency_bounds_upper_impl.py index a92d16cd7e..239e7dd04a 100644 --- a/python/scripts/dependencies/_dependency_bounds_upper_impl.py +++ b/python/scripts/dependencies/_dependency_bounds_upper_impl.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -# ruff: noqa: INP001, S404, S603 +# ruff: noqa: S404, S603 """Raise dependency upper bounds, validate, and persist the latest passing set.""" @@ -22,15 +22,16 @@ from urllib import error as urllib_error from urllib import request as urllib_request import tomli +from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import InvalidVersion, Version +from rich import print + from scripts.dependencies._dependency_bounds_runtime import ( extend_command_with_runtime_tools, extend_command_with_task, next_zero_major_minor_boundary, ) -from packaging.requirements import InvalidRequirement, Requirement -from packaging.version import InvalidVersion, Version -from rich import print -from scripts.task_runner import discover_projects, extract_poe_tasks +from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches CHECK_TASK_PRIORITY = ("check", "typing", "pyright", "mypy", "lint") REQ_PATTERN = r"^\s*([A-Za-z0-9_.-]+(?:\[[^\]]+\])?)\s*(.*?)\s*$" @@ -1088,7 +1089,7 @@ def main() -> None: "--packages", nargs="*", default=None, - help="Optional package filters by workspace path (e.g., packages/core) or package name.", + help="Optional package filters by short name (for example core), workspace path, or package name.", ) parser.add_argument( "--dependencies", @@ -1153,7 +1154,11 @@ def main() -> None: project_section = package_config.get("project", {}) optional_dependencies = project_section.get("optional-dependencies", {}) or {} dependency_groups = package_config.get("dependency-groups", {}) or {} - if package_filters and str(project_path) not in package_filters and package_name not in package_filters: + # Reuse the shared selector matcher so direct optimizer runs accept the + # same short-name package filters as the contributor-facing Poe tasks. + if package_filters and not any( + project_filter_matches(project_path, package_filter, [package_name]) for package_filter in package_filters + ): continue plans.append( PackagePlan( diff --git a/python/scripts/dependencies/add_dependency_to_project.py b/python/scripts/dependencies/add_dependency_to_project.py new file mode 100644 index 0000000000..c595faa887 --- /dev/null +++ b/python/scripts/dependencies/add_dependency_to_project.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: S603 + +"""Add a dependency to one workspace package selected by short name or path. + +``uv add --package`` expects the published workspace distribution name, while +the root Poe surface intentionally speaks in short repo package names such as +``core``. This wrapper keeps the user-facing selector stable and translates it +just before delegating to uv. +""" + +from __future__ import annotations + +import argparse +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import tomli +from rich import print + +from scripts.task_runner import discover_projects, project_filter_matches + + +@dataclass(frozen=True) +class WorkspacePackage: + """Workspace package metadata needed for `uv add --package`.""" + + short_name: str + project_path: Path + distribution_name: str + + +def _load_distribution_name(pyproject_file: Path) -> str: + with pyproject_file.open("rb") as f: + data = tomli.load(f) + return str(data.get("project", {}).get("name", "")).strip() + + +def _discover_workspace_packages(workspace_root: Path) -> list[WorkspacePackage]: + workspace_pyproject = workspace_root / "pyproject.toml" + packages: list[WorkspacePackage] = [] + for project_path in sorted(discover_projects(workspace_pyproject), key=str): + pyproject_file = workspace_root / project_path / "pyproject.toml" + if not pyproject_file.exists(): + continue + distribution_name = _load_distribution_name(pyproject_file) + if not distribution_name: + continue + packages.append( + WorkspacePackage( + short_name=project_path.name, + project_path=project_path, + distribution_name=distribution_name, + ) + ) + return packages + + +def _resolve_workspace_package(workspace_root: Path, project_filter: str) -> WorkspacePackage: + """Resolve one workspace package from a user-facing selector. + + The wrapper accepts the same short-name/path/distribution-name vocabulary as + the other root tasks, but errors on ambiguous matches so dependency edits + never hit the wrong package. + """ + matches = [ + package + for package in _discover_workspace_packages(workspace_root) + if project_filter_matches(package.project_path, project_filter, [package.short_name, package.distribution_name]) + ] + if not matches: + raise SystemExit(f"No workspace package matched selector '{project_filter}'.") + if len(matches) > 1: + names = ", ".join(sorted(package.short_name for package in matches)) + raise SystemExit( + f"Package selector '{project_filter}' matched multiple workspace packages: {names}. " + "Use a more specific short name or path." + ) + return matches[0] + + +def main() -> None: + """Resolve a workspace project selector, then delegate to `uv add`.""" + parser = argparse.ArgumentParser( + description="Add a dependency to a single workspace package selected by short name, path, or package name." + ) + parser.add_argument( + "-P", + "--package", + dest="project", + metavar="PACKAGE", + required=True, + help="Workspace package selector, such as `core`.", + ) + # Keep the old long flag as a silent alias while downstream automation + # finishes moving to the user-facing ``--package`` spelling. + parser.add_argument("--project", dest="project", help=argparse.SUPPRESS) + parser.add_argument("-D", "--dependency", required=True, help="Dependency specifier to add.") + args = parser.parse_args() + + workspace_root = Path(__file__).resolve().parents[2] + package = _resolve_workspace_package(workspace_root, args.project) + print( + f"[cyan]Adding {args.dependency} to {package.short_name} " + f"({package.distribution_name})[/cyan]" + ) + result = subprocess.run( + ["uv", "add", "--package", package.distribution_name, args.dependency], + cwd=workspace_root, + check=False, + ) + if result.returncode: + raise SystemExit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/python/scripts/dependencies/validate_dependency_bounds.py b/python/scripts/dependencies/validate_dependency_bounds.py index 8563cb36da..5c6a9e74e7 100644 --- a/python/scripts/dependencies/validate_dependency_bounds.py +++ b/python/scripts/dependencies/validate_dependency_bounds.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -# ruff: noqa: INP001, S404, S603 +# ruff: noqa: S404, S603 """Unified dependency-bound validation entrypoint. @@ -8,6 +8,10 @@ Modes: - lower: run lower-bound expansion for one package. - upper: run upper-bound expansion for one package. - both: run lower then upper expansion for one package. + +Package filters intentionally reuse the root task selector semantics so the +same short package names (for example ``core``) work in both contributor +commands and direct debugging entrypoints. """ from __future__ import annotations @@ -23,6 +27,7 @@ from pathlib import Path import tomli from rich import print + from scripts.dependencies._dependency_bounds_runtime import ( extend_command_with_runtime_tools, extend_command_with_task, @@ -33,7 +38,7 @@ from scripts.dependencies._dependency_bounds_upper_impl import ( _load_package_name, _resolve_internal_editables, ) -from scripts.task_runner import discover_projects, extract_poe_tasks +from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches _LOWER_IMPL_MODULE = "scripts.dependencies._dependency_bounds_lower_impl" _UPPER_IMPL_MODULE = "scripts.dependencies._dependency_bounds_upper_impl" @@ -76,10 +81,10 @@ def _coerce_subprocess_output(output: str | bytes | None) -> str: def _build_test_plans(workspace_root: Path, package_filter: str | None) -> list[PackageTestPlan]: + """Build per-package test plans for the requested workspace selector.""" workspace_pyproject = workspace_root / "pyproject.toml" package_map = _build_workspace_package_map(workspace_root) internal_graph = _build_internal_graph(workspace_root, package_map) - normalized_filter = None if package_filter in {None, "", "*"} else package_filter plans: list[PackageTestPlan] = [] missing_tasks: list[str] = [] @@ -89,7 +94,14 @@ def _build_test_plans(workspace_root: Path, package_filter: str | None) -> list[ continue package_name = _load_package_name(pyproject_file) - if normalized_filter and str(project_path) != normalized_filter and package_name != normalized_filter: + # Reuse the shared matcher so dependency-bound test mode accepts the + # same short names and legacy path-style selectors as the root Poe + # commands. + if ( + package_filter + and package_filter != "*" + and not project_filter_matches(project_path, package_filter, [package_name]) + ): continue available_tasks = extract_poe_tasks(pyproject_file) @@ -366,7 +378,10 @@ def main() -> None: parser.add_argument( "--package", default=None, - help="Optional workspace package path/name filter for all modes. Use '*' or omit it for the whole workspace.", + help=( + "Optional workspace package selector for all modes, such as `core`. " + "Use '*' or omit it for the whole workspace." + ), ) parser.add_argument( "--dependencies", diff --git a/python/scripts/local_mcp_streamable_http_server.py b/python/scripts/local_mcp_streamable_http_server.py new file mode 100644 index 0000000000..772b7e4161 --- /dev/null +++ b/python/scripts/local_mcp_streamable_http_server.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Run a deterministic local streamable HTTP MCP server for integration tests.""" + +from __future__ import annotations + +import argparse +import asyncio +from collections.abc import Sequence + +from mcp.server.fastmcp import FastMCP +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8011 +DEFAULT_MOUNT_PATH = "/mcp" +SERVER_NAME = "agent-framework-local-ci-mcp" +AGENT_FRAMEWORK_DESCRIPTION = ( + "Microsoft Agent Framework is a multi-language framework for building, orchestrating, and deploying AI agents." +) + + +def _normalize_mount_path(path: str) -> str: + """Normalize a configured mount path for the streamable HTTP endpoint.""" + normalized = path.strip() or DEFAULT_MOUNT_PATH + if not normalized.startswith("/"): + normalized = f"/{normalized}" + return normalized.rstrip("/") or "/" + + +def create_server(*, host: str, port: int, mount_path: str) -> FastMCP: + """Create the local MCP integration test server.""" + server = FastMCP( + name=SERVER_NAME, + instructions="Deterministic local MCP server used by Agent Framework integration tests.", + host=host, + port=port, + streamable_http_path=mount_path, + log_level="INFO", + ) + + @server.custom_route("/healthz", methods=["GET"], include_in_schema=False) + async def healthz(_request: Request) -> Response: + """Return a simple readiness response for CI health checks.""" + await asyncio.sleep(0) + return JSONResponse( + { + "status": "ok", + "name": SERVER_NAME, + "mcp_path": mount_path, + } + ) + + @server.tool( + name="search_agent_framework_docs", + description="Return deterministic Agent Framework documentation text for MCP integration tests.", + ) + def search_agent_framework_docs(query: str) -> str: + """Return a deterministic response for the MCP integration tests.""" + return ( + f"{AGENT_FRAMEWORK_DESCRIPTION}\n\n" + f"Query: {query}\n" + "This response came from the local streamable HTTP MCP integration test server." + ) + + return server + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse CLI arguments for the local MCP server.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default=DEFAULT_HOST, help="Host interface to bind.") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to bind.") + parser.add_argument( + "--mount-path", + default=DEFAULT_MOUNT_PATH, + help="Mount path for the streamable HTTP MCP endpoint.", + ) + return parser.parse_args(list(argv) if argv is not None else None) + + +def main(argv: Sequence[str] | None = None) -> None: + """Start the local MCP streamable HTTP server.""" + args = parse_args(argv) + server = create_server( + host=args.host, + port=args.port, + mount_path=_normalize_mount_path(args.mount_path), + ) + server.run(transport="streamable-http") + + +if __name__ == "__main__": + main() diff --git a/python/scripts/sample_validation/README.md b/python/scripts/sample_validation/README.md index 064d9752da..d7d9f0a08a 100644 --- a/python/scripts/sample_validation/README.md +++ b/python/scripts/sample_validation/README.md @@ -165,18 +165,17 @@ Produces: ## Report Status Codes -| Status | Label | Description | -| ------- | --------- | ----------------------------------------- | -| SUCCESS | [PASS] | Sample ran to completion with exit code 0 | -| FAILURE | [FAIL] | Sample exited with non-zero code | -| TIMEOUT | [TIMEOUT] | Sample exceeded timeout limit | -| ERROR | [ERROR] | Exception during execution | +| Status | Label | Description | +| ------------- | --------------- | ----------------------------------------- | +| SUCCESS | [PASS] | Sample ran to completion with exit code 0 | +| FAILURE | [FAIL] | Sample did not complete successfully (non-zero exit code) | +| MISSING_SETUP | [MISSING_SETUP] | Sample skipped due to missing setup | ## Troubleshooting ### Agent output parsing errors -If an agent returns non-JSON content, that sample is marked as `ERROR` with parser details in the report. +If an agent returns non-JSON content, that sample is marked as `FAILURE` with parser details in the report. ### GitHub Copilot authentication or CLI issues diff --git a/python/scripts/sample_validation/__main__.py b/python/scripts/sample_validation/__main__.py index 5d222b94b9..948fed3a30 100644 --- a/python/scripts/sample_validation/__main__.py +++ b/python/scripts/sample_validation/__main__.py @@ -75,6 +75,13 @@ Examples: help="Custom name for the report files (without extension). If not provided, uses timestamp.", ) + parser.add_argument( + "--exclude", + nargs="+", + type=str, + help="Subdirectory paths to exclude (relative to the search directory set by --subdir)", + ) + return parser.parse_args() @@ -104,6 +111,7 @@ async def main() -> int: samples_dir=samples_dir, python_root=python_root, subdir=args.subdir, + exclude=args.exclude, max_parallel_workers=max(1, args.max_parallel_workers), ) @@ -138,7 +146,7 @@ async def main() -> int: print(f" JSON: {json_path}") # Return appropriate exit code - failed = report.failure_count + report.timeout_count + report.error_count + failed = report.failure_count + report.missing_setup_count return 1 if failed > 0 else 0 diff --git a/python/scripts/sample_validation/aggregate.py b/python/scripts/sample_validation/aggregate.py new file mode 100644 index 0000000000..478bfeafdb --- /dev/null +++ b/python/scripts/sample_validation/aggregate.py @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Aggregate validation reports across runs and produce a trend report. + +Reads JSON reports from individual validation jobs, combines them with +cached history from previous runs, and produces a markdown trend report +showing per-sample status over the last 5 runs. + +Usage: + python aggregate.py +""" + +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +MAX_HISTORY = 5 + +STATUS_EMOJI = { + "success": "✅", + "failure": "❌", + "missing_setup": "⚠️", +} + + +def _format_run_label(timestamp: str) -> str: + """Format a run timestamp as a compact column label (e.g. '03-24 18:05').""" + try: + dt = datetime.fromisoformat(timestamp) + return dt.strftime("%m-%d %H:%M") + except (ValueError, TypeError): + return timestamp[:16] + + +def load_current_run(reports_dir: Path) -> dict[str, Any]: + """Load all JSON report files from the current run and merge them.""" + combined_results: dict[str, str] = {} + total = success = failure = missing = 0 + + json_files = sorted(reports_dir.glob("*.json")) + if not json_files: + print(f"Warning: No JSON report files found in {reports_dir}") + return { + "timestamp": datetime.now().isoformat(), + "summary": { + "total_samples": 0, + "success_count": 0, + "failure_count": 0, + "missing_setup_count": 0, + }, + "results": {}, + } + + for json_file in json_files: + print(f" Loading report: {json_file.name}") + with open(json_file, encoding="utf-8") as f: + report = json.load(f) + for result in report["results"]: + combined_results[result["path"]] = result["status"] + summary = report["summary"] + total += summary["total_samples"] + success += summary["success_count"] + failure += summary["failure_count"] + missing += summary["missing_setup_count"] + + return { + "timestamp": datetime.now().isoformat(), + "summary": { + "total_samples": total, + "success_count": success, + "failure_count": failure, + "missing_setup_count": missing, + }, + "results": combined_results, + } + + +def load_history(history_path: Path) -> list[dict[str, Any]]: + """Load previous run history from cache.""" + if history_path.exists(): + with open(history_path, encoding="utf-8") as f: + data = json.load(f) + runs = data.get("runs", []) + print(f" Loaded {len(runs)} previous run(s) from history") + return runs + print(" No previous history found") + return [] + + +def save_history(history_path: Path, runs: list[dict[str, Any]]) -> None: + """Save run history, keeping only the last MAX_HISTORY entries.""" + history_path.parent.mkdir(parents=True, exist_ok=True) + trimmed = runs[-MAX_HISTORY:] + with open(history_path, "w", encoding="utf-8") as f: + json.dump({"runs": trimmed}, f, indent=2) + print(f" Saved {len(trimmed)} run(s) to history") + + +def generate_trend_report(runs: list[dict[str, Any]]) -> str: + """Generate a markdown trend report from run history.""" + lines = [ + "# Sample Validation Trend Report", + "", + f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M UTC')}*", + "", + ] + + # --- Overall status table (most recent first) --- + lines.append("## Overall Status (Last 5 Runs)") + lines.append("") + lines.append("| Run | Success | Failure | Missing Setup | Total |") + lines.append("|-----|---------|---------|---------------|-------|") + + for run in reversed(runs): + s = run["summary"] + label = _format_run_label(run["timestamp"]) + lines.append( + f"| {label} | {s['success_count']}/{s['total_samples']} " + f"| {s['failure_count']}/{s['total_samples']} " + f"| {s['missing_setup_count']}/{s['total_samples']} " + f"| {s['total_samples']} |" + ) + + # Pad with N/A rows if fewer than 5 runs + for _ in range(MAX_HISTORY - len(runs)): + lines.append("| N/A | N/A | N/A | N/A | N/A |") + + lines.append("") + + # --- Per-sample results table --- + lines.append("## Per-Sample Results") + lines.append("") + + # Collect all sample paths across all runs + all_paths: set[str] = set() + for run in runs: + all_paths.update(run["results"].keys()) + + if not all_paths: + lines.append("*No sample results available.*") + return "\n".join(lines) + + # Build header (most recent run first) + header = "| Sample |" + separator = "|--------|" + for run in reversed(runs): + label = _format_run_label(run["timestamp"]) + header += f" {label} |" + separator += "------------|" + for _ in range(MAX_HISTORY - len(runs)): + header += " N/A |" + separator += "-----|" + + lines.append(header) + lines.append(separator) + + for path in sorted(all_paths): + row = f"| `{path}` |" + for run in reversed(runs): + status = run["results"].get(path, "N/A") + emoji = STATUS_EMOJI.get(status, "N/A") + row += f" {emoji} |" + for _ in range(MAX_HISTORY - len(runs)): + row += " N/A |" + lines.append(row) + + lines.append("") + lines.append("**Legend:** ✅ Success · ❌ Failure · ⚠️ Missing Setup · N/A Not available") + lines.append("") + + return "\n".join(lines) + + +def main() -> int: + if len(sys.argv) != 4: + print("Usage: python aggregate.py ") + return 1 + + reports_dir = Path(sys.argv[1]) + history_path = Path(sys.argv[2]) + output_path = Path(sys.argv[3]) + + print("Aggregating validation results...") + + # Load current run's reports + print(f"\nLoading reports from {reports_dir}:") + current_run = load_current_run(reports_dir) + s = current_run["summary"] + print( + f" Current run: {s['success_count']} success, " + f"{s['failure_count']} failure, " + f"{s['missing_setup_count']} missing setup " + f"(total: {s['total_samples']})" + ) + + # Load history and append current run + print(f"\nLoading history from {history_path}:") + runs = load_history(history_path) + runs.append(current_run) + runs = runs[-MAX_HISTORY:] + + # Save updated history + print(f"\nSaving history to {history_path}:") + save_history(history_path, runs) + + # Generate trend report + print("\nGenerating trend report...") + report = generate_trend_report(runs) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(report, encoding="utf-8") + print(f"Trend report written to {output_path}") + + # Also print the report to stdout + print("\n" + "=" * 80) + print(report) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/scripts/sample_validation/create_dynamic_workflow_executor.py b/python/scripts/sample_validation/create_dynamic_workflow_executor.py index 69c5cc9a5e..4cffd5c71b 100644 --- a/python/scripts/sample_validation/create_dynamic_workflow_executor.py +++ b/python/scripts/sample_validation/create_dynamic_workflow_executor.py @@ -14,7 +14,8 @@ from agent_framework import ( handler, ) from agent_framework.github import GitHubCopilotAgent -from copilot.types import PermissionRequest, PermissionRequestResult +from copilot.generated.session_events import PermissionRequest +from copilot.types import PermissionRequestResult from pydantic import BaseModel from typing_extensions import Never @@ -36,6 +37,7 @@ class AgentResponseFormat(BaseModel): status: str output: str error: str + fix: str @dataclass @@ -54,15 +56,20 @@ class BatchCompletion: AgentInstruction = ( "You are validating exactly one Python sample.\n" - "Analyze the sample code and execute it. Based on the execution result, determine if it " - "runs successfully, fails, or times out. Feel free to install any required dependencies.\n" + "Analyze the sample code and execute it as it is. Based on the execution result, determine " + "if it runs successfully, fails, or is missing_setup. Use `missing_setup` if the sample reports " + "missing required environment variables. The environment you're given should contain the necessary " + "variables. Don't create new environment variables nor modify the sample code.\n" + "Feel free to install any required dependencies if needed.\n" "The sample can be interactive. If it is interactive, respond to the sample when prompted " "based on your analysis of the code. You do not need to consult human on what to respond.\n" + "If the sample fails, investigate the error and suggest a fix.\n" "Return ONLY valid JSON with this schema:\n" "{\n" - ' "status": "success|failure|timeout|error",\n' + ' "status": "success|failure|missing_setup",\n' ' "output": "short summary of the result and what you did if the sample was interactive",\n' - ' "error": "error details or empty string"\n' + ' "error": "error details or empty string",\n' + ' "fix": "suggested code fix if the sample failed, otherwise empty string"\n' "}\n\n" ) @@ -87,16 +94,15 @@ def status_from_text(value: str) -> RunStatus: for status in RunStatus: if status.value == normalized: return status - return RunStatus.ERROR + return RunStatus.FAILURE def prompt_permission( request: PermissionRequest, context: dict[str, str] ) -> PermissionRequestResult: """Permission handler that always approves.""" - kind = request.get("kind", "unknown") logger.debug( - f"[Permission Request: {kind}] ({context})Automatically approved for sample validation." + f"[Permission Request: {request.kind}] ({context})Automatically approved for sample validation." ) return PermissionRequestResult(kind="approved") @@ -108,39 +114,73 @@ class CustomAgentExecutor(Executor): returned as error responses, otherwise an exception in one agent could crash the entire workflow. """ + # Retry in case GitHub Copilot agent encounters transient errors unrelated to the sample execution. + RETRY_COUNT = 1 + def __init__(self, agent: GitHubCopilotAgent): super().__init__(id=agent.id) self.agent = agent + self._session = agent.create_session() @handler async def handle_task( self, sample: SampleInfo, ctx: WorkflowContext[WorkerFreed | RunResult] ) -> None: """Execute one sample task and notify collector + coordinator.""" - try: - response = await self.agent.run( - [ - Message( - role="user", - text=f"Validate the following sample:\n\n{sample.relative_path}", + current_retry = 0 + while True: + try: + response = await self.agent.run( + [ + Message( + role="user", + text=f"Validate the following sample:\n\n{sample.relative_path}", + ) + ], + session=self._session, + ) + result_payload = parse_agent_json(response.text) + result = RunResult( + sample=sample, + status=status_from_text(result_payload.status), + output=result_payload.output, + error=result_payload.error, + fix=result_payload.fix, + ) + break + except Exception as ex: + if current_retry < self.RETRY_COUNT: + logger.warning( + f"Error executing agent {self.agent.id} (attempt {current_retry + 1}/{self.RETRY_COUNT}): {ex}. Retrying..." ) - ] - ) - result_payload = parse_agent_json(response.text) - result = RunResult( - sample=sample, - status=status_from_text(result_payload.status), - output=result_payload.output, - error=result_payload.error, - ) - except Exception as ex: - logger.error(f"Error executing agent {self.agent.id}: {ex}") - result = RunResult( - sample=sample, - status=RunStatus.ERROR, - output="", - error=str(ex), - ) + try: + current_retry += 1 + await self.agent.stop() + await self.agent.start() + self._session = self.agent.create_session() # Reset session for retry + continue + except Exception as restart_ex: + logger.error( + f"Error restarting agent {self.agent.id}: {restart_ex}. No more retries." + ) + result = RunResult( + sample=sample, + status=RunStatus.FAILURE, + output="", + error=f"Original error: {ex}. Restart error: {restart_ex}", + fix="", + ) + break + + logger.error(f"Error executing agent {self.agent.id}: {ex}") + result = RunResult( + sample=sample, + status=RunStatus.FAILURE, + output="", + error=str(ex), + fix="", + ) + break await ctx.send_message(result, target_id="collector") await ctx.send_message(WorkerFreed(worker_id=self.id), target_id="coordinator") @@ -252,7 +292,7 @@ class CreateConcurrentValidationWorkflowExecutor(Executor): instructions=AgentInstruction, default_options={ "on_permission_request": prompt_permission, - "timeout": 180, + "timeout": 60, }, # type: ignore ) agents.append(agent) diff --git a/python/scripts/sample_validation/discovery.py b/python/scripts/sample_validation/discovery.py index 78eb1c9bfa..c5424dd6ee 100644 --- a/python/scripts/sample_validation/discovery.py +++ b/python/scripts/sample_validation/discovery.py @@ -52,13 +52,18 @@ def _has_main_entrypoint_guard(path: Path) -> bool: ) -def discover_samples(samples_dir: Path, subdir: str | None = None) -> list[SampleInfo]: +def discover_samples( + samples_dir: Path, + subdir: str | None = None, + exclude: list[str] | None = None, +) -> list[SampleInfo]: """ Find all Python sample files in the samples directory. Args: samples_dir: Root samples directory subdir: Optional subdirectory to filter to + exclude: Optional list of subdirectory paths (relative to the search directory) to exclude Returns: List of SampleInfo objects for each discovered sample @@ -72,12 +77,21 @@ def discover_samples(samples_dir: Path, subdir: str | None = None) -> list[Sampl else: search_dir = samples_dir + # Resolve excluded paths to absolute for reliable comparison + exclude_paths = {(search_dir / exc).resolve() for exc in (exclude or [])} + python_files: list[Path] = [] # Walk through all subdirectories and find .py files for root, dirs, files in os.walk(search_dir): - # Skip directories that start with _ (like _sample_validation) - dirs[:] = [d for d in dirs if not d.startswith("_") and d != "__pycache__"] + # Skip directories that start with _, __pycache__, or excluded paths + dirs[:] = [ + d + for d in dirs + if not d.startswith("_") + and d != "__pycache__" + and (Path(root) / d).resolve() not in exclude_paths + ] for file in files: # Skip files that start with _ and include only scripts with a main entrypoint guard @@ -113,8 +127,10 @@ class DiscoverSamplesExecutor(Executor): print(f"🔍 Discovering samples in {self.config.samples_dir}") if self.config.subdir: print(f" Filtering to subdirectory: {self.config.subdir}") + if self.config.exclude: + print(f" Excluding: {', '.join(self.config.exclude)}") - samples = discover_samples(self.config.samples_dir, self.config.subdir) + samples = discover_samples(self.config.samples_dir, self.config.subdir, self.config.exclude) print(f" Found {len(samples)} samples") await ctx.send_message(DiscoveryResult(samples=samples)) diff --git a/python/scripts/sample_validation/models.py b/python/scripts/sample_validation/models.py index ca9f26adab..ff45b5909b 100644 --- a/python/scripts/sample_validation/models.py +++ b/python/scripts/sample_validation/models.py @@ -18,6 +18,7 @@ class ValidationConfig: samples_dir: Path python_root: Path subdir: str | None = None + exclude: list[str] | None = None max_parallel_workers: int = 10 @@ -60,8 +61,7 @@ class RunStatus(Enum): SUCCESS = "success" FAILURE = "failure" - TIMEOUT = "timeout" - ERROR = "error" + MISSING_SETUP = "missing_setup" @dataclass @@ -72,6 +72,7 @@ class RunResult: status: RunStatus output: str error: str + fix: str @dataclass @@ -89,8 +90,7 @@ class Report: total_samples: int success_count: int failure_count: int - timeout_count: int - error_count: int + missing_setup_count: int results: list[RunResult] = field(default_factory=list) # type: ignore def to_markdown(self) -> str: @@ -107,15 +107,14 @@ class Report: f"| Total Samples | {self.total_samples} |", f"| [PASS] Success | {self.success_count} |", f"| [FAIL] Failure | {self.failure_count} |", - f"| [TIMEOUT] Timeout | {self.timeout_count} |", - f"| [ERROR] Error | {self.error_count} |", + f"| [MISSING_SETUP] Missing Setup | {self.missing_setup_count} |", "", "## Detailed Results", "", ] # Group by status - for status in [RunStatus.FAILURE, RunStatus.TIMEOUT, RunStatus.ERROR, RunStatus.SUCCESS]: + for status in [RunStatus.FAILURE, RunStatus.MISSING_SETUP, RunStatus.SUCCESS]: status_results = [r for r in self.results if r.status == status] if not status_results: continue @@ -123,8 +122,7 @@ class Report: status_label = { RunStatus.SUCCESS: "[PASS]", RunStatus.FAILURE: "[FAIL]", - RunStatus.TIMEOUT: "[TIMEOUT]", - RunStatus.ERROR: "[ERROR]", + RunStatus.MISSING_SETUP: "[MISSING_SETUP]", } lines.append(f"### {status_label[status]} {status.value.title()} ({len(status_results)})") @@ -148,8 +146,7 @@ class Report: "total_samples": self.total_samples, "success_count": self.success_count, "failure_count": self.failure_count, - "timeout_count": self.timeout_count, - "error_count": self.error_count, + "missing_setup_count": self.missing_setup_count, }, "results": [ { @@ -157,6 +154,7 @@ class Report: "status": r.status.value, "output": r.output, "error": r.error, + "fix": r.fix, } for r in self.results ], diff --git a/python/scripts/sample_validation/report.py b/python/scripts/sample_validation/report.py index db8eddeed1..10c4ff0406 100644 --- a/python/scripts/sample_validation/report.py +++ b/python/scripts/sample_validation/report.py @@ -22,12 +22,11 @@ def generate_report(results: list[RunResult]) -> Report: Returns: Report object with aggregated statistics """ - # Sort results: failures, timeouts, errors first, then successes + # Sort results: failures, missing setup first, then successes status_priority = { RunStatus.FAILURE: 0, - RunStatus.TIMEOUT: 1, - RunStatus.ERROR: 2, - RunStatus.SUCCESS: 3, + RunStatus.MISSING_SETUP: 1, + RunStatus.SUCCESS: 2, } sorted_results = sorted(results, key=lambda r: status_priority[r.status]) @@ -36,8 +35,7 @@ def generate_report(results: list[RunResult]) -> Report: total_samples=len(results), success_count=sum(1 for r in results if r.status == RunStatus.SUCCESS), failure_count=sum(1 for r in results if r.status == RunStatus.FAILURE), - timeout_count=sum(1 for r in results if r.status == RunStatus.TIMEOUT), - error_count=sum(1 for r in results if r.status == RunStatus.ERROR), + missing_setup_count=sum(1 for r in results if r.status == RunStatus.MISSING_SETUP), results=sorted_results, ) @@ -86,8 +84,7 @@ def print_summary(report: Report) -> None: if ( report.failure_count == 0 - and report.timeout_count == 0 - and report.error_count == 0 + and report.missing_setup_count == 0 ): print("[PASS] ALL SAMPLES PASSED!") else: @@ -98,8 +95,7 @@ def print_summary(report: Report) -> None: print("Results:") print(f" [PASS] Success: {report.success_count}") print(f" [FAIL] Failure: {report.failure_count}") - print(f" [TIMEOUT] Timeout: {report.timeout_count}") - print(f" [ERR] Errors: {report.error_count}") + print(f" [MISSING_SETUP] Missing Setup: {report.missing_setup_count}") print("=" * 80) # Print JSON output for GitHub Actions visibility diff --git a/python/scripts/sample_validation/run_dynamic_validation_workflow_executor.py b/python/scripts/sample_validation/run_dynamic_validation_workflow_executor.py index 6f28dc9244..c7244cff2a 100644 --- a/python/scripts/sample_validation/run_dynamic_validation_workflow_executor.py +++ b/python/scripts/sample_validation/run_dynamic_validation_workflow_executor.py @@ -66,9 +66,10 @@ class RunDynamicValidationWorkflowExecutor(Executor): fallback_results = [ RunResult( sample=sample, - status=RunStatus.ERROR, + status=RunStatus.FAILURE, output="", error="Nested workflow did not return an ExecutionResult.", + fix="", ) for sample in creation.samples ] diff --git a/python/scripts/task_runner.py b/python/scripts/task_runner.py index a6e14ccaaa..617b4d58b0 100644 --- a/python/scripts/task_runner.py +++ b/python/scripts/task_runner.py @@ -1,6 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""Shared utilities for running poe tasks across workspace packages in parallel.""" +"""Shared utilities for running Poe tasks across workspace packages. + +These helpers centralize workspace discovery, selector matching, and execution +mode so the root task dispatcher and dependency tooling interpret package +filters the same way. +""" import concurrent.futures import glob @@ -8,6 +13,8 @@ import os import subprocess import sys import time +from collections.abc import Sequence +from fnmatch import fnmatch from pathlib import Path import tomli @@ -70,12 +77,67 @@ def build_work_items(projects: list[Path], task_names: list[str]) -> list[tuple[ return work_items -def _run_task_subprocess(project: Path, task: str, workspace_root: Path) -> tuple[Path, str, int, str, str, float]: +def normalize_project_filter(value: str) -> str: + """Normalize a user-supplied workspace selector. + + Strip presentation differences so short names, relative paths, and globs can + be compared with one matcher. + """ + normalized = value.strip().strip("/").replace("\\", "/") + return normalized or "." + + +def build_project_filter_candidates(project: Path | str, aliases: Sequence[str] = ()) -> set[str]: + """Return accepted selector values for one workspace project. + + We accept the workspace path, short package name, and any supplied aliases + so user-facing ``--package core`` stays stable even when underlying tools + still need paths or distribution names. + """ + normalized_path = normalize_project_filter(str(project)) + candidates = {normalized_path} + if normalized_path == ".": + candidates.update({"./", "root"}) + else: + # Accept bare short names like ``core`` alongside ``packages/core`` and + # ``./packages/core`` so callers do not have to care which form a + # downstream script prefers. + path = Path(normalized_path) + candidates.add(path.name) + candidates.add(f"./{normalized_path}") + + for alias in aliases: + normalized_alias = normalize_project_filter(alias) + if normalized_alias and normalized_alias != ".": + candidates.add(normalized_alias) + + return {candidate.lower() for candidate in candidates} + + +def project_filter_matches(project: Path | str, pattern: str, aliases: Sequence[str] = ()) -> bool: + """Return whether a project matches a user-supplied selector or glob. + + Matching happens against the normalized candidate set so CLI callers can use + the same selector vocabulary everywhere. + """ + normalized_pattern = normalize_project_filter(pattern).lower() + return any( + fnmatch(candidate, normalized_pattern) + for candidate in build_project_filter_candidates(project, aliases) + ) + + +def _run_task_subprocess( + project: Path, + task: str, + workspace_root: Path, + task_args: Sequence[str] = (), +) -> tuple[Path, str, int, str, str, float]: """Run a single poe task in a project directory via subprocess.""" start = time.monotonic() cwd = workspace_root / project result = subprocess.run( - ["uv", "run", "poe", task], + ["uv", "run", "poe", task, *task_args], cwd=cwd, capture_output=True, text=True, @@ -84,20 +146,20 @@ def _run_task_subprocess(project: Path, task: str, workspace_root: Path) -> tupl return (project, task, result.returncode, result.stdout, result.stderr, elapsed) -def _run_sequential(work_items: list[tuple[Path, str]]) -> None: +def _run_sequential(work_items: list[tuple[Path, str]], task_args: Sequence[str] = ()) -> None: """Run tasks sequentially using in-process PoeThePoet (streaming output).""" from poethepoet.app import PoeThePoet for project, task in work_items: print(f"Running task {task} in {project}") app = PoeThePoet(cwd=project) - result = app(cli_args=[task]) + result = app(cli_args=[task, *task_args]) if result: sys.exit(result) -def _run_parallel(work_items: list[tuple[Path, str]], workspace_root: Path) -> None: - """Run all (package × task) combinations in parallel via subprocesses.""" +def _run_parallel(work_items: list[tuple[Path, str]], workspace_root: Path, task_args: Sequence[str] = ()) -> None: + """Run all (package x task) combinations in parallel via subprocesses.""" max_workers = min(len(work_items), os.cpu_count() or 4) failures: list[tuple[Path, str, str, str]] = [] completed = 0 @@ -107,7 +169,7 @@ def _run_parallel(work_items: list[tuple[Path, str]], workspace_root: Path) -> N with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { - executor.submit(_run_task_subprocess, project, task, workspace_root): (project, task) + executor.submit(_run_task_subprocess, project, task, workspace_root, task_args): (project, task) for project, task in work_items } for future in concurrent.futures.as_completed(futures): @@ -123,7 +185,7 @@ def _run_parallel(work_items: list[tuple[Path, str]], workspace_root: Path) -> N if failures: print(f"\n[red]{len(failures)} task(s) failed:[/red]") for project, task, stdout, stderr in failures: - print(f"\n[red]{'='*60}[/red]") + print(f"\n[red]{'=' * 60}[/red]") print(f"[red]FAILED: {task} in {project}[/red]") if stdout.strip(): print(stdout) @@ -134,7 +196,13 @@ def _run_parallel(work_items: list[tuple[Path, str]], workspace_root: Path) -> N print(f"\n[green]All {total} task(s) passed ✓[/green]") -def run_tasks(work_items: list[tuple[Path, str]], workspace_root: Path, *, sequential: bool = False) -> None: +def run_tasks( + work_items: list[tuple[Path, str]], + workspace_root: Path, + *, + sequential: bool = False, + task_args: Sequence[str] = (), +) -> None: """Run work items either in parallel or sequentially. Single items use in-process PoeThePoet for streaming output. @@ -145,6 +213,6 @@ def run_tasks(work_items: list[tuple[Path, str]], workspace_root: Path, *, seque return if sequential or len(work_items) == 1: - _run_sequential(work_items) + _run_sequential(work_items, task_args) else: - _run_parallel(work_items, workspace_root) + _run_parallel(work_items, workspace_root, task_args) diff --git a/python/scripts/workspace_poe_tasks.py b/python/scripts/workspace_poe_tasks.py new file mode 100644 index 0000000000..d9e55e001d --- /dev/null +++ b/python/scripts/workspace_poe_tasks.py @@ -0,0 +1,698 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Dispatch contributor-facing workspace tasks with consistent scope flags. + +This script is the single root-task entrypoint used by ``python/pyproject.toml``. +It keeps selector semantics, aggregate-vs-fan-out behaviour, and compatibility +aliases in one place so docs and automation can share the same command surface. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import tomli +from packaging.specifiers import SpecifierSet +from packaging.version import Version +from rich import print +from task_runner import build_work_items, discover_projects, project_filter_matches, run_tasks + +WORKSPACE_ROOT = Path(__file__).resolve().parent.parent +WORKSPACE_PYPROJECT = WORKSPACE_ROOT / "pyproject.toml" +CURRENT_PYTHON = Version(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}") +SAMPLE_EXCLUDES = "samples/autogen-migration,samples/semantic-kernel-migration" +SAMPLE_RUFF_IGNORE = "E501,ASYNC,B901,TD002" +MARKDOWN_EXCLUDES = [ + "cookiecutter-agent-framework-lab", + "tau2", + "packages/devui/frontend", + "context_providers/azure_ai_search", +] +DEFAULT_AGGREGATE_TEST_EXCLUDES = {"devui", "lab"} + + +@dataclass(frozen=True) +class WorkspaceProject: + """Metadata about a workspace package.""" + + path: Path + name: str + distribution_name: str + requires_python: str | None + + +def parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + """Parse the workspace command and return any pass-through arguments.""" + parser = argparse.ArgumentParser(description="Dispatch workspace Poe tasks with consistent scope flags.") + subparsers = parser.add_subparsers(dest="command", required=True) + + def add_project_option(command: argparse.ArgumentParser) -> None: + command.add_argument( + "-P", + "--package", + dest="project", + default="*", + metavar="PACKAGE", + help="Workspace package selector or glob pattern, such as `core`.", + ) + # Keep a hidden compatibility alias while old automation and local + # muscle memory migrate from ``--project`` to ``--package``. + command.add_argument("--project", dest="project", help=argparse.SUPPRESS) + + def add_syntax_mode_options(command: argparse.ArgumentParser) -> None: + command.add_argument("-F", "--format", action="store_true", help="Run formatting only.") + command.add_argument("-C", "--check", action="store_true", help="Run lint checks only.") + + def add_all_option(command: argparse.ArgumentParser) -> None: + command.add_argument("-A", "--all", action="store_true", help="Run a single aggregate workspace sweep.") + + def add_samples_option(command: argparse.ArgumentParser) -> None: + command.add_argument("-S", "--samples", action="store_true", help="Target samples/ instead of packages.") + + def add_cov_option(command: argparse.ArgumentParser) -> None: + command.add_argument("-C", "--cov", action="store_true", help="Enable coverage output.") + + syntax = subparsers.add_parser("syntax") + add_project_option(syntax) + add_samples_option(syntax) + add_syntax_mode_options(syntax) + + for command_name in ("fmt", "build", "clean-dist", "check-packages"): + command = subparsers.add_parser(command_name) + add_project_option(command) + + lint = subparsers.add_parser("lint") + add_project_option(lint) + add_samples_option(lint) + + pyright = subparsers.add_parser("pyright") + add_project_option(pyright) + add_all_option(pyright) + add_samples_option(pyright) + + mypy = subparsers.add_parser("mypy") + add_project_option(mypy) + add_all_option(mypy) + + typing = subparsers.add_parser("typing") + add_project_option(typing) + add_all_option(typing) + + test = subparsers.add_parser("test") + add_project_option(test) + add_all_option(test) + add_cov_option(test) + + check = subparsers.add_parser("check") + add_project_option(check) + add_samples_option(check) + + prek_check = subparsers.add_parser("prek-check") + prek_check.add_argument("files", nargs="*", default=["."], help="Files reported by pre-commit.") + + subparsers.add_parser("ci-mypy") + + return parser.parse_known_args(argv) + + +def load_toml(file_path: Path) -> dict: + """Load a TOML file.""" + with file_path.open("rb") as file: + return tomli.load(file) + + +def discover_workspace_projects() -> list[WorkspaceProject]: + """Return workspace packages together with their Python-version metadata.""" + projects: list[WorkspaceProject] = [] + for project_path in discover_projects(WORKSPACE_PYPROJECT): + pyproject = load_toml(WORKSPACE_ROOT / project_path / "pyproject.toml") + requires_python = pyproject.get("project", {}).get("requires-python") + distribution_name = str(pyproject.get("project", {}).get("name", "")).strip() + projects.append( + WorkspaceProject( + path=project_path, + name=project_path.name, + distribution_name=distribution_name, + requires_python=requires_python, + ) + ) + return projects + + +def supports_current_python(project: WorkspaceProject) -> bool: + """Return whether the current interpreter satisfies the project's Python requirement.""" + if not project.requires_python: + return True + return SpecifierSet(project.requires_python).contains(CURRENT_PYTHON, prereleases=True) + + +def select_projects(pattern: str) -> list[WorkspaceProject]: + """Select supported workspace projects that match the supplied pattern. + + The shared matcher accepts short names such as ``core``, legacy path-style + values, and distribution names so every root task family speaks the same + selector dialect. + """ + matched_projects = [ + project + for project in discover_workspace_projects() + if project_filter_matches(project.path, pattern, aliases=[project.name, project.distribution_name]) + ] + if not matched_projects: + print(f"[red]No workspace projects matched pattern '{pattern}'.[/red]") + raise SystemExit(2) + + supported_projects = [project for project in matched_projects if supports_current_python(project)] + unsupported_projects = [project.name for project in matched_projects if not supports_current_python(project)] + if unsupported_projects: + version = f"{sys.version_info.major}.{sys.version_info.minor}" + print( + "[yellow]Skipping packages not supported by " + f"Python {version}: {', '.join(sorted(unsupported_projects))}[/yellow]" + ) + + return supported_projects + + +def relative_path(path: Path) -> str: + """Convert a workspace path to a stable relative string.""" + return path.relative_to(WORKSPACE_ROOT).as_posix() + + +def collect_source_dirs(projects: list[WorkspaceProject]) -> list[Path]: + """Collect top-level import package directories for the selected projects.""" + source_dirs: set[Path] = set() + for project in projects: + project_root = WORKSPACE_ROOT / project.path + for init_file in project_root.rglob("__init__.py"): + package_dir = init_file.parent + if package_dir.name.startswith("agent_framework"): + source_dirs.add(package_dir) + return sorted(source_dirs) + + +def collect_test_dirs(projects: list[WorkspaceProject]) -> list[Path]: + """Collect test directories for the selected projects.""" + test_dirs: set[Path] = set() + for project in projects: + project_root = WORKSPACE_ROOT / project.path + for directory_name in ("tests", "ag_ui_tests"): + for test_dir in project_root.rglob(directory_name): + relative_test_dir = test_dir.relative_to(project_root) + # Ignore hidden/generated trees such as ``.mypy_cache`` so the + # aggregate sweep only targets real repository test directories. + if test_dir.is_dir() and not any(part.startswith(".") for part in relative_test_dir.parts): + test_dirs.add(test_dir) + return sorted(test_dirs) + + +def run_command(command: list[str]) -> None: + """Run a subprocess from the workspace root and stream its output.""" + result = subprocess.run(command, cwd=WORKSPACE_ROOT, check=False) + if result.returncode: + raise SystemExit(result.returncode) + + +def run_fan_out(task_names: list[str], project_pattern: str, task_args: list[str]) -> None: + """Run package-local Poe tasks across the selected projects.""" + selected_projects = select_projects(project_pattern) + if not selected_projects: + print("[yellow]No selected projects support the current Python version, skipping.[/yellow]") + return + + work_items = build_work_items([project.path for project in selected_projects], task_names) + run_tasks(work_items, WORKSPACE_ROOT, task_args=task_args) + + +def sample_pyright_config() -> str: + """Return the sample Pyright configuration for the current interpreter.""" + if sys.version_info < (3, 11): + return "pyrightconfig.samples.py310.json" + return "pyrightconfig.samples.json" + + +def run_sample_lint(extra_args: list[str]) -> None: + """Run linting against samples/.""" + command = [ + "uv", + "run", + "ruff", + "check", + "samples", + "--fix", + "--exclude", + SAMPLE_EXCLUDES, + "--ignore", + SAMPLE_RUFF_IGNORE, + *extra_args, + ] + run_command(command) + + +def run_sample_format(extra_args: list[str]) -> None: + """Run formatting against samples/.""" + command = [ + "uv", + "run", + "ruff", + "format", + "samples", + "--exclude", + SAMPLE_EXCLUDES, + *extra_args, + ] + run_command(command) + + +def run_sample_pyright(extra_args: list[str]) -> None: + """Run sample syntax/import validation.""" + command = ["uv", "run", "pyright", "-p", sample_pyright_config(), "--warnings", *extra_args] + run_command(command) + + +def run_markdown_code_lint(files: list[str] | None = None) -> None: + """Run markdown code-block linting globally or for the changed markdown files only.""" + command = [ + "uv", + "run", + "python", + "scripts/check_md_code_blocks.py", + ] + if files is None: + command.extend([ + "README.md", + "./packages/**/README.md", + "./samples/**/*.md", + ]) + else: + if not files: + print("[yellow]No markdown files changed, skipping markdown code lint.[/yellow]") + return + command.extend(files) + command.append("--no-glob") + + for excluded_path in MARKDOWN_EXCLUDES: + command.extend(["--exclude", excluded_path]) + run_command(command) + + +def run_aggregate_pyright(project_pattern: str, extra_args: list[str]) -> None: + """Run a single Pyright sweep across the selected project roots.""" + projects = select_projects(project_pattern) + if not projects: + print("[yellow]No selected projects support the current Python version, skipping.[/yellow]") + return + + project_paths = [relative_path(WORKSPACE_ROOT / project.path) for project in projects] + run_command(["uv", "run", "pyright", *extra_args, *project_paths]) + + +def run_aggregate_mypy(project_pattern: str, extra_args: list[str]) -> None: + """Run a single MyPy sweep across the selected project import roots.""" + projects = select_projects(project_pattern) + if not projects: + print("[yellow]No selected projects support the current Python version, skipping.[/yellow]") + return + + source_dirs = [relative_path(path) for path in collect_source_dirs(projects)] + if not source_dirs: + print("[yellow]No import roots found for the selected projects, skipping MyPy.[/yellow]") + return + + run_command(["uv", "run", "mypy", "--config-file", "pyproject.toml", *extra_args, *source_dirs]) + + +def run_aggregate_test(project_pattern: str, cov: bool, extra_args: list[str]) -> None: + """Run a single pytest sweep across the selected project test directories.""" + projects = select_projects(project_pattern) + if not projects: + print("[yellow]No selected projects support the current Python version, skipping.[/yellow]") + return + + if project_pattern == "*": + # Preserve the legacy ``all-tests`` contract when ``test --all`` runs with + # the default selector: experimental packages stay opt-in instead of + # suddenly joining every PR unit-test sweep. + projects = [project for project in projects if project.name not in DEFAULT_AGGREGATE_TEST_EXCLUDES] + if not projects: + print("[yellow]No aggregate-test projects remain after applying default exclusions.[/yellow]") + return + + test_dirs = [relative_path(path) for path in collect_test_dirs(projects)] + if not test_dirs: + print("[yellow]No test directories found for the selected projects, skipping pytest.[/yellow]") + return + + command = [ + "uv", + "run", + "pytest", + "--import-mode=importlib", + "-m", + "not integration", + "-rs", + "-n", + "logical", + "--dist", + "worksteal", + ] + if cov: + for source_dir in collect_source_dirs(projects): + command.append(f"--cov={source_dir.name}") + command.extend(["--cov-config=pyproject.toml", "--cov-report=term-missing:skip-covered"]) + + command.extend(extra_args) + command.extend(test_dirs) + run_command(command) + + +def normalize_changed_file(file_path: str) -> str: + """Normalize changed-file paths passed from git or pre-commit.""" + normalized = file_path.replace("\\", "/") + if normalized.startswith("python/"): + return normalized[7:] + return normalized + + +def has_changed_sample_files(files: list[str]) -> bool: + """Return whether any changed file lives under samples/.""" + return any(normalize_changed_file(file_path).startswith("samples/") for file_path in files) + + +def changed_markdown_files(files: list[str]) -> list[str]: + """Return markdown files from the provided change list.""" + markdown_files = [normalize_changed_file(file_path) for file_path in files] + return sorted({file_path for file_path in markdown_files if file_path.endswith(".md")}) + + +def run_changed_package_tasks(task_names: list[str], files: list[str]) -> None: + """Run package-local tasks only in packages affected by the provided file list.""" + command = [ + "uv", + "run", + "python", + "scripts/run_tasks_in_changed_packages.py", + *task_names, + "--files", + *files, + ] + run_command(command) + + +def run_prek_check(files: list[str]) -> None: + """Run the lightweight pre-commit task surface.""" + normalized_files = [normalize_changed_file(file_path) for file_path in files] or ["."] + run_changed_package_tasks(["fmt", "lint"], normalized_files) + run_markdown_code_lint(changed_markdown_files(normalized_files)) + if has_changed_sample_files(normalized_files): + print("[cyan]Sample files changed, running sample checks.[/cyan]") + run_sample_lint([]) + run_sample_pyright([]) + else: + print("[yellow]No sample files changed, skipping sample checks.[/yellow]") + + +def git_diff_name_only(*revisions: str) -> list[str] | None: + """Try a git diff strategy and return changed files if it succeeds.""" + result = subprocess.run( + ["git", "diff", "--name-only", *revisions, "--", "."], + cwd=WORKSPACE_ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + return [line for line in result.stdout.splitlines() if line] + + +def detect_ci_changed_files() -> list[str]: + """Detect changed files for change-based mypy runs.""" + base_ref = os.environ.get("GITHUB_BASE_REF") + if base_ref: + subprocess.run( + ["git", "fetch", "origin", base_ref, "--depth=1"], + cwd=WORKSPACE_ROOT, + capture_output=True, + text=True, + check=False, + ) + strategies = [ + (f"origin/{base_ref}...HEAD",), + ("FETCH_HEAD...HEAD",), + ("HEAD^...HEAD",), + ] + else: + strategies = [ + ("origin/main...HEAD",), + ("main...HEAD",), + ("HEAD~1",), + ] + + for strategy in strategies: + changed_files = git_diff_name_only(*strategy) + if changed_files is not None: + return changed_files or ["."] + + return ["."] + + +def run_ci_mypy() -> None: + """Run MyPy only where changes require it, mirroring CI behaviour.""" + changed_files = detect_ci_changed_files() + print("[cyan]Changed files for CI mypy:[/cyan]") + for file_path in changed_files: + print(f" {file_path}") + run_changed_package_tasks(["mypy"], changed_files) + + +def ensure_no_extra_args(command_name: str, extra_args: list[str]) -> None: + """Reject unsupported pass-through arguments for commands that do not forward them.""" + if extra_args: + joined_args = " ".join(extra_args) + print(f"[red]Command '{command_name}' does not accept extra arguments: {joined_args}[/red]") + raise SystemExit(2) + + +def resolve_syntax_modes(*, format_selected: bool, check_selected: bool) -> tuple[bool, bool]: + """Resolve which syntax steps to run.""" + if not format_selected and not check_selected: + return True, True + return format_selected, check_selected + + +def run_syntax( + *, + project_pattern: str, + samples: bool, + format_selected: bool, + check_selected: bool, + extra_args: list[str], +) -> None: + """Run formatting and/or lint checking for packages or samples. + + Combined package mode deliberately dispatches ``fmt`` and ``lint`` together + so the shared task runner can start both legs in parallel. + """ + run_format, run_check = resolve_syntax_modes( + format_selected=format_selected, + check_selected=check_selected, + ) + if run_format and run_check and extra_args: + joined_args = " ".join(extra_args) + print( + "[red]Extra arguments are only supported when syntax runs a single mode; " + f"use either --format or --check with: {joined_args}[/red]" + ) + raise SystemExit(2) + + if samples and project_pattern != "*": + print("[red]--samples cannot be combined with --package.[/red]") + raise SystemExit(2) + + format_args = extra_args if run_format and not run_check else [] + check_args = extra_args if run_check and not run_format else [] + + if samples: + if run_format: + run_sample_format(format_args) + if run_check: + run_sample_lint(check_args) + return + + if run_format and run_check: + # Fan out both legs in one call so task_runner can parallelize format + # and lint work across the same selected package set. + run_fan_out(["fmt", "lint"], project_pattern, []) + return + + if run_format: + run_fan_out(["fmt"], project_pattern, format_args) + if run_check: + run_fan_out(["lint"], project_pattern, check_args) + + +def main() -> None: + """Dispatch the requested workspace task.""" + args, extra_args = parse_args(sys.argv[1:]) + + if args.command == "syntax": + run_syntax( + project_pattern=args.project, + samples=args.samples, + format_selected=args.format, + check_selected=args.check, + extra_args=extra_args, + ) + return + + if args.command == "fmt": + run_syntax( + project_pattern=args.project, + samples=False, + format_selected=True, + check_selected=False, + extra_args=extra_args, + ) + return + + if args.command == "lint": + if args.samples: + run_syntax( + project_pattern=args.project, + samples=True, + format_selected=False, + check_selected=True, + extra_args=extra_args, + ) + return + run_syntax( + project_pattern=args.project, + samples=False, + format_selected=False, + check_selected=True, + extra_args=extra_args, + ) + return + + if args.command == "pyright": + if args.samples: + if args.all or args.project != "*": + print("[red]--samples cannot be combined with --all or --package.[/red]") + raise SystemExit(2) + run_sample_pyright(extra_args) + return + if args.all: + run_aggregate_pyright(args.project, extra_args) + return + run_fan_out(["pyright"], args.project, extra_args) + return + + if args.command == "mypy": + if args.all: + run_aggregate_mypy(args.project, extra_args) + return + run_fan_out(["mypy"], args.project, extra_args) + return + + if args.command == "typing": + ensure_no_extra_args(args.command, extra_args) + if args.all: + # Start MyPy first so combined typing runs follow the requested + # ordering even though completion still depends on runtime duration. + run_aggregate_mypy(args.project, []) + run_aggregate_pyright(args.project, []) + return + # Preserve the same "MyPy first" ordering for the per-package fan-out + # path as well. + run_fan_out(["mypy", "pyright"], args.project, []) + return + + if args.command == "test": + if args.all: + run_aggregate_test(args.project, args.cov, extra_args) + return + run_fan_out(["test"], args.project, extra_args) + return + + if args.command == "build": + ensure_no_extra_args(args.command, extra_args) + run_fan_out(["build"], args.project, []) + return + + if args.command == "clean-dist": + ensure_no_extra_args(args.command, extra_args) + run_fan_out(["clean-dist"], args.project, []) + return + + if args.command == "check-packages": + ensure_no_extra_args(args.command, extra_args) + run_syntax( + project_pattern=args.project, + samples=False, + format_selected=False, + check_selected=False, + extra_args=[], + ) + run_fan_out(["pyright"], args.project, []) + return + + if args.command == "check": + ensure_no_extra_args(args.command, extra_args) + if args.samples: + if args.project != "*": + print("[red]--samples cannot be combined with --package.[/red]") + raise SystemExit(2) + run_syntax( + project_pattern="*", + samples=True, + format_selected=False, + check_selected=False, + extra_args=[], + ) + run_sample_pyright([]) + return + run_syntax( + project_pattern=args.project, + samples=False, + format_selected=False, + check_selected=False, + extra_args=[], + ) + run_fan_out(["pyright"], args.project, []) + run_fan_out(["test"], args.project, []) + # Sample validation and markdown lint are intentionally workspace-wide; + # a package-scoped check should stay focused on the selected package set. + if args.project == "*": + run_syntax( + project_pattern="*", + samples=True, + format_selected=False, + check_selected=False, + extra_args=[], + ) + run_sample_pyright([]) + run_markdown_code_lint() + return + + if args.command == "prek-check": + ensure_no_extra_args(args.command, extra_args) + run_prek_check(args.files) + return + + if args.command == "ci-mypy": + ensure_no_extra_args(args.command, extra_args) + run_ci_mypy() + return + + print(f"[red]Unsupported command: {args.command}[/red]") + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/python/shared_tasks.toml b/python/shared_tasks.toml index 775cb6ec0a..427cc1c5a7 100644 --- a/python/shared_tasks.toml +++ b/python/shared_tasks.toml @@ -1,10 +1,39 @@ -[tool.poe.tasks] -fmt = "ruff format" -format.ref = "fmt" -lint = "ruff check" -pyright = "pyright" -publish = "uv publish" -clean-dist = "rm -rf dist" -build-package = "uv build" -move-dist = "sh -c 'mkdir -p ../../dist && mv dist/* ../../dist/ 2>/dev/null || true'" -build = ["build-package", "move-dist"] +[tool.poe.tasks.syntax] +help = "Run Ruff formatting and Ruff checks for this package." +sequence = ["fmt", "lint"] + +[tool.poe.tasks.fmt] +help = "DEPRECATED: Use `syntax --format` instead." +cmd = "ruff format" + +[tool.poe.tasks.format] +help = "DEPRECATED: Use `syntax --format` instead." +ref = "fmt" + +[tool.poe.tasks.lint] +help = "DEPRECATED: Use `syntax --check` instead." +cmd = "ruff check" + +[tool.poe.tasks.pyright] +help = "Run Pyright for this package." +cmd = "pyright" + +[tool.poe.tasks.publish] +help = "Publish this package with uv." +cmd = "uv publish" + +[tool.poe.tasks.clean-dist] +help = "Remove generated dist artifacts for this package." +cmd = "rm -rf dist" + +[tool.poe.tasks.build-package] +help = "Build distribution artifacts for this package." +cmd = "uv build" + +[tool.poe.tasks.move-dist] +help = "Move built package artifacts into the workspace dist directory." +cmd = "sh -c 'mkdir -p ../../dist && mv dist/* ../../dist/ 2>/dev/null || true'" + +[tool.poe.tasks.build] +help = "Build this package and move its artifacts into the workspace dist directory." +sequence = ["build-package", "move-dist"] diff --git a/python/uv.lock b/python/uv.lock index a21d16ed54..317113161f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -42,15 +42,18 @@ members = [ "agent-framework-declarative", "agent-framework-devui", "agent-framework-durabletask", + "agent-framework-foundry", "agent-framework-foundry-local", "agent-framework-github-copilot", "agent-framework-lab", "agent-framework-mem0", "agent-framework-ollama", + "agent-framework-openai", "agent-framework-orchestrations", "agent-framework-purview", "agent-framework-redis", ] +constraints = [{ name = "litellm", url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" }] [[package]] name = "a2a-sdk" @@ -91,7 +94,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'" }, @@ -100,7 +103,9 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -122,7 +127,9 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ { name = "flit", specifier = "==3.12.0" }, + { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, { name = "mypy", specifier = "==1.19.1" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, { name = "poethepoet", specifier = "==0.42.1" }, { name = "prek", specifier = "==0.3.4" }, { name = "pyright", specifier = "==1.1.408" }, @@ -140,7 +147,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 +162,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 +190,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,26 +205,32 @@ 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'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-ai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-ai-inference", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-openai", editable = "packages/openai" }, { name = "aiohttp", specifier = ">=3.7.0,<4" }, { name = "azure-ai-agents", specifier = ">=1.2.0b5,<1.2.0b6" }, { name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" }, + { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, + { name = "azure-identity", specifier = ">=1,<2" }, ] [[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 +245,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 +260,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 +282,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 +299,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 +314,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 +329,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,17 +344,10 @@ 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'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -362,14 +368,17 @@ all = [ { name = "agent-framework-declarative", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-foundry-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-github-copilot", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -387,22 +396,18 @@ requires-dist = [ { name = "agent-framework-declarative", marker = "extra == 'all'", editable = "packages/declarative" }, { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, { name = "agent-framework-durabletask", marker = "extra == 'all'", editable = "packages/durabletask" }, + { name = "agent-framework-foundry", marker = "extra == 'all'", editable = "packages/foundry" }, { name = "agent-framework-foundry-local", marker = "extra == 'all'", editable = "packages/foundry_local" }, { name = "agent-framework-github-copilot", marker = "python_full_version >= '3.11' and extra == 'all'", editable = "packages/github_copilot" }, { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, { name = "agent-framework-ollama", marker = "extra == 'all'", editable = "packages/ollama" }, + { name = "agent-framework-openai", marker = "extra == 'all'", editable = "packages/openai" }, { name = "agent-framework-orchestrations", marker = "extra == 'all'", editable = "packages/orchestrations" }, { name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" }, { name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" }, - { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, - { name = "azure-identity", specifier = ">=1,<2" }, - { name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" }, - { name = "openai", specifier = ">=1.99.0,<3" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.24.0,<2" }, { name = "opentelemetry-api", specifier = ">=1.39.0,<2" }, - { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, - { name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.13,<0.4.14" }, - { name = "packaging", specifier = ">=24.1,<25" }, { name = "pydantic", specifier = ">=2,<3" }, { name = "python-dotenv", specifier = ">=1,<2" }, { name = "typing-extensions", specifier = ">=4.15.0,<5" }, @@ -411,7 +416,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,11 +441,13 @@ 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'" }, { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] @@ -460,6 +467,8 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" }, { name = "fastapi", specifier = ">=0.115.0,<0.133.1" }, + { name = "openai", specifier = ">=1.99.0,<3" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, { name = "pytest", marker = "extra == 'all'", specifier = "==9.0.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.42.0" }, @@ -470,7 +479,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'" }, @@ -495,24 +504,43 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260305" }] +[[package]] +name = "agent-framework-foundry" +version = "1.0.0rc5" +source = { editable = "packages/foundry" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-openai", editable = "packages/openai" }, + { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, +] + [[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'" }, + { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "foundry-local-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-openai", editable = "packages/openai" }, { name = "foundry-local-sdk", specifier = ">=0.5.1,<0.5.2" }, ] [[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 +555,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'" }, @@ -537,6 +565,7 @@ dependencies = [ gaia = [ { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyarrow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -579,6 +608,7 @@ requires-dist = [ { name = "loguru", marker = "extra == 'tau2'", specifier = ">=0.7.3" }, { name = "numpy", marker = "extra == 'tau2'" }, { name = "opentelemetry-api", marker = "extra == 'gaia'", specifier = ">=1.39.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'gaia'", specifier = ">=1.39.0,<2" }, { name = "orjson", marker = "extra == 'gaia'", specifier = ">=3.10.7,<4" }, { name = "pyarrow", marker = "extra == 'gaia'", specifier = ">=18.0.0" }, { name = "pydantic", marker = "extra == 'gaia'", specifier = ">=2.0.0" }, @@ -606,7 +636,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 +651,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'" }, @@ -634,9 +664,26 @@ requires-dist = [ { name = "ollama", specifier = ">=0.5.3,<0.5.4" }, ] +[[package]] +name = "agent-framework-openai" +version = "1.0.0rc5" +source = { editable = "packages/openai" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "openai", specifier = ">=1.99.0,<3" }, + { name = "packaging", specifier = ">=24.1,<25" }, +] + [[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 +694,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 +711,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'" }, @@ -968,11 +1015,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -1005,7 +1052,7 @@ wheels = [ [[package]] name = "azure-ai-projects" -version = "2.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1015,9 +1062,9 @@ dependencies = [ { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/3d/6a7d04f61f3befc74a6f09ad7a0c02e8c701fc6db91ad7151c46da44a902/azure_ai_projects-2.0.0.tar.gz", hash = "sha256:0892f075cf287d747be54c25bea93dc9406ad100d44efc2fdaadb26586ecf4ff", size = 491449, upload-time = "2026-03-06T05:59:51.645Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/f9/a15c8a16e35e6d620faebabc6cc4f9e2f4b7f1d962cc6f58931c46947e24/azure_ai_projects-2.0.1.tar.gz", hash = "sha256:c8c64870aa6b89903af69a4ff28b4eff3df9744f14615ea572cae87394946a0c", size = 491774, upload-time = "2026-03-12T19:59:02.712Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/af/7b218cccab8e22af44844bfc16275b55c1fa48ed494145614b9852950fe6/azure_ai_projects-2.0.0-py3-none-any.whl", hash = "sha256:e655e0e495d0c76077d95cc8e0d606fcdbf3f4dbdf1a8379cbd4bea1e34c401d", size = 236354, upload-time = "2026-03-06T05:59:53.536Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f7/290ca39501c06c6e23b46ba9f7f3dfb05ecc928cde105fed85d6845060dd/azure_ai_projects-2.0.1-py3-none-any.whl", hash = "sha256:dfda540d256e67a52bf81c75418b6bf92b811b96693fe45787e154a888ad2396", size = 236560, upload-time = "2026-03-12T19:59:04.249Z" }, ] [[package]] @@ -1031,15 +1078,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.38.2" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734, upload-time = "2026-02-18T19:33:05.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957, upload-time = "2026-02-18T19:33:07.696Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, ] [[package]] @@ -1087,7 +1134,7 @@ wheels = [ [[package]] name = "azure-identity" -version = "1.25.2" +version = "1.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1096,9 +1143,9 @@ dependencies = [ { name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" }, + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, ] [[package]] @@ -1160,30 +1207,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.66" +version = "1.42.73" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/2e/67206daa5acb6053157ae5241421713a84ed6015d33d0781985bd5558898/boto3-1.42.66.tar.gz", hash = "sha256:3bec5300fb2429c3be8e8961fdb1f11e85195922c8a980022332c20af05616d5", size = 112805, upload-time = "2026-03-11T19:58:19.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8b/d00575be514744ca4839e7d85bf4a8a3c7b6b4574433291e58d14c68ae09/boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae", size = 112775, upload-time = "2026-03-20T19:39:52.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/09/83224363c3f5e468e298e48beb577ffe8cb51f18c2116bc1ecf404796e60/boto3-1.42.66-py3-none-any.whl", hash = "sha256:7c6c60dc5500e8a2967a306372a5fdb4c7f9a5b8adc5eb9aa2ebb5081c51ff47", size = 140557, upload-time = "2026-03-11T19:58:17.61Z" }, + { url = "https://files.pythonhosted.org/packages/aa/05/1fcf03d90abaa3d0b42a6bfd10231dd709493ecbacf794aa2eea5eae6841/boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455", size = 140556, upload-time = "2026-03-20T19:39:50.298Z" }, ] [[package]] name = "botocore" -version = "1.42.66" +version = "1.42.73" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/ef/1c8f89da69b0c3742120e19a6ea72ec46ac0596294466924fdd4cf0f36bb/botocore-1.42.66.tar.gz", hash = "sha256:39756a21142b646de552d798dde2105759b0b8fa0d881a34c26d15bd4c9448fa", size = 14977446, upload-time = "2026-03-11T19:58:07.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/6f/7b45ed2ca300c1ad38ecfc82c1368546d4a90512d9dff589ebbd182a7317/botocore-1.42.66-py3-none-any.whl", hash = "sha256:ac48af1ab527dfa08c4617c387413ca56a7f87780d7bfc1da34ef847a59219a5", size = 14653886, upload-time = "2026-03-11T19:58:04.922Z" }, + { url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" }, ] [[package]] @@ -1279,91 +1326,107 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.5" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, - { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, - { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, - { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, - { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, - { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, - { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, - { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, - { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, - { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, - { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, - { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, - { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, - { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, - { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, - { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, - { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, - { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, - { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, - { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, - { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, - { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, - { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, - { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, - { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, - { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, - { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, - { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, - { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, - { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, - { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, - { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, - { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, - { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, - { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, - { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, - { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, - { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, - { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, - { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, - { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, - { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, - { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, - { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, - { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, - { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] @@ -1586,115 +1649,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" +version = "7.13.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, - { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, - { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, - { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, - { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, - { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, - { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, - { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [package.optional-dependencies] @@ -1704,15 +1767,14 @@ toml = [ [[package]] name = "croniter" -version = "6.0.0" +version = "6.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, ] [[package]] @@ -1786,14 +1848,14 @@ wheels = [ [[package]] name = "deepdiff" -version = "8.6.1" +version = "8.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "orderly-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/76/36c9aab3d5c19a94091f7c6c6e784efca50d87b124bf026c36e94719f33c/deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a", size = 634054, upload-time = "2025-09-03T19:40:41.461Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/50/767448e792d41bfb6094ee317a355c1cb221dca24b2e178e2203bbea2a77/deepdiff-8.6.2.tar.gz", hash = "sha256:186dcbd181e4d76cef11ab05f802d0056c5d6083c5a6748c1473e9d7481e183e", size = 634860, upload-time = "2026-03-18T17:16:33.785Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5f/c52bd1255db763d0cdcb7084d2e90c42119cb229302c56bdf1d0aa78abd2/deepdiff-8.6.2-py3-none-any.whl", hash = "sha256:4d22034a866c3928303a9332c279362f714192d9305bac17c498720d095fd1b4", size = 91979, upload-time = "2026-03-18T17:16:32.171Z" }, ] [[package]] @@ -2041,59 +2103,59 @@ wheels = [ [[package]] name = "fonttools" -version = "4.62.0" +version = "4.62.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/96/686339e0fda8142b7ebed39af53f4a5694602a729662f42a6209e3be91d0/fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098", size = 3579521, upload-time = "2026-03-09T16:50:06.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/e0/9db48ec7f6b95bae7b20667ded54f18dba8e759ef66232c8683822ae26fc/fonttools-4.62.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:62b6a3d0028e458e9b59501cf7124a84cd69681c433570e4861aff4fb54a236c", size = 2873527, upload-time = "2026-03-09T16:48:12.416Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/86eccfdc922cb9fafc63189a9793fa9f6dd60e68a07be42e454ef2c0deae/fonttools-4.62.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:966557078b55e697f65300b18025c54e872d7908d1899b7314d7c16e64868cb2", size = 2417427, upload-time = "2026-03-09T16:48:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/d3/98/f547a1fceeae81a9a5c6461bde2badac8bf50bda7122a8012b32b1e65396/fonttools-4.62.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf34861145b516cddd19b07ae6f4a61ea1c6326031b960ec9ddce8ee815e888", size = 4934993, upload-time = "2026-03-09T16:48:18.186Z" }, - { url = "https://files.pythonhosted.org/packages/5c/57/a23a051fcff998fdfabdd33c6721b5bad499da08b586d3676993410071f0/fonttools-4.62.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e2ff573de2775508c8a366351fb901c4ced5dc6cf2d87dd15c973bedcdd5216", size = 4892154, upload-time = "2026-03-09T16:48:20.736Z" }, - { url = "https://files.pythonhosted.org/packages/e2/62/e27644b433dc6db1d47bc6028a27d772eec5cc8338e24a9a1fce5d7120aa/fonttools-4.62.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:55b189a1b3033860a38e4e5bd0626c5aa25c7ce9caee7bc784a8caec7a675401", size = 4911635, upload-time = "2026-03-09T16:48:23.174Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e2/1bf141911a5616bacfe9cf237c80ccd69d0d92482c38c0f7f6a55d063ad9/fonttools-4.62.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:825f98cd14907c74a4d0a3f7db8570886ffce9c6369fed1385020febf919abf6", size = 5031492, upload-time = "2026-03-09T16:48:25.095Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/790c292f4347ecfa77d9c7e0d1d91e04ab227f6e4a337ed4fe37ca388048/fonttools-4.62.0-cp310-cp310-win32.whl", hash = "sha256:c858030560f92a054444c6e46745227bfd3bb4e55383c80d79462cd47289e4b5", size = 1507656, upload-time = "2026-03-09T16:48:26.973Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ee/08c0b7f8bac6e44638de6fe9a3e710a623932f60eccd58912c4d4743516d/fonttools-4.62.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bf75eb69330e34ad2a096fac67887102c8537991eb6cac1507fc835bbb70e0a", size = 1556540, upload-time = "2026-03-09T16:48:30.359Z" }, - { url = "https://files.pythonhosted.org/packages/e4/33/63d79ca41020dd460b51f1e0f58ad1ff0a36b7bcbdf8f3971d52836581e9/fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9", size = 2870816, upload-time = "2026-03-09T16:48:32.39Z" }, - { url = "https://files.pythonhosted.org/packages/c0/7a/9aeec114bc9fc00d757a41f092f7107863d372e684a5b5724c043654477c/fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13", size = 2416127, upload-time = "2026-03-09T16:48:34.627Z" }, - { url = "https://files.pythonhosted.org/packages/5a/71/12cfd8ae0478b7158ffa8850786781f67e73c00fd897ef9d053415c5f88b/fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5", size = 5100678, upload-time = "2026-03-09T16:48:36.454Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d7/8e4845993ee233c2023d11babe9b3dae7d30333da1d792eeccebcb77baab/fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff", size = 5070859, upload-time = "2026-03-09T16:48:38.786Z" }, - { url = "https://files.pythonhosted.org/packages/ae/a0/287ae04cd883a52e7bb1d92dfc4997dcffb54173761c751106845fa9e316/fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9", size = 5076689, upload-time = "2026-03-09T16:48:41.886Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4e/a2377ad26c36fcd3e671a1c316ea5ed83107de1588e2d897a98349363bc7/fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582", size = 5202053, upload-time = "2026-03-09T16:48:43.867Z" }, - { url = "https://files.pythonhosted.org/packages/44/2e/ad0472e69b02f83dc88983a9910d122178461606404be5b4838af6d1744a/fonttools-4.62.0-cp311-cp311-win32.whl", hash = "sha256:42c7848fa8836ab92c23b1617c407a905642521ff2d7897fe2bf8381530172f1", size = 2292852, upload-time = "2026-03-09T16:48:46.962Z" }, - { url = "https://files.pythonhosted.org/packages/77/ce/f5a4c42c117f8113ce04048053c128d17426751a508f26398110c993a074/fonttools-4.62.0-cp311-cp311-win_amd64.whl", hash = "sha256:4da779e8f342a32856075ddb193b2a024ad900bc04ecb744014c32409ae871ed", size = 2344367, upload-time = "2026-03-09T16:48:48.818Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9d/7ad1ffc080619f67d0b1e0fa6a0578f0be077404f13fd8e448d1616a94a3/fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221", size = 2870004, upload-time = "2026-03-09T16:48:50.837Z" }, - { url = "https://files.pythonhosted.org/packages/4d/8b/ba59069a490f61b737e064c3129453dbd28ee38e81d56af0d04d7e6b4de4/fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85", size = 2414662, upload-time = "2026-03-09T16:48:53.295Z" }, - { url = "https://files.pythonhosted.org/packages/8c/8c/c52a4310de58deeac7e9ea800892aec09b00bb3eb0c53265b31ec02be115/fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72", size = 5032975, upload-time = "2026-03-09T16:48:55.718Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a1/d16318232964d786907b9b3613b8409f74cf0be2da400854509d3a864e43/fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e", size = 4988544, upload-time = "2026-03-09T16:48:57.715Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8d/7e745ca3e65852adc5e52a83dc213fe1b07d61cb5b394970fcd4b1199d1e/fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51", size = 4971296, upload-time = "2026-03-09T16:48:59.678Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d4/b717a4874175146029ca1517e85474b1af80c9d9a306fc3161e71485eea5/fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5", size = 5122503, upload-time = "2026-03-09T16:49:02.464Z" }, - { url = "https://files.pythonhosted.org/packages/cb/4b/92cfcba4bf8373f51c49c5ae4b512ead6fbda7d61a0e8c35a369d0db40a0/fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527", size = 2281060, upload-time = "2026-03-09T16:49:04.385Z" }, - { url = "https://files.pythonhosted.org/packages/cd/06/cc96468781a4dc8ae2f14f16f32b32f69bde18cb9384aad27ccc7adf76f7/fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8", size = 2331193, upload-time = "2026-03-09T16:49:06.598Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/985c1670aa6d82ef270f04cde11394c168f2002700353bd2bde405e59b8f/fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa", size = 2864929, upload-time = "2026-03-09T16:49:09.331Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/c409c8ceec0d3119e9ab0b7b1a2e3c76d1f4d66e4a9db5c59e6b7652e7df/fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c", size = 2412586, upload-time = "2026-03-09T16:49:11.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ac/8e300dbf7b4d135287c261ffd92ede02d9f48f0d2db14665fbc8b059588a/fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5", size = 5013708, upload-time = "2026-03-09T16:49:14.063Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bc/60d93477b653eeb1ddf5f9ec34be689b79234d82dbdded269ac0252715b8/fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee", size = 4964355, upload-time = "2026-03-09T16:49:16.515Z" }, - { url = "https://files.pythonhosted.org/packages/cb/eb/6dc62bcc3c3598c28a3ecb77e69018869c3e109bd83031d4973c059d318b/fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc", size = 4953472, upload-time = "2026-03-09T16:49:18.494Z" }, - { url = "https://files.pythonhosted.org/packages/82/b3/3af7592d9b254b7b7fec018135f8776bfa0d1ad335476c2791b1334dc5e4/fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f", size = 5094701, upload-time = "2026-03-09T16:49:21.67Z" }, - { url = "https://files.pythonhosted.org/packages/31/3d/976645583ab567d3ee75ff87b33aa1330fa2baeeeae5fc46210b4274dd45/fonttools-4.62.0-cp313-cp313-win32.whl", hash = "sha256:d31558890f3fa00d4f937d12708f90c7c142c803c23eaeb395a71f987a77ebe3", size = 2279710, upload-time = "2026-03-09T16:49:23.812Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/e25245a30457595740041dba9d0ea8ec1b2517f2f1a6a741f15eba1a4edc/fonttools-4.62.0-cp313-cp313-win_amd64.whl", hash = "sha256:6826a5aa53fb6def8a66bf423939745f415546c4e92478a7c531b8b6282b6c3b", size = 2330291, upload-time = "2026-03-09T16:49:26.237Z" }, - { url = "https://files.pythonhosted.org/packages/1a/64/61f69298aa6e7c363dcf00dd6371a654676900abe27d1effd1a74b43e5d0/fonttools-4.62.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4fa5a9c716e2f75ef34b5a5c2ca0ee4848d795daa7e6792bf30fd4abf8993449", size = 2864222, upload-time = "2026-03-09T16:49:28.285Z" }, - { url = "https://files.pythonhosted.org/packages/c6/57/6b08756fe4455336b1fe160ab3c11fccc90768ccb6ee03fb0b45851aace4/fonttools-4.62.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:625f5cbeb0b8f4e42343eaeb4bc2786718ddd84760a2f5e55fdd3db049047c00", size = 2410674, upload-time = "2026-03-09T16:49:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/6f/86/db65b63bb1b824b63e602e9be21b18741ddc99bcf5a7850f9181159ae107/fonttools-4.62.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6247e58b96b982709cd569a91a2ba935d406dccf17b6aa615afaed37ac3856aa", size = 4999387, upload-time = "2026-03-09T16:49:32.593Z" }, - { url = "https://files.pythonhosted.org/packages/86/c8/c6669e42d2f4efd60d38a3252cebbb28851f968890efb2b9b15f9d1092b0/fonttools-4.62.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:840632ea9c1eab7b7f01c369e408c0721c287dfd7500ab937398430689852fd1", size = 4912506, upload-time = "2026-03-09T16:49:34.927Z" }, - { url = "https://files.pythonhosted.org/packages/2e/49/0ae552aa098edd0ec548413fbf818f52ceb70535016215094a5ce9bf8f70/fonttools-4.62.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:28a9ea2a7467a816d1bec22658b0cce4443ac60abac3e293bdee78beb74588f3", size = 4951202, upload-time = "2026-03-09T16:49:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/65/ae38fc8a4cea6f162d74cf11f58e9aeef1baa7d0e3d1376dabd336c129e5/fonttools-4.62.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ae611294f768d413949fd12693a8cba0e6332fbc1e07aba60121be35eac68d0", size = 5060758, upload-time = "2026-03-09T16:49:39.464Z" }, - { url = "https://files.pythonhosted.org/packages/db/3d/bb797496f35c60544cd5af71ffa5aad62df14ef7286908d204cb5c5096fe/fonttools-4.62.0-cp314-cp314-win32.whl", hash = "sha256:273acb61f316d07570a80ed5ff0a14a23700eedbec0ad968b949abaa4d3f6bb5", size = 2283496, upload-time = "2026-03-09T16:49:42.448Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9f/91081ffe5881253177c175749cce5841f5ec6e931f5d52f4a817207b7429/fonttools-4.62.0-cp314-cp314-win_amd64.whl", hash = "sha256:a5f974006d14f735c6c878fc4b117ad031dc93638ddcc450ca69f8fd64d5e104", size = 2335426, upload-time = "2026-03-09T16:49:44.228Z" }, - { url = "https://files.pythonhosted.org/packages/f8/65/f47f9b3db1ec156a1f222f1089ba076b2cc9ee1d024a8b0a60c54258517e/fonttools-4.62.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0361a7d41d86937f1f752717c19f719d0fde064d3011038f9f19bdf5fc2f5c95", size = 2947079, upload-time = "2026-03-09T16:49:46.471Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/bc62e5058a0c22cf02b1e0169ef0c3ca6c3247216d719f95bead3c05a991/fonttools-4.62.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4108c12773b3c97aa592311557c405d5b4fc03db2b969ed928fcf68e7b3c887", size = 2448802, upload-time = "2026-03-09T16:49:48.328Z" }, - { url = "https://files.pythonhosted.org/packages/2b/df/bfaa0e845884935355670e6e68f137185ab87295f8bc838db575e4a66064/fonttools-4.62.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b448075f32708e8fb377fe7687f769a5f51a027172c591ba9a58693631b077a8", size = 5137378, upload-time = "2026-03-09T16:49:50.223Z" }, - { url = "https://files.pythonhosted.org/packages/32/32/04f616979a18b48b52e634988b93d847b6346260faf85ecccaf7e2e9057f/fonttools-4.62.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5f1fa8cc9f1a56a3e33ee6b954d6d9235e6b9d11eb7a6c9dfe2c2f829dc24db", size = 4920714, upload-time = "2026-03-09T16:49:53.172Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2e/274e16689c1dfee5c68302cd7c444213cfddd23cf4620374419625037ec6/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f8c8ea812f82db1e884b9cdb663080453e28f0f9a1f5027a5adb59c4cc8d38d1", size = 5016012, upload-time = "2026-03-09T16:49:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0c/b08117270626e7117ac2f89d732fdd4386ec37d2ab3a944462d29e6f89a1/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:03c6068adfdc67c565d217e92386b1cdd951abd4240d65180cec62fa74ba31b2", size = 5042766, upload-time = "2026-03-09T16:49:57.726Z" }, - { url = "https://files.pythonhosted.org/packages/11/83/a48b73e54efa272ee65315a6331b30a9b3a98733310bc11402606809c50e/fonttools-4.62.0-cp314-cp314t-win32.whl", hash = "sha256:d28d5baacb0017d384df14722a63abe6e0230d8ce642b1615a27d78ffe3bc983", size = 2347785, upload-time = "2026-03-09T16:49:59.698Z" }, - { url = "https://files.pythonhosted.org/packages/f8/27/c67eab6dc3525bdc39586511b1b3d7161e972dacc0f17476dbaf932e708b/fonttools-4.62.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f9e20c4618f1e04190c802acae6dc337cb6db9fa61e492fd97cd5c5a9ff6d07", size = 2413914, upload-time = "2026-03-09T16:50:02.251Z" }, - { url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, + { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] [[package]] @@ -2301,16 +2363,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.49.0" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rsa", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/59/7371175bfd949abfb1170aa076352131d7281bd9449c0f978604fc4431c3/google_auth-2.49.0.tar.gz", hash = "sha256:9cc2d9259d3700d7a257681f81052db6737495a1a46b610597f4b8bafe5286ae", size = 333444, upload-time = "2026-03-06T21:53:06.07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/45/de64b823b639103de4b63dd193480dce99526bd36be6530c2dba85bf7817/google_auth-2.49.0-py3-none-any.whl", hash = "sha256:f893ef7307f19cf53700b7e2f61b5a6affe3aa0edf9943b13788920ab92d8d87", size = 240676, upload-time = "2026-03-06T21:52:38.304Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [[package]] @@ -2497,34 +2558,34 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.4.0" +version = "1.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/01/928fd82663fb0ab455551a178303a2960e65029da66b21974594f3a20a94/hf_xet-1.4.0.tar.gz", hash = "sha256:48e6ba7422b0885c9bbd8ac8fdf5c4e1306c3499b82d489944609cc4eae8ecbd", size = 660350, upload-time = "2026-03-11T18:50:03.354Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357, upload-time = "2026-03-13T06:58:51.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/4b/2351e30dddc6f3b47b3da0a0693ec1e82f8303b1a712faa299cf3552002b/hf_xet-1.4.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:76725fcbc5f59b23ac778f097d3029d6623e3cf6f4057d99d1fce1a7e3cff8fc", size = 3796397, upload-time = "2026-03-11T18:49:47.382Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/3db90ec0afb4e26e3330b1346b89fe0e9a3b7bfc2d6a2b2262787790d25f/hf_xet-1.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:76f1f73bee81a6e6f608b583908aa24c50004965358ac92c1dc01080a21bcd09", size = 3556235, upload-time = "2026-03-11T18:49:45.785Z" }, - { url = "https://files.pythonhosted.org/packages/57/6e/2a662af2cbc6c0a64ebe9fcdb8faf05b5205753d45a75a3011bb2209d0b4/hf_xet-1.4.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1818c2e5d6f15354c595d5111c6eb0e5a30a6c5c1a43eeaec20f19607cff0b34", size = 4213145, upload-time = "2026-03-11T18:49:38.009Z" }, - { url = "https://files.pythonhosted.org/packages/b9/4a/47c129affb540767e0e3e101039a95f4a73a292ec689c26e8f0c5b633f9d/hf_xet-1.4.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:70764d295f485db9cc9a6af76634ea00ec4f96311be7485f8f2b6144739b4ccf", size = 3991951, upload-time = "2026-03-11T18:49:36.396Z" }, - { url = "https://files.pythonhosted.org/packages/76/81/ec516cfc6281cfeef027b0919166b2fe11ab61fbe6131a2c43fafbed8b68/hf_xet-1.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d3bd2a1e289f772c715ca88cdca8ceb3d8b5c9186534d5925410e531d849a3e", size = 4193205, upload-time = "2026-03-11T18:49:54.415Z" }, - { url = "https://files.pythonhosted.org/packages/49/48/0945b5e542ed6c6ce758b589b27895a449deab630dfcdee5a6ee0f699d21/hf_xet-1.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:06da3797f1fdd9a8f8dbc8c1bddfa0b914789b14580c375d29c32ee35c2c66ca", size = 4431022, upload-time = "2026-03-11T18:49:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ad/a4859c55ab4b67a4fde2849be8bde81917f54062050419b821071f199a9c/hf_xet-1.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:30b9d8f384ccec848124d51d883e91f3c88d430589e02a7b6d867730ab8d53ac", size = 3674977, upload-time = "2026-03-11T18:50:06.369Z" }, - { url = "https://files.pythonhosted.org/packages/4b/17/5bf3791e3a53e597913c2a775a48a98aaded9c2ddb5d1afaedabb55e2ed8/hf_xet-1.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:07ffdbf7568fa3245b24d949f0f3790b5276fb7293a5554ac4ec02e5f7e2b38d", size = 3536778, upload-time = "2026-03-11T18:50:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/3f/a1/05a7f9d6069bf78405d3fc2464b6c76b167128501e13b4f1d6266e1d1f54/hf_xet-1.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e2731044f3a18442f9f7a3dcf03b96af13dee311f03846a1df1f0553a3ea0fc6", size = 3796727, upload-time = "2026-03-11T18:49:52.889Z" }, - { url = "https://files.pythonhosted.org/packages/ac/8a/67abc642c2b32efcb7a257cdad8555c2904e23f18a1b4fec3aef1ebfe0fc/hf_xet-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b6f3729335fbc4baef60fe14fe32ef13ac9d377bdc898148c541e20c6056b504", size = 3555869, upload-time = "2026-03-11T18:49:51.313Z" }, - { url = "https://files.pythonhosted.org/packages/19/3d/4765367c64ee70db15fa771d5b94bf12540b85076a1d3210ebbfec42d477/hf_xet-1.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c0c9f052738a024073d332c573275c8e33697a3ef3f5dd2fb4ef98216e1e74a", size = 4212980, upload-time = "2026-03-11T18:49:44.21Z" }, - { url = "https://files.pythonhosted.org/packages/0e/bf/6ad99ee0e7ca2318f912a87318e493d82d8f9aace6be81f774bd14b996df/hf_xet-1.4.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f44b2324be75bfa399735996ac299fd478684c48ce47d12a42b5f24b1a99ccb8", size = 3991136, upload-time = "2026-03-11T18:49:42.512Z" }, - { url = "https://files.pythonhosted.org/packages/50/aa/932e25c69699076088f57e3c14f83ccae87bac25e755994f3362acc908d5/hf_xet-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:01de78b1ceddf8b38da001f7cc728b3bc3eb956948b18e8a1997ad6fc80fbe9d", size = 4192676, upload-time = "2026-03-11T18:50:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/5c/0a/5e41339a294fd3450948989a47ecba9824d5bc1950cf767f928ecaf53a55/hf_xet-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cac8616e7a974105c3494735313f5ab0fb79b5accadec1a7a992859a15536a9", size = 4430729, upload-time = "2026-03-11T18:50:01.923Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c1/c3d8ed9b7118e9166b0cf71dfd501da82f1abe306387e34e0f3ee59553ec/hf_xet-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3a5d9cb25095ceb3beab4843ae2d1b3e5746371ddbf2e5849f7be6a7d6f44df4", size = 3674989, upload-time = "2026-03-11T18:50:12.633Z" }, - { url = "https://files.pythonhosted.org/packages/65/bc/ea26cf774063cb09d7aaaa6cba9d341fb72b42ea99b8a94ca254dbafbbb0/hf_xet-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9b777674499dc037317db372c90a2dd91329b5f1ee93c645bb89155bb974f5bf", size = 3536805, upload-time = "2026-03-11T18:50:11.082Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f9/a0b01945726aea81d2f213457cd5f5102a51e6fd1ca9f9769f561fb57501/hf_xet-1.4.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:981d2b5222c3baadf9567c135cf1d1073786f546b7745686978d46b5df179e16", size = 3799223, upload-time = "2026-03-11T18:49:49.884Z" }, - { url = "https://files.pythonhosted.org/packages/5d/30/ee62b0c00412f49a7e6f509f0104ee8808692278d247234336df48029349/hf_xet-1.4.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:cc8bd050349d0d7995ce7b3a3a18732a2a8062ce118a82431602088abb373428", size = 3560682, upload-time = "2026-03-11T18:49:48.633Z" }, - { url = "https://files.pythonhosted.org/packages/93/d0/0fe5c44dbced465a651a03212e1135d0d7f95d19faada692920cb56f8e38/hf_xet-1.4.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d0c38d2a280d814280b8c15eead4a43c9781e7bf6fc37843cffab06dcdc76b9", size = 4218323, upload-time = "2026-03-11T18:49:40.921Z" }, - { url = "https://files.pythonhosted.org/packages/73/df/7b3c99a4e50442039eae498e5c23db634538eb3e02214109880cf1165d4c/hf_xet-1.4.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a883f0250682ea888a1bd0af0631feda377e59ad7aae6fb75860ecee7ae0f93", size = 3997156, upload-time = "2026-03-11T18:49:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/a9/26/47dfedf271c21d95346660ae1698e7ece5ab10791fa6c4f20c59f3713083/hf_xet-1.4.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:99e1d9255fe8ecdf57149bb0543d49e7b7bd8d491ddf431eb57e114253274df5", size = 4199052, upload-time = "2026-03-11T18:49:57.097Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c0/346b9aad1474e881e65f998d5c1981695f0af045bc7a99204d9d86759a89/hf_xet-1.4.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b25f06ce42bd2d5f2e79d4a2d72f783d3ac91827c80d34a38cf8e5290dd717b0", size = 4434346, upload-time = "2026-03-11T18:49:58.67Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d6/88ce9d6caa397c3b935263d5bcbe3ebf6c443f7c76098b8c523d206116b9/hf_xet-1.4.0-cp37-abi3-win_amd64.whl", hash = "sha256:8d6d7816d01e0fa33f315c8ca21b05eca0ce4cdc314f13b81d953e46cc6db11d", size = 3678921, upload-time = "2026-03-11T18:50:09.496Z" }, - { url = "https://files.pythonhosted.org/packages/65/eb/17d99ed253b28a9550ca479867c66a8af4c9bcd8cdc9a26b0c8007c2000a/hf_xet-1.4.0-cp37-abi3-win_arm64.whl", hash = "sha256:cb8d9549122b5b42f34b23b14c6b662a88a586a919d418c774d8dbbc4b3ce2aa", size = 3541054, upload-time = "2026-03-11T18:50:07.963Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/e8cf74c3c48e5485c7acc5a990d0d8516cdfb5fdf80f799174f1287cc1b5/hf_xet-1.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ac8202ae1e664b2c15cdfc7298cbb25e80301ae596d602ef7870099a126fcad4", size = 3796125, upload-time = "2026-03-13T06:58:33.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b73ebab01cbf60777323b7de9ef05550790451eb5172a220d6b9845385ec/hf_xet-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6d2f8ee39fa9fba9af929f8c0d0482f8ee6e209179ad14a909b6ad78ffcb7c81", size = 3555985, upload-time = "2026-03-13T06:58:31.797Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e7/ded6d1bd041c3f2bca9e913a0091adfe32371988e047dd3a68a2463c15a2/hf_xet-1.4.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4642a6cf249c09da8c1f87fe50b24b2a3450b235bf8adb55700b52f0ea6e2eb6", size = 4212085, upload-time = "2026-03-13T06:58:24.323Z" }, + { url = "https://files.pythonhosted.org/packages/97/c1/a0a44d1f98934f7bdf17f7a915b934f9fca44bb826628c553589900f6df8/hf_xet-1.4.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:769431385e746c92dc05492dde6f687d304584b89c33d79def8367ace06cb555", size = 3988266, upload-time = "2026-03-13T06:58:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/7a/82/be713b439060e7d1f1d93543c8053d4ef2fe7e6922c5b31642eaa26f3c4b/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9dd1c1bc4cc56168f81939b0e05b4c36dd2d28c13dc1364b17af89aa0082496", size = 4188513, upload-time = "2026-03-13T06:58:40.858Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287, upload-time = "2026-03-13T06:58:42.601Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574, upload-time = "2026-03-13T06:58:53.881Z" }, + { url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760, upload-time = "2026-03-13T06:58:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0f/fcd2504015eab26358d8f0f232a1aed6b8d363a011adef83fe130bff88f7/hf_xet-1.4.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:949dcf88b484bb9d9276ca83f6599e4aa03d493c08fc168c124ad10b2e6f75d7", size = 3796493, upload-time = "2026-03-13T06:58:39.267Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/19c25105ff81731ca6d55a188b5de2aa99d7a2644c7aa9de1810d5d3b726/hf_xet-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41659966020d59eb9559c57de2cde8128b706a26a64c60f0531fa2318f409418", size = 3555797, upload-time = "2026-03-13T06:58:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/8933c073186849b5e06762aa89847991d913d10a95d1603eb7f2c3834086/hf_xet-1.4.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c588e21d80010119458dd5d02a69093f0d115d84e3467efe71ffb2c67c19146", size = 4212127, upload-time = "2026-03-13T06:58:30.539Z" }, + { url = "https://files.pythonhosted.org/packages/eb/01/f89ebba4e369b4ed699dcb60d3152753870996f41c6d22d3d7cac01310e1/hf_xet-1.4.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a296744d771a8621ad1d50c098d7ab975d599800dae6d48528ba3944e5001ba0", size = 3987788, upload-time = "2026-03-13T06:58:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/84/4d/8a53e5ffbc2cc33bbf755382ac1552c6d9af13f623ed125fe67cc3e6772f/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f563f7efe49588b7d0629d18d36f46d1658fe7e08dce3fa3d6526e1c98315e2d", size = 4188315, upload-time = "2026-03-13T06:58:48.017Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b8/b7a1c1b5592254bd67050632ebbc1b42cc48588bf4757cb03c2ef87e704a/hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5b2e0132c56d7ee1bf55bdb638c4b62e7106f6ac74f0b786fed499d5548c5570", size = 4428306, upload-time = "2026-03-13T06:58:49.502Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/40779e45b20e11c7c5821a94135e0207080d6b3d76e7b78ccb413c6f839b/hf_xet-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2f45c712c2fa1215713db10df6ac84b49d0e1c393465440e9cb1de73ecf7bbf6", size = 3665826, upload-time = "2026-03-13T06:58:59.88Z" }, + { url = "https://files.pythonhosted.org/packages/51/4c/e2688c8ad1760d7c30f7c429c79f35f825932581bc7c9ec811436d2f21a0/hf_xet-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6d53df40616f7168abfccff100d232e9d460583b9d86fa4912c24845f192f2b8", size = 3529113, upload-time = "2026-03-13T06:58:58.491Z" }, + { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" }, + { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, ] [[package]] @@ -2632,7 +2693,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.6.0" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2645,9 +2706,9 @@ dependencies = [ { name = "typer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/7a/304cec37112382c4fe29a43bcb0d5891f922785d18745883d2aa4eb74e4b/huggingface_hub-1.6.0.tar.gz", hash = "sha256:d931ddad8ba8dfc1e816bf254810eb6f38e5c32f60d4184b5885662a3b167325", size = 717071, upload-time = "2026-03-06T14:19:18.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b", size = 724684, upload-time = "2026-03-20T10:36:08.767Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e3/e3a44f54c8e2f28983fcf07f13d4260b37bd6a0d3a081041bc60b91d230e/huggingface_hub-1.6.0-py3-none-any.whl", hash = "sha256:ef40e2d5cb85e48b2c067020fa5142168342d5108a1b267478ed384ecbf18961", size = 612874, upload-time = "2026-03-06T14:19:16.844Z" }, + { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, ] [[package]] @@ -2996,7 +3057,7 @@ wheels = [ [[package]] name = "langfuse" -version = "4.0.0" +version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3009,9 +3070,9 @@ dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/4b/8df7cd1684b46b6760d9c03893cbbc9ddbdd3f72eaf003b3859cec308587/langfuse-4.0.0.tar.gz", hash = "sha256:10df126c8d68e5746ff39a0a5100233f9f29446626c478e7770b1c775e4c4e17", size = 271030, upload-time = "2026-03-10T16:21:51.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749, upload-time = "2026-03-19T14:03:34.508Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/56/7f14cbe189e8a10c805609d52a4578b5f1bca3d4060b531baf920827d4f5/langfuse-4.0.0-py3-none-any.whl", hash = "sha256:4afe6a114937fa544e7f5f86c34533c711f0f12ebf77480239b626da28bbae68", size = 462159, upload-time = "2026-03-10T16:21:49.701Z" }, + { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049, upload-time = "2026-03-19T14:03:32.527Z" }, ] [[package]] @@ -3102,7 +3163,7 @@ wheels = [ [[package]] name = "litellm" version = "1.82.1" -source = { registry = "https://pypi.org/simple" } +source = { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3117,9 +3178,8 @@ dependencies = [ { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/bd/6251e9a965ae2d7bc3342ae6c1a2d25dd265d354c502e63225451b135016/litellm-1.82.1.tar.gz", hash = "sha256:bc8427cdccc99e191e08e36fcd631c93b27328d1af789839eb3ac01a7d281890", size = 17197496, upload-time = "2026-03-10T09:10:04.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl", hash = "sha256:a9ec3fe42eccb1611883caaf8b1bf33c9f4e12163f94c7d1004095b14c379eb2", size = 15341896, upload-time = "2026-03-10T09:10:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl", hash = "sha256:a9ec3fe42eccb1611883caaf8b1bf33c9f4e12163f94c7d1004095b14c379eb2" }, ] [package.optional-dependencies] @@ -3151,22 +3211,78 @@ proxy = [ { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] +[package.metadata] +requires-dist = [ + { name = "a2a-sdk", marker = "python_full_version >= '3.10' and extra == 'extra-proxy'", specifier = ">=0.3.22,<0.4.0" }, + { name = "aiohttp", specifier = ">=3.10" }, + { name = "apscheduler", marker = "extra == 'proxy'", specifier = ">=3.10.4,<4.0.0" }, + { name = "azure-identity", marker = "(python_full_version >= '3.9' and extra == 'extra-proxy') or (python_full_version >= '3.9' and extra == 'proxy')", specifier = ">=1.15.0,<2.0.0" }, + { name = "azure-keyvault-secrets", marker = "extra == 'extra-proxy'", specifier = ">=4.8.0,<5.0.0" }, + { name = "azure-storage-blob", marker = "extra == 'proxy'", specifier = ">=12.25.1,<13.0.0" }, + { name = "backoff", marker = "extra == 'proxy'" }, + { name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.40.76,<2.0.0" }, + { name = "click" }, + { name = "cryptography", marker = "extra == 'proxy'" }, + { name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.1,<6.0.0" }, + { name = "fastapi", marker = "extra == 'proxy'", specifier = ">=0.120.1" }, + { name = "fastapi-sso", marker = "extra == 'proxy'", specifier = ">=0.16.0,<0.17.0" }, + { name = "fastuuid", specifier = ">=0.13.0" }, + { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = ">=1.38.0" }, + { name = "google-cloud-iam", marker = "extra == 'extra-proxy'", specifier = ">=2.19.1,<3.0.0" }, + { name = "google-cloud-kms", marker = "extra == 'extra-proxy'", specifier = ">=2.21.3,<3.0.0" }, + { name = "grpcio", marker = "python_full_version >= '3.14' and extra == 'grpc'", specifier = ">=1.75.0" }, + { name = "grpcio", marker = "python_full_version < '3.14' and extra == 'grpc'", specifier = ">=1.62.3,!=1.68.*,!=1.69.*,!=1.70.*,!=1.71.0,!=1.71.1,!=1.72.0,!=1.72.1,!=1.73.0" }, + { name = "gunicorn", marker = "extra == 'proxy'", specifier = ">=23.0.0,<24.0.0" }, + { name = "httpx", specifier = ">=0.23.0" }, + { name = "importlib-metadata", specifier = ">=6.8.0" }, + { name = "jinja2", specifier = ">=3.1.2,<4.0.0" }, + { name = "jsonschema", specifier = ">=4.23.0,<5.0.0" }, + { name = "litellm-enterprise", marker = "extra == 'proxy'", specifier = ">=0.1.33,<0.2.0" }, + { name = "litellm-proxy-extras", marker = "extra == 'proxy'", specifier = ">=0.4.53,<0.5.0" }, + { name = "mcp", marker = "python_full_version >= '3.10' and extra == 'proxy'", specifier = ">=1.25.0,<2.0.0" }, + { name = "mlflow", marker = "python_full_version >= '3.10' and extra == 'mlflow'", specifier = ">3.1.4" }, + { name = "numpydoc", marker = "extra == 'utils'" }, + { name = "openai", specifier = ">=2.8.0" }, + { name = "orjson", marker = "extra == 'proxy'", specifier = ">=3.9.7,<4.0.0" }, + { name = "polars", marker = "python_full_version >= '3.10' and extra == 'proxy'", specifier = ">=1.31.0,<2.0.0" }, + { name = "prisma", marker = "extra == 'extra-proxy'", specifier = ">=0.11.0,<0.12.0" }, + { name = "pydantic", specifier = ">=2.5.0,<3.0.0" }, + { name = "pyjwt", marker = "python_full_version >= '3.9' and extra == 'proxy'", specifier = ">=2.10.1,<3.0.0" }, + { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.5.0,<2.0.0" }, + { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8,<0.9" }, + { name = "python-dotenv", specifier = ">=0.2.0" }, + { name = "python-multipart", marker = "extra == 'proxy'", specifier = ">=0.0.20" }, + { name = "pyyaml", marker = "extra == 'proxy'", specifier = ">=6.0.1,<7.0.0" }, + { name = "redisvl", marker = "python_full_version >= '3.9' and python_full_version < '3.14' and extra == 'extra-proxy'", specifier = ">=0.4.1,<0.5.0" }, + { name = "resend", marker = "extra == 'extra-proxy'", specifier = ">=0.8.0" }, + { name = "rich", marker = "extra == 'proxy'", specifier = ">=13.7.1,<14.0.0" }, + { name = "rq", marker = "extra == 'proxy'" }, + { name = "semantic-router", marker = "python_full_version >= '3.9' and python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.1.12" }, + { name = "soundfile", marker = "extra == 'proxy'", specifier = ">=0.12.1,<0.13.0" }, + { name = "tiktoken", specifier = ">=0.7.0" }, + { name = "tokenizers" }, + { name = "uvicorn", marker = "extra == 'proxy'", specifier = ">=0.32.1,<1.0.0" }, + { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.21.0,<0.22.0" }, + { name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0.0" }, +] +provides-extras = ["caching", "extra-proxy", "google", "grpc", "mlflow", "proxy", "semantic-router", "utils"] + [[package]] name = "litellm-enterprise" -version = "0.1.34" +version = "0.1.35" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/ca/1c0bf58bbce062ad53d8f6ba85bc56e92a869b969f8ad7cd68d50423f42a/litellm_enterprise-0.1.34.tar.gz", hash = "sha256:d6fe43ef28728c1a6c131ba22667f1a8304035b70b435aa3e6cf1c2b91e84657", size = 57609, upload-time = "2026-03-09T11:12:12.162Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/5f/e593f335698a5c70d7e96e8ab9fdc4cfd4cc9249c524723fe64ed7f00cbb/litellm_enterprise-0.1.35.tar.gz", hash = "sha256:b752d07e538424743fcc08ba0d3d9d83d1f04a45c115811ac7828d789b6d87cc", size = 58817, upload-time = "2026-03-21T15:06:16.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/ad/23143b786081c8ebe1481a97e08058a6a5e9d5fc7fc4507256040aebcd42/litellm_enterprise-0.1.34-py3-none-any.whl", hash = "sha256:e2e8d084055f8c96e646d906d7dbee8bafee03d343247a349e8ccf2745fb7822", size = 121091, upload-time = "2026-03-09T11:12:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fa/39efe3dfa680ca5bc5795b9c904c914b09a65278c2970c8fece6e0e30e47/litellm_enterprise-0.1.35-py3-none-any.whl", hash = "sha256:8d2d9c925de8ee35e308c0f4975483b60f5e22beb50506e261e555e466f019c5", size = 122659, upload-time = "2026-03-21T15:06:15.586Z" }, ] [[package]] name = "litellm-proxy-extras" -version = "0.4.54" +version = "0.4.60" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/b8/21a14fc27fb6d10f22c0758db63f4c1224fe8ea8aa4c7d0a26d5fa9da7b2/litellm_proxy_extras-0.4.54.tar.gz", hash = "sha256:2c777ecdf39901c4007ade4466eb6398985ed4000afe3fc2cac997e1169e8cee", size = 31265, upload-time = "2026-03-12T01:08:08.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/00/828092491c0106657f9cb9ee43ac6ed71d13e9eba627d1e81c0c68b6126d/litellm_proxy_extras-0.4.60.tar.gz", hash = "sha256:1c122f2a7e0eb58fa4c6d8da9da82ac1fe2869de3510bcfade5c2932af202328", size = 32034, upload-time = "2026-03-22T05:54:55.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7e/8dd3378eba2c7116562b6bd823fa929872e20bdcab93757358e6f3b4c9e3/litellm_proxy_extras-0.4.54-py3-none-any.whl", hash = "sha256:6621cf529f7f3647eb2dd0d2c417d91db8c7a05c3c592bef251887a122928837", size = 73661, upload-time = "2026-03-12T01:08:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e8/828213b07512e673403da306a804dbe9b2965fcb7286d746c4bbff585b61/litellm_proxy_extras-0.4.60-py3-none-any.whl", hash = "sha256:7abcc811f7430e4b24e7a8ba7186219a4845a955ae7a71d8822bd03fd9fc3393", size = 76605, upload-time = "2026-03-22T05:54:54.41Z" }, ] [[package]] @@ -3395,7 +3511,7 @@ wheels = [ [[package]] name = "mem0ai" -version = "1.0.5" +version = "1.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3406,9 +3522,9 @@ dependencies = [ { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/79/2307e5fe1610d2ad0d08688af10cd5163861390deeb070f83449c0b65417/mem0ai-1.0.5.tar.gz", hash = "sha256:0835a0001ecac40ba2667bbf17629329c1b2f33eaa585e93a6be54d868a82f79", size = 182982, upload-time = "2026-03-03T22:27:09.488Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/a7/ad272ecb8e67690d08f6fb59d7162260dbbbaf9f1b9336c52bef254bfbcd/mem0ai-1.0.7.tar.gz", hash = "sha256:57ec923d9703cd0f9bc0ffac511d2839ed99448a28c9ad8c017335083846d338", size = 188641, upload-time = "2026-03-20T22:46:24.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/0e/43ec9f125ebe6e8390805aa56237ee7165fc4f2b796122644cb0043e6631/mem0ai-1.0.5-py3-none-any.whl", hash = "sha256:0526814d2ec9134e21a628cc04ae0e6dc1779a579af92c481cb9fd7f7b8d17aa", size = 275991, upload-time = "2026-03-03T22:27:07.73Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8e/c7a6dbf2dfadd603fbfb17cd9c201b26a98382cb5c5e4c07b101978faf09/mem0ai-1.0.7-py3-none-any.whl", hash = "sha256:a0a2e9d75ef0e98f9b2579e2d40beb05b87b9e05b65304084c13b0d408ee5f1c", size = 287025, upload-time = "2026-03-20T22:46:23.238Z" }, ] [[package]] @@ -3927,7 +4043,7 @@ wheels = [ [[package]] name = "openai" -version = "2.26.0" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3939,14 +4055,14 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/91/2a06c4e9597c338cac1e5e5a8dd6f29e1836fc229c4c523529dca387fda8/openai-2.26.0.tar.gz", hash = "sha256:b41f37c140ae0034a6e92b0c509376d907f3a66109935fba2c1b471a7c05a8fb", size = 666702, upload-time = "2026-03-05T23:17:35.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/2e/3f73e8ca53718952222cacd0cf7eecc9db439d020f0c1fe7ae717e4e199a/openai-2.26.0-py3-none-any.whl", hash = "sha256:6151bf8f83802f036117f06cc8a57b3a4da60da9926826cc96747888b57f394f", size = 1136409, upload-time = "2026-03-05T23:17:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, ] [[package]] name = "openai-agents" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3957,9 +4073,9 @@ dependencies = [ { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/2e/402d3bfd6432c503bab699ece49e6febe38c64ade3365ae4fe31e7b3cba1/openai_agents-0.12.0.tar.gz", hash = "sha256:086d5cd16815d40a88231cbfd9dcca594cdf8596c6efd4859dcbafdfb31068ba", size = 2604305, upload-time = "2026-03-12T08:52:42.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/df/68927da38588f7b9c418754f2a0c30c9cda1d8621b035906faf85767dda5/openai_agents-0.13.0.tar.gz", hash = "sha256:90ac13697dec3c110c3ed9893629e01b6fc178ae410a7f0e39f387be408e8715", size = 2660070, upload-time = "2026-03-23T06:20:23.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/2c/8f03b5a56329559573e692d6dc2f02c3cbbe4fcd07f9c5d81b3c280e80e7/openai_agents-0.12.0-py3-none-any.whl", hash = "sha256:24f5cc5d6213dfcda42188918ad0a739861aa505f4ef738ee07b69169faf5c09", size = 446876, upload-time = "2026-03-12T08:52:40.779Z" }, + { url = "https://files.pythonhosted.org/packages/e0/8d/62cf7374a2050daa6b7605c12a9085fa528d493f9cf076826c0c78ac16f7/openai_agents-0.13.0-py3-none-any.whl", hash = "sha256:d1077e71e9c7461f9098922bbc63a1f2a1244c93fd3dc24249882b3130eccd55", size = 454617, upload-time = "2026-03-23T06:20:22.068Z" }, ] [[package]] @@ -4106,15 +4222,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] -[[package]] -name = "opentelemetry-semantic-conventions-ai" -version = "0.4.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e6/40b59eda51ac47009fb47afcdf37c6938594a0bd7f3b9fadcbc6058248e3/opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036", size = 5368, upload-time = "2025-08-22T10:14:17.387Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/b5/cf25da2218910f0d6cdf7f876a06bed118c4969eacaf60a887cbaef44f44/opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5", size = 6080, upload-time = "2025-08-22T10:14:16.477Z" }, -] - [[package]] name = "ordered-set" version = "4.1.0" @@ -4538,30 +4645,30 @@ wheels = [ [[package]] name = "polars" -version = "1.38.1" +version = "1.39.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/5e/208a24471a433bcd0e9a6889ac49025fd4daad2815c8220c5bd2576e5f1b/polars-1.38.1.tar.gz", hash = "sha256:803a2be5344ef880ad625addfb8f641995cfd777413b08a10de0897345778239", size = 717667, upload-time = "2026-02-06T18:13:23.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/49/737c1a6273c585719858261753da0b688454d1b634438ccba8a9c4eb5aab/polars-1.38.1-py3-none-any.whl", hash = "sha256:a29479c48fed4984d88b656486d221f638cba45d3e961631a50ee5fdde38cb2c", size = 810368, upload-time = "2026-02-06T18:11:55.819Z" }, + { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.38.1" +version = "1.39.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/4b/04d6b3fb7cf336fbe12fbc4b43f36d1783e11bb0f2b1e3980ec44878df06/polars_runtime_32-1.38.1.tar.gz", hash = "sha256:04f20ed1f5c58771f34296a27029dc755a9e4b1390caeaef8f317e06fdfce2ec", size = 2812631, upload-time = "2026-02-06T18:13:25.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/a2/a00defbddadd8cf1042f52380dcba6b6592b03bac8e3b34c436b62d12d3b/polars_runtime_32-1.38.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:18154e96044724a0ac38ce155cf63aa03c02dd70500efbbf1a61b08cadd269ef", size = 44108001, upload-time = "2026-02-06T18:11:58.127Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/599ff3709e6a303024efd7edfd08cf8de55c6ac39527d8f41cbc4399385f/polars_runtime_32-1.38.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c49acac34cc4049ed188f1eb67d6ff3971a39b4af7f7b734b367119970f313ac", size = 40230140, upload-time = "2026-02-06T18:12:01.181Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8c/3ac18d6f89dc05fe2c7c0ee1dc5b81f77a5c85ad59898232c2500fe2ebbf/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef2ef2626a954e010e006cc8e4de467ecf32d08008f130cea1c78911f545323", size = 41994039, upload-time = "2026-02-06T18:12:04.332Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5a/61d60ec5cc0ab37cbd5a699edb2f9af2875b7fdfdfb2a4608ca3cc5f0448/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5f7a8125e2d50e2e060296551c929aec09be23a9edcb2b12ca923f555a5ba", size = 45755804, upload-time = "2026-02-06T18:12:07.846Z" }, - { url = "https://files.pythonhosted.org/packages/91/54/02cd4074c98c361ccd3fec3bcb0bd68dbc639c0550c42a4436b0ff0f3ccf/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10d19cd9863e129273b18b7fcaab625b5c8143c2d22b3e549067b78efa32e4fa", size = 42159605, upload-time = "2026-02-06T18:12:10.919Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f3/b2a5e720cc56eaa38b4518e63aa577b4bbd60e8b05a00fe43ca051be5879/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61e8d73c614b46a00d2f853625a7569a2e4a0999333e876354ac81d1bf1bb5e2", size = 45336615, upload-time = "2026-02-06T18:12:14.074Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8d/ee2e4b7de948090cfb3df37d401c521233daf97bfc54ddec5d61d1d31618/polars_runtime_32-1.38.1-cp310-abi3-win_amd64.whl", hash = "sha256:08c2b3b93509c1141ac97891294ff5c5b0c548a373f583eaaea873a4bf506437", size = 45680732, upload-time = "2026-02-06T18:12:19.097Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/72c216f4ab0c82b907009668f79183ae029116ff0dd245d56ef58aac48e7/polars_runtime_32-1.38.1-cp310-abi3-win_arm64.whl", hash = "sha256:6d07d0cc832bfe4fb54b6e04218c2c27afcfa6b9498f9f6bbf262a00d58cc7c4", size = 41639413, upload-time = "2026-02-06T18:12:22.044Z" }, + { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, ] [[package]] @@ -4758,16 +4865,17 @@ wheels = [ [[package]] name = "protobuf" -version = "5.29.6" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" }, - { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" }, - { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" }, - { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -4844,11 +4952,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -5047,11 +5155,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +dependencies = [ + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -5366,7 +5477,7 @@ wheels = [ [[package]] name = "qdrant-client" -version = "1.17.0" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -5378,21 +5489,21 @@ dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/fb/c9c4cecf6e7fdff2dbaeee0de40e93fe495379eb5fe2775b184ea45315da/qdrant_client-1.17.0.tar.gz", hash = "sha256:47eb033edb9be33a4babb4d87b0d8d5eaf03d52112dca0218db7f2030bf41ba9", size = 344839, upload-time = "2026-02-19T16:03:17.069Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/dd/f8a8261b83946af3cd65943c93c4f83e044f01184e8525404989d22a81a5/qdrant_client-1.17.1.tar.gz", hash = "sha256:22f990bbd63485ed97ba551a4c498181fcb723f71dcab5d6e4e43fe1050a2bc0", size = 344979, upload-time = "2026-03-13T17:13:44.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/15/dfadbc9d8c9872e8ac45fa96f5099bb2855f23426bfea1bbcdc85e64ef6e/qdrant_client-1.17.0-py3-none-any.whl", hash = "sha256:f5b452c68c42b3580d3d266446fb00d3c6e3aae89c916e16585b3c704e108438", size = 390381, upload-time = "2026-02-19T16:03:15.486Z" }, + { url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" }, ] [[package]] name = "redis" -version = "6.4.0" +version = "7.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "(python_full_version < '3.11.3' and sys_platform == 'darwin') or (python_full_version < '3.11.3' and sys_platform == 'linux') or (python_full_version < '3.11.3' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" }, ] [[package]] @@ -5714,18 +5825,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/1a/3b64696bc0c33aa1d86d3e6add03c4e0afe51110264fd41208bd95c2665c/rq-2.7.0-py3-none-any.whl", hash = "sha256:4b320e95968208d2e249fa0d3d90ee309478e2d7ea60a116f8ff9aa343a4c117", size = 115728, upload-time = "2026-02-22T11:10:48.401Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.15.5" @@ -6238,28 +6337,28 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.3.2" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, ] [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]]