mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into features/3768-devui-aspire-integration
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run fmt, lint, pyright in parallel across packages
|
||||
- name: Run syntax and pyright across packages
|
||||
run: uv run poe check-packages
|
||||
|
||||
samples-markdown:
|
||||
@@ -104,10 +104,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run samples lint
|
||||
run: uv run poe samples-lint
|
||||
- name: Run samples syntax check
|
||||
run: uv run poe samples-syntax
|
||||
- name: Run samples checks
|
||||
run: uv run poe check -S
|
||||
- name: Run markdown code lint
|
||||
run: uv run poe markdown-code-lint
|
||||
|
||||
@@ -140,4 +138,4 @@ jobs:
|
||||
- name: Run Mypy
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
|
||||
run: uv run poe ci-mypy
|
||||
run: uv run python scripts/workspace_poe_tasks.py ci-mypy
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
id: validate_ranges
|
||||
# Keep workflow running so we can still publish diagnostics from this run.
|
||||
continue-on-error: true
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --project "*"
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
working-directory: ./python
|
||||
|
||||
- name: Upload dependency range report
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
This PR was generated by the dependency range validation workflow.
|
||||
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --project "*"`
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
|
||||
- Updated package dependency bounds
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
EOF
|
||||
|
||||
@@ -48,9 +48,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
uv run poe test -A
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -32,13 +32,13 @@ jobs:
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run all tests with coverage report
|
||||
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
- name: Check coverage threshold
|
||||
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
|
||||
run: uv run poe test -A
|
||||
working-directory: ./python
|
||||
|
||||
# Surface failing tests
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Stale issue and PR ping
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Midnight UTC daily
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days_threshold:
|
||||
description: 'Days of silence before pinging the author'
|
||||
required: false
|
||||
default: '4'
|
||||
dry_run:
|
||||
description: 'Log what would be pinged without taking action'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
|
||||
concurrency:
|
||||
group: stale-issue-pr-ping
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ping_stale:
|
||||
name: "Ping stale issues and PRs"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install PyGithub==2.6.0
|
||||
|
||||
- name: Run stale issue/PR ping
|
||||
run: python .github/scripts/stale_issue_pr_ping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
|
||||
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
@@ -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<AgentResponse>();
|
||||
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<AgentEvaluationResults> 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<AgentEvaluationResults> 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<EvalItem, CheckResult>` 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<EvaluationResult> 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<string, AgentEvaluationResults>? 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<AgentEvaluationResults> EvaluateAsync(
|
||||
this AIAgent agent,
|
||||
IEnumerable<string> queries,
|
||||
IEvaluator evaluator,
|
||||
ChatConfiguration? chatConfiguration = null,
|
||||
IEnumerable<string>? expectedOutput = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// Evaluate pre-existing responses (without re-running the agent)
|
||||
public static Task<AgentEvaluationResults> EvaluateAsync(
|
||||
this AIAgent agent,
|
||||
AgentResponse responses,
|
||||
IEvaluator evaluator,
|
||||
IEnumerable<string>? queries = null,
|
||||
ChatConfiguration? chatConfiguration = null,
|
||||
IEnumerable<string>? expectedOutput = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// Evaluate with multiple evaluators (one result per evaluator)
|
||||
public static Task<IReadOnlyList<AgentEvaluationResults>> EvaluateAsync(
|
||||
this AIAgent agent,
|
||||
IEnumerable<string> queries,
|
||||
IEnumerable<IEvaluator> evaluators,
|
||||
ChatConfiguration? chatConfiguration = null,
|
||||
IEnumerable<string>? expectedOutput = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// Evaluate a workflow run with per-agent breakdown
|
||||
public static Task<AgentEvaluationResults> 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<string, bool> check); // response only
|
||||
public static EvalCheck Create(string name, Func<string, string?, bool> check); // expectedOutput
|
||||
public static EvalCheck Create(string name, Func<EvalItem, bool> check); // full item
|
||||
public static EvalCheck Create(string name, Func<EvalItem, CheckResult> check); // full control
|
||||
public static EvalCheck Create(string name, Func<string, Task<bool>> 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<string, object>? Arguments = null);
|
||||
|
||||
public sealed class EvalItem
|
||||
{
|
||||
public EvalItem(string query, string response, IReadOnlyList<ChatMessage> conversation);
|
||||
|
||||
public string Query { get; }
|
||||
public string Response { get; }
|
||||
public IReadOnlyList<ChatMessage> Conversation { get; }
|
||||
public IReadOnlyList<AITool>? Tools { get; set; }
|
||||
public string? ExpectedOutput { get; set; }
|
||||
public IReadOnlyList<ExpectedToolCall>? 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
|
||||
@@ -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<AgentSkillResource>? Resources { get; }
|
||||
public abstract IReadOnlyList<AgentSkillScript>? Scripts { get; }
|
||||
}
|
||||
|
||||
public abstract class AgentSkillResource
|
||||
{
|
||||
public string Name { get; }
|
||||
public string? Description { get; }
|
||||
public abstract Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public abstract class AgentSkillScript
|
||||
{
|
||||
public string Name { get; }
|
||||
public string? Description { get; }
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public abstract class AgentSkillsSource
|
||||
{
|
||||
public abstract Task<IList<AgentSkill>> 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<AgentSkillResource>? resources = null,
|
||||
IReadOnlyList<AgentSkillScript>? 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<object?> 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<object?> 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<object?> 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<string> 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<string>? AllowedResourceExtensions { get; set; }
|
||||
public IEnumerable<string>? 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<AgentSkill> 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<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<object?>(_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<object?> 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<object?> 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<AgentSkillResource>? Resources { get; } =
|
||||
[
|
||||
new AgentInlineSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"),
|
||||
];
|
||||
|
||||
public override IReadOnlyList<AgentSkillScript>? 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<AgentSkill, bool> _predicate;
|
||||
|
||||
public FilteringAgentSkillsSource(AgentSkillsSource innerSource, Func<AgentSkill, bool> predicate)
|
||||
: base(innerSource)
|
||||
{
|
||||
_predicate = predicate;
|
||||
}
|
||||
|
||||
public override async Task<IList<AgentSkill>> 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<AgentSkill>? _cached;
|
||||
|
||||
public CachingAgentSkillsSource(AgentSkillsSource innerSource)
|
||||
: base(innerSource)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<IList<AgentSkill>> 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<AgentSkillsSource>`** 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<AgentSkillsSource>`.
|
||||
- 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
|
||||
/// <summary>
|
||||
/// A skill resource backed by a cloud storage endpoint.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI of the cloud blob that holds this resource's content.
|
||||
/// </summary>
|
||||
public Uri BlobUri { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> 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
|
||||
/// <summary>
|
||||
/// A skill script executed via a cloud function endpoint.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI of the cloud function that runs this script.
|
||||
/// </summary>
|
||||
public Uri FunctionUri { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> 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
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkill"/> whose content, resources, and scripts are stored in a cloud service.
|
||||
/// </summary>
|
||||
public sealed class CloudSkill : AgentSkill
|
||||
{
|
||||
public CloudSkill(
|
||||
AgentSkillFrontmatter frontmatter,
|
||||
string content,
|
||||
Uri endpoint,
|
||||
IReadOnlyList<AgentSkillResource>? resources = null,
|
||||
IReadOnlyList<AgentSkillScript>? 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base cloud endpoint for this skill.
|
||||
/// </summary>
|
||||
public Uri Endpoint { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillResource>? Resources { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IReadOnlyList<AgentSkillScript>? 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
|
||||
/// <summary>
|
||||
/// A skill source that discovers and loads skills from a cloud catalog API.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<AgentSkill>> 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<CloudSkillCatalog>(json)!;
|
||||
|
||||
var skills = new List<AgentSkill>();
|
||||
|
||||
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<AgentSkillResource>();
|
||||
|
||||
// Build cloud-backed scripts.
|
||||
var scripts = entry.Scripts
|
||||
.Select(s => new CloudSkillScript(s.Name, s.FunctionUri, _httpClient, s.Description))
|
||||
.ToList<AgentSkillScript>();
|
||||
|
||||
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<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
// AgentSkill exposes scripts as:
|
||||
public abstract IReadOnlyList<AgentSkillScript>? 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<AgentSkillScript>? 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<AIFunction>?`. `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<AIFunction>? 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<AIFunction>? 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<object?> 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<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
// AgentSkill exposes resources as:
|
||||
public abstract IReadOnlyList<AgentSkillResource>? 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<AgentSkillResource>? 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<AIFunction>?`.
|
||||
`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<AIFunction>? 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<AIFunction>? 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<object?> 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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -22,11 +22,10 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="2.0.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0-beta.2" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.19.0" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
<!-- Google Gemini -->
|
||||
<PackageVersion Include="Google.GenAI" Version="0.11.0" />
|
||||
@@ -43,12 +42,12 @@
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.3" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.3" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.3" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -67,25 +66,26 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
@@ -114,9 +114,9 @@
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
<PackageVersion Include="OpenAI" Version="2.9.1" />
|
||||
<!-- Identity -->
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.1" />
|
||||
<!-- Workflows -->
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.4.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.4.1" />
|
||||
@@ -152,6 +152,7 @@
|
||||
<!-- Symbols -->
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||
<!-- Toolset -->
|
||||
<PackageVersion Include="ReferenceTrimmer" Version="3.4.5" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100" />
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -86,6 +87,8 @@
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/04_WorkflowMcpTool.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/05_WorkflowAndAgents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
@@ -111,7 +114,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentSkills/">
|
||||
<File Path="samples/02-agents/AgentSkills/README.md" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
@@ -319,7 +322,6 @@
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj" />
|
||||
</Folder>
|
||||
@@ -463,6 +465,10 @@
|
||||
<File Path="src/Shared/Samples/TextOutputHelperExtensions.cs" />
|
||||
<File Path="src/Shared/Samples/XunitLogger.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/Redaction/">
|
||||
<File Path="src/Shared/Redaction/README.md" />
|
||||
<File Path="src/Shared/Redaction/ReplacingRedactor.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/Throw/">
|
||||
<File Path="src/Shared/Throw/README.md" />
|
||||
<File Path="src/Shared/Throw/Throw.cs" />
|
||||
|
||||
@@ -29,4 +29,7 @@
|
||||
<ItemGroup Condition="'$(InjectSharedDiagnosticIds)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\DiagnosticIds\*.cs" LinkBase="Shared\DiagnosticIds" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(InjectSharedRedaction)' == 'true'">
|
||||
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Redaction\*.cs" LinkBase="Shared\Redaction" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
+12
-12
@@ -9,7 +9,7 @@ using ServerFunctionApproval;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ChatMessage>? result = null;
|
||||
|
||||
Dictionary<string, FunctionApprovalRequestContent> approvalRequests = [];
|
||||
Dictionary<string, ToolApprovalRequestContent> 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<string, object?>?)approvalRequest.FunctionArguments?
|
||||
.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>)));
|
||||
|
||||
var approvalRequestContent = new FunctionApprovalRequestContent(
|
||||
id: approvalRequest.ApprovalId,
|
||||
var approvalRequestContent = new ToolApprovalRequestContent(
|
||||
requestId: approvalRequest.ApprovalId,
|
||||
new FunctionCallContent(
|
||||
callId: approvalRequest.ApprovalId,
|
||||
name: approvalRequest.FunctionName,
|
||||
|
||||
+8
-9
@@ -9,7 +9,7 @@ using ServerFunctionApproval;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, string>();
|
||||
#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<string, FunctionApprovalRequestContent> trackedRequestApprovalToolCalls = new(); // Remote approvals
|
||||
Dictionary<string, ToolApprovalRequestContent> 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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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."));
|
||||
|
||||
@@ -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");
|
||||
@@ -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/)
|
||||
-40
@@ -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.
|
||||
-5
@@ -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 |
|
||||
-55
@@ -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.
|
||||
+4
@@ -14,6 +14,10 @@
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SubprocessScriptRunner.cs" Link="SubprocessScriptRunner.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -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}");
|
||||
@@ -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**
|
||||
```
|
||||
+11
@@ -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 <number> --factor <factor>` (e.g. `--value 26.2 --factor 1.60934`)
|
||||
3. Present the converted value clearly with both units
|
||||
+10
@@ -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 |
|
||||
+29
@@ -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()
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static class SubprocessScriptRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a skill script as a local subprocess.
|
||||
/// </summary>
|
||||
public static async Task<object?> 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<string> outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
Task<string> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a parameter key to a consistent --flag format.
|
||||
/// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
|
||||
/// </summary>
|
||||
private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+6
-3
@@ -20,19 +20,21 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
|
||||
/// <param name="instructions">Optional instructions for the agent.</param>
|
||||
/// <param name="name">Optional name for the agent.</param>
|
||||
/// <param name="description">Optional description for the agent.</param>
|
||||
/// <param name="model">Optional default model ID to use for requests. Required when using a plain <see cref="ResponsesClient"/> (not via Azure OpenAI).</param>
|
||||
/// <param name="loggerFactory">Optional instance of <see cref="ILoggerFactory"/></param>
|
||||
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
|
||||
/// </summary>
|
||||
/// <param name="client">Instance of <see cref="ResponsesClient"/></param>
|
||||
/// <param name="options">Options to create the agent.</param>
|
||||
/// <param name="model">Optional default model ID to use for requests. Required when using a plain <see cref="ResponsesClient"/> (not via Azure OpenAI).</param>
|
||||
/// <param name="loggerFactory">Optional instance of <see cref="ILoggerFactory"/></param>
|
||||
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))
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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.");
|
||||
|
||||
|
||||
+17
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().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<FunctionApprovalRequestContent>().ToList();
|
||||
// approvalRequests = updates.SelectMany(x => x.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
@@ -48,18 +48,18 @@ while (approvalRequests.Count > 0)
|
||||
List<ChatMessage> 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<FunctionApprovalRequestContent>().ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
|
||||
// For streaming use:
|
||||
// updates = await agent.RunStreamingAsync(userInputResponses, session).ToListAsync();
|
||||
// approvalRequests = updates.SelectMany(x => x.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
// approvalRequests = updates.SelectMany(x => x.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
+2
-2
@@ -10,14 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Agents.Persistent" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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());
|
||||
|
||||
+6
@@ -16,5 +16,11 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Assets\walkway.jpg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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();
|
||||
|
||||
+2
-1
@@ -25,8 +25,9 @@ var stateStore = new Dictionary<string, JsonElement?>();
|
||||
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.",
|
||||
|
||||
@@ -246,7 +246,7 @@ async Task<AgentResponse> ConsolePromptingApprovalMiddleware(IEnumerable<ChatMes
|
||||
AgentResponse response = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
|
||||
// For simplicity, we are assuming here that only function approvals are pending.
|
||||
List<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
@@ -255,13 +255,13 @@ async Task<AgentResponse> ConsolePromptingApprovalMiddleware(IEnumerable<ChatMes
|
||||
response.Messages = 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)]);
|
||||
});
|
||||
|
||||
response = await innerAgent.RunAsync(response.Messages, session, options, cancellationToken);
|
||||
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
-4
@@ -12,13 +12,9 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.Json" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.PowerFx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
|
||||
+3
-3
@@ -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<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
@@ -48,7 +48,7 @@ while (approvalRequests.Count > 0)
|
||||
List<ChatMessage> 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<FunctionApprovalRequestContent>().ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -197,7 +197,7 @@ async Task<AgentResponse> ConsolePromptingApprovalMiddleware(IEnumerable<ChatMes
|
||||
AgentResponse response = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
|
||||
// For simplicity, we are assuming here that only function approvals are pending.
|
||||
List<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
@@ -206,14 +206,14 @@ async Task<AgentResponse> ConsolePromptingApprovalMiddleware(IEnumerable<ChatMes
|
||||
response.Messages = 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)]);
|
||||
});
|
||||
|
||||
response = await innerAgent.RunAsync(response.Messages, session, options, cancellationToken);
|
||||
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
+2
-2
@@ -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)
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
-1
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.AI.Projects.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+2
-2
@@ -9,12 +9,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Agents.Persistent" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<McpServerToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolApprovalRequestContent>().ToList();
|
||||
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
@@ -89,11 +89,12 @@ while (approvalRequests.Count > 0)
|
||||
List<ChatMessage> 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<McpServerToolApprovalRequestContent>().ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
@@ -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<McpServerToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolApprovalRequestContent>().ToList();
|
||||
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
@@ -78,11 +80,12 @@ while (approvalRequests.Count > 0)
|
||||
List<ChatMessage> 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<McpServerToolApprovalRequestContent>().ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
@@ -61,6 +61,12 @@ public static class Program
|
||||
{
|
||||
Console.WriteLine($"{outputEvent}");
|
||||
}
|
||||
|
||||
if (evt is WorkflowErrorEvent errorEvent)
|
||||
{
|
||||
Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}");
|
||||
Console.WriteLine($"Details: {errorEvent.Exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve
|
||||
/// <summary>
|
||||
/// A custom executor that uses an AI agent to provide feedback on a slogan.
|
||||
/// </summary>
|
||||
internal sealed class FeedbackExecutor : Executor<SloganResult>
|
||||
[SendsMessage(typeof(FeedbackResult))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed partial class FeedbackExecutor : Executor<SloganResult>
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private AgentSession? _session;
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Agents.Persistent" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a translation agent for the specified target language.
|
||||
/// </summary>
|
||||
/// <param name="targetLanguage">The target language for translation</param>
|
||||
/// <param name="persistentAgentsClient">The PersistentAgentsClient to create the agent</param>
|
||||
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to create the agent with.</param>
|
||||
/// <param name="model">The model to use for the agent</param>
|
||||
/// <returns>A ChatClientAgent configured for the specified language</returns>
|
||||
private static async Task<ChatClientAgent> GetTranslationAgentAsync(
|
||||
private static async Task<ChatClientAgent> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ using Microsoft.Extensions.AI;
|
||||
namespace WorkflowAsAnAgentSample;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AIAgent"/>. 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 <see cref="IResettableExecutor"/>, 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 <see cref="IResettableExecutor.ResetAsync"/>
|
||||
/// on shared executors so that accumulated state (e.g., collected messages) is cleared
|
||||
/// before the next run begins. See <c>WorkflowFactory.ConcurrentAggregationExecutor</c>
|
||||
/// for the implementation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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();
|
||||
|
||||
@@ -10,6 +10,14 @@ internal static class WorkflowFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a workflow that uses two language agents to process input concurrently.
|
||||
///
|
||||
/// In this workflow, the <c>Start</c> <see cref="ChatForwardingExecutor"/> and the
|
||||
/// <see cref="ConcurrentAggregationExecutor"/> 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 <see cref="IResettableExecutor"/> so the
|
||||
/// framework can clear their state between runs. Framework-provided executors like
|
||||
/// <see cref="ChatForwardingExecutor"/> already implement this interface.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client to use for the agents</param>
|
||||
/// <returns>A workflow that processes input using two language agents</returns>
|
||||
@@ -40,7 +48,18 @@ internal static class WorkflowFactory
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
///
|
||||
/// This executor is stateful — it accumulates messages in <see cref="_messages"/>
|
||||
/// 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
|
||||
/// <see cref="IResettableExecutor"/> allows the framework to call <see cref="ResetAsync"/>
|
||||
/// between runs, clearing accumulated state so each run starts fresh.
|
||||
///
|
||||
/// Without <see cref="IResettableExecutor"/>, attempting to reuse a workflow containing
|
||||
/// shared executor instances that do not implement this interface would throw an
|
||||
/// <see cref="InvalidOperationException"/>.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class ConcurrentAggregationExecutor() :
|
||||
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
|
||||
{
|
||||
@@ -64,7 +83,11 @@ internal static class WorkflowFactory
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public ValueTask ResetAsync()
|
||||
{
|
||||
this._messages.Clear();
|
||||
|
||||
@@ -41,6 +41,7 @@ internal enum NumberSignal
|
||||
/// <summary>
|
||||
/// Executor that makes a guess based on the current bounds.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(int))]
|
||||
internal sealed class GuessNumberExecutor() : Executor<NumberSignal>("Guess")
|
||||
{
|
||||
/// <summary>
|
||||
@@ -104,6 +105,8 @@ internal sealed class GuessNumberExecutor() : Executor<NumberSignal>("Guess")
|
||||
/// <summary>
|
||||
/// Executor that judges the guess and provides feedback.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(NumberSignal))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class JudgeExecutor() : Executor<int>("Judge")
|
||||
{
|
||||
private readonly int _targetNumber;
|
||||
|
||||
@@ -41,6 +41,7 @@ internal enum NumberSignal
|
||||
/// <summary>
|
||||
/// Executor that makes a guess based on the current bounds.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(int))]
|
||||
internal sealed class GuessNumberExecutor() : Executor<NumberSignal>("Guess")
|
||||
{
|
||||
/// <summary>
|
||||
@@ -104,6 +105,8 @@ internal sealed class GuessNumberExecutor() : Executor<NumberSignal>("Guess")
|
||||
/// <summary>
|
||||
/// Executor that judges the guess and provides feedback.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(NumberSignal))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class JudgeExecutor() : Executor<int>("Judge")
|
||||
{
|
||||
private readonly int _targetNumber;
|
||||
|
||||
@@ -53,6 +53,8 @@ internal sealed class SignalWithNumber
|
||||
/// <summary>
|
||||
/// Executor that judges the guess and provides feedback.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(SignalWithNumber))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class JudgeExecutor() : Executor<int>("Judge")
|
||||
{
|
||||
private readonly int _targetNumber;
|
||||
|
||||
@@ -72,6 +72,8 @@ public static class Program
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(ChatMessage))]
|
||||
[SendsMessage(typeof(TurnToken))]
|
||||
internal sealed partial class ConcurrentStartExecutor() :
|
||||
Executor("ConcurrentStartExecutor")
|
||||
{
|
||||
@@ -97,7 +99,8 @@ internal sealed partial class ConcurrentStartExecutor() :
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
internal sealed class ConcurrentAggregationExecutor() :
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed partial class ConcurrentAggregationExecutor() :
|
||||
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
@@ -128,6 +128,7 @@ public static class Program
|
||||
/// <summary>
|
||||
/// Splits data into roughly equal chunks based on the number of mapper nodes.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(SplitComplete))]
|
||||
internal sealed class Split(string[] mapperIds, string id) :
|
||||
Executor<string>(id)
|
||||
{
|
||||
@@ -186,6 +187,7 @@ internal sealed class Split(string[] mapperIds, string id) :
|
||||
/// <summary>
|
||||
/// Maps each token to a count of 1 and writes pairs to a per-mapper file.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(MapComplete))]
|
||||
internal sealed class Mapper(string id) : Executor<SplitComplete>(id)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -212,6 +214,7 @@ internal sealed class Mapper(string id) : Executor<SplitComplete>(id)
|
||||
/// <summary>
|
||||
/// Groups intermediate pairs by key and partitions them across reducers.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(ShuffleComplete))]
|
||||
internal sealed class Shuffler(string[] reducerIds, string[] mapperIds, string id) :
|
||||
Executor<MapComplete>(id)
|
||||
{
|
||||
@@ -311,6 +314,7 @@ internal sealed class Shuffler(string[] reducerIds, string[] mapperIds, string i
|
||||
/// <summary>
|
||||
/// Sums grouped counts per key for its assigned partition.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(ReduceComplete))]
|
||||
internal sealed class Reducer(string id) : Executor<ShuffleComplete>(id)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -352,6 +356,7 @@ internal sealed class Reducer(string id) : Executor<ShuffleComplete>(id)
|
||||
/// <summary>
|
||||
/// Joins all reducer outputs and yields the final output.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(List<string>))]
|
||||
internal sealed class CompletionExecutor(string id) :
|
||||
Executor<List<ReduceComplete>>(id)
|
||||
{
|
||||
|
||||
@@ -228,6 +228,7 @@ internal sealed class EmailAssistantExecutor : Executor<DetectionResult, EmailRe
|
||||
/// <summary>
|
||||
/// Executor that sends emails.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class SendEmailExecutor() : Executor<EmailResponse>("SendEmailExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
@@ -240,6 +241,7 @@ internal sealed class SendEmailExecutor() : Executor<EmailResponse>("SendEmailEx
|
||||
/// <summary>
|
||||
/// Executor that handles spam messages.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class HandleSpamExecutor() : Executor<DetectionResult>("HandleSpamExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -252,6 +252,7 @@ internal sealed class EmailAssistantExecutor : Executor<DetectionResult, EmailRe
|
||||
/// <summary>
|
||||
/// Executor that sends emails.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class SendEmailExecutor() : Executor<EmailResponse>("SendEmailExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
@@ -264,6 +265,7 @@ internal sealed class SendEmailExecutor() : Executor<EmailResponse>("SendEmailEx
|
||||
/// <summary>
|
||||
/// Executor that handles spam messages.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class HandleSpamExecutor() : Executor<DetectionResult>("HandleSpamExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
@@ -285,6 +287,7 @@ internal sealed class HandleSpamExecutor() : Executor<DetectionResult>("HandleSp
|
||||
/// <summary>
|
||||
/// Executor that handles uncertain emails.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class HandleUncertainExecutor() : Executor<DetectionResult>("HandleUncertainExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -310,6 +310,7 @@ internal sealed class EmailAssistantExecutor : Executor<AnalysisResult, EmailRes
|
||||
/// <summary>
|
||||
/// Executor that sends emails.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class SendEmailExecutor() : Executor<EmailResponse>("SendEmailExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
@@ -322,6 +323,7 @@ internal sealed class SendEmailExecutor() : Executor<EmailResponse>("SendEmailEx
|
||||
/// <summary>
|
||||
/// Executor that handles spam messages.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class HandleSpamExecutor() : Executor<AnalysisResult>("HandleSpamExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
@@ -343,6 +345,7 @@ internal sealed class HandleSpamExecutor() : Executor<AnalysisResult>("HandleSpa
|
||||
/// <summary>
|
||||
/// Executor that handles uncertain messages.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class HandleUncertainExecutor() : Executor<AnalysisResult>("HandleUncertainExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user