Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5d282827 | |||
| 535690cd1d | |||
| e90b6de5a7 | |||
| d98ac29115 | |||
| 040e2705aa | |||
| 8c057507f4 | |||
| 0d5c0f8fa0 | |||
| 217912a2c0 | |||
| 0841116330 | |||
| cd6345e91e | |||
| 59b979213a | |||
| ad26cfe8c7 | |||
| 0c8bf5b6c0 | |||
| cb2914fa7e | |||
| 0796af0c26 | |||
| 711d6f24ae | |||
| bd17a64697 | |||
| 5147579992 | |||
| 85fde62a76 | |||
| 85eb53d412 | |||
| 2d7c8da6b0 | |||
| e0b0b79d9e | |||
| a4f6c26990 | |||
| 1389f304f2 | |||
| 93719f4a34 | |||
| a486374fd8 | |||
| e78604103d |
@@ -0,0 +1,212 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Enforce Python package coverage according to package lifecycle."""
|
||||
|
||||
# ruff:file-ignore[print]
|
||||
# ruff:file-ignore[implicit-namespace-package]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import]
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
DEVELOPMENT_STATUS_PREFIX = "Development Status :: "
|
||||
ENFORCED_DEVELOPMENT_STATUS = 4
|
||||
EXEMPT_PACKAGES = {"devui", "lab"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackagePolicy:
|
||||
"""Coverage policy derived from a package's project metadata."""
|
||||
|
||||
directory: str
|
||||
distribution_name: str
|
||||
development_status: int
|
||||
development_status_label: str
|
||||
enforced: bool
|
||||
exempt: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoverageStats:
|
||||
"""Line and branch coverage counters."""
|
||||
|
||||
lines_valid: int = 0
|
||||
lines_covered: int = 0
|
||||
branches_valid: int = 0
|
||||
branches_covered: int = 0
|
||||
|
||||
@property
|
||||
def line_coverage_percent(self) -> float:
|
||||
"""Return line coverage as a percentage."""
|
||||
if not self.lines_valid:
|
||||
return 0
|
||||
return self.lines_covered / self.lines_valid * 100
|
||||
|
||||
|
||||
def normalize_coverage_path(path: str) -> str:
|
||||
"""Normalize a coverage path for matching."""
|
||||
return path.replace("\\", "/").lstrip("./")
|
||||
|
||||
|
||||
def load_package_policies(packages_dir: Path) -> list[PackagePolicy]:
|
||||
"""Load lifecycle-based coverage policies from package pyproject files."""
|
||||
policies: list[PackagePolicy] = []
|
||||
for pyproject_path in sorted(packages_dir.glob("*/pyproject.toml")):
|
||||
with pyproject_path.open("rb") as pyproject_file:
|
||||
pyproject = tomllib.load(pyproject_file)
|
||||
|
||||
project = pyproject.get("project", {})
|
||||
distribution_name = str(project.get("name", "")).strip()
|
||||
if not distribution_name:
|
||||
raise ValueError(f"{pyproject_path}: project.name is required")
|
||||
|
||||
status_classifiers = [
|
||||
classifier
|
||||
for classifier in project.get("classifiers", [])
|
||||
if classifier.startswith(DEVELOPMENT_STATUS_PREFIX)
|
||||
]
|
||||
if len(status_classifiers) != 1:
|
||||
raise ValueError(
|
||||
f"{pyproject_path}: expected exactly one Development Status classifier, found {len(status_classifiers)}"
|
||||
)
|
||||
|
||||
match = re.fullmatch(r"Development Status :: (\d+) - (.+)", status_classifiers[0])
|
||||
if match is None:
|
||||
raise ValueError(f"{pyproject_path}: malformed Development Status classifier")
|
||||
|
||||
directory = pyproject_path.parent.name
|
||||
development_status = int(match.group(1))
|
||||
exempt = directory in EXEMPT_PACKAGES
|
||||
policies.append(
|
||||
PackagePolicy(
|
||||
directory=directory,
|
||||
distribution_name=distribution_name,
|
||||
development_status=development_status,
|
||||
development_status_label=match.group(2),
|
||||
enforced=development_status >= ENFORCED_DEVELOPMENT_STATUS and not exempt,
|
||||
exempt=exempt,
|
||||
)
|
||||
)
|
||||
|
||||
if not policies:
|
||||
raise ValueError(f"No package pyproject.toml files found below {packages_dir}")
|
||||
return policies
|
||||
|
||||
|
||||
def parse_coverage_xml(xml_path: Path) -> tuple[dict[str, CoverageStats], float, float]:
|
||||
"""Parse Cobertura XML and aggregate coverage by package directory."""
|
||||
root = ET.parse(xml_path).getroot() # ruff:ignore[suspicious-xml-element-tree-usage] # Trusted CI-generated coverage report.
|
||||
package_stats: dict[str, CoverageStats] = {}
|
||||
|
||||
for class_elem in root.findall(".//class"):
|
||||
file_path = normalize_coverage_path(class_elem.get("filename", ""))
|
||||
path_parts = file_path.split("/")
|
||||
try:
|
||||
packages_index = path_parts.index("packages")
|
||||
package_directory = path_parts[packages_index + 1]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
stats = package_stats.setdefault(package_directory, CoverageStats())
|
||||
for line in class_elem.findall(".//line"):
|
||||
stats.lines_valid += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
stats.lines_covered += 1
|
||||
|
||||
if line.get("branch") != "true":
|
||||
continue
|
||||
condition_coverage = line.get("condition-coverage", "")
|
||||
match = re.search(r"\((\d+)/(\d+)\)", condition_coverage)
|
||||
if match is not None:
|
||||
stats.branches_covered += int(match.group(1))
|
||||
stats.branches_valid += int(match.group(2))
|
||||
|
||||
return (
|
||||
package_stats,
|
||||
float(root.get("line-rate", 0)) * 100,
|
||||
float(root.get("branch-rate", 0)) * 100,
|
||||
)
|
||||
|
||||
|
||||
def check_coverage(xml_path: Path, threshold: float, packages_dir: Path) -> bool:
|
||||
"""Check all lifecycle-enforced packages against the coverage threshold."""
|
||||
policies = load_package_policies(packages_dir)
|
||||
package_stats, overall_line_coverage, overall_branch_coverage = parse_coverage_xml(xml_path)
|
||||
|
||||
print("\n" + "=" * 110)
|
||||
print("PYTHON PACKAGE TEST COVERAGE")
|
||||
print("=" * 110)
|
||||
print(f"Overall Line Coverage: {overall_line_coverage:.1f}%")
|
||||
print(f"Overall Branch Coverage: {overall_branch_coverage:.1f}%")
|
||||
print(f"Enforced Threshold: {threshold:.1f}%")
|
||||
print("-" * 110)
|
||||
print(f"{'Package':<48} {'Stage':<20} {'Policy':<14} {'Lines':<12} {'Line Cov':<10}")
|
||||
print("-" * 110)
|
||||
|
||||
failed_packages: list[str] = []
|
||||
for policy in sorted(policies, key=lambda item: (not item.enforced, item.distribution_name)):
|
||||
stats = package_stats.get(policy.directory)
|
||||
if policy.exempt:
|
||||
policy_label = "EXEMPT"
|
||||
elif policy.enforced:
|
||||
policy_label = "ENFORCED"
|
||||
else:
|
||||
policy_label = "REPORT ONLY"
|
||||
|
||||
if stats is None:
|
||||
lines = "-"
|
||||
coverage = "missing"
|
||||
if policy.enforced:
|
||||
failed_packages.append(f"{policy.distribution_name} (missing from coverage report)")
|
||||
else:
|
||||
lines = f"{stats.lines_covered}/{stats.lines_valid}"
|
||||
coverage = f"{stats.line_coverage_percent:.1f}%"
|
||||
if policy.enforced and stats.line_coverage_percent < threshold:
|
||||
failed_packages.append(f"{policy.distribution_name} ({coverage})")
|
||||
|
||||
stage = f"{policy.development_status} - {policy.development_status_label}"
|
||||
print(f"{policy.distribution_name:<48} {stage:<20} {policy_label:<14} {lines:<12} {coverage:<10}")
|
||||
|
||||
print("-" * 110)
|
||||
if failed_packages:
|
||||
print(f"\nFAILED: Enforced packages below {threshold:.1f}% or missing:")
|
||||
for package in failed_packages:
|
||||
print(f" - {package}")
|
||||
return False
|
||||
|
||||
print(f"\nPASSED: All non-exempt Beta-or-higher packages meet {threshold:.1f}% line coverage.")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the coverage policy check."""
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
|
||||
return 1
|
||||
|
||||
try:
|
||||
threshold = float(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Error: Invalid threshold value: {sys.argv[2]}")
|
||||
return 1
|
||||
|
||||
repository_root = Path(__file__).resolve().parents[2]
|
||||
try:
|
||||
passed = check_coverage(
|
||||
Path(sys.argv[1]),
|
||||
threshold,
|
||||
repository_root / "python" / "packages",
|
||||
)
|
||||
except (FileNotFoundError, ET.ParseError, ValueError) as error:
|
||||
print(f"Error: {error}")
|
||||
return 1
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# ruff:file-ignore[implicit-namespace-package, undocumented-public-class, undocumented-public-method]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "python_check_coverage.py"
|
||||
SPEC = importlib.util.spec_from_file_location("python_check_coverage", SCRIPT_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load {SCRIPT_PATH}")
|
||||
coverage_checker = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = coverage_checker
|
||||
SPEC.loader.exec_module(coverage_checker)
|
||||
|
||||
|
||||
class CoveragePolicyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.packages_dir = self.root / "packages"
|
||||
self.packages_dir.mkdir()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def write_package(self, directory: str, name: str, status: str) -> None:
|
||||
package_dir = self.packages_dir / directory
|
||||
package_dir.mkdir()
|
||||
(package_dir / "pyproject.toml").write_text(
|
||||
f"""
|
||||
[project]
|
||||
name = "{name}"
|
||||
classifiers = ["Development Status :: {status}"]
|
||||
""".strip()
|
||||
)
|
||||
|
||||
def write_coverage(self, files: dict[str, list[int]]) -> Path:
|
||||
classes = []
|
||||
total_lines = 0
|
||||
covered_lines = 0
|
||||
for file_path, hits in files.items():
|
||||
lines = []
|
||||
for line_number, hit_count in enumerate(hits, start=1):
|
||||
total_lines += 1
|
||||
covered_lines += hit_count > 0
|
||||
lines.append(f'<line number="{line_number}" hits="{hit_count}"/>')
|
||||
classes.append(f'<class filename="{file_path}"><lines>{"".join(lines)}</lines></class>')
|
||||
|
||||
line_rate = covered_lines / total_lines if total_lines else 0
|
||||
xml_path = self.root / "coverage.xml"
|
||||
xml_path.write_text(
|
||||
f"""
|
||||
<coverage line-rate="{line_rate}" branch-rate="0">
|
||||
<packages>
|
||||
<package name="test">
|
||||
<classes>{"".join(classes)}</classes>
|
||||
</package>
|
||||
</packages>
|
||||
</coverage>
|
||||
""".strip()
|
||||
)
|
||||
return xml_path
|
||||
|
||||
def test_load_package_policies_uses_lifecycle_exemptions(self) -> None:
|
||||
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
self.write_package("stable", "agent-framework-stable", "5 - Production/Stable")
|
||||
self.write_package("devui", "agent-framework-devui", "4 - Beta")
|
||||
self.write_package("lab", "agent-framework-lab", "4 - Beta")
|
||||
|
||||
policies = {policy.directory: policy for policy in coverage_checker.load_package_policies(self.packages_dir)}
|
||||
|
||||
self.assertFalse(policies["alpha"].enforced)
|
||||
self.assertTrue(policies["beta"].enforced)
|
||||
self.assertTrue(policies["stable"].enforced)
|
||||
self.assertTrue(policies["devui"].exempt)
|
||||
self.assertFalse(policies["devui"].enforced)
|
||||
self.assertTrue(policies["lab"].exempt)
|
||||
self.assertFalse(policies["lab"].enforced)
|
||||
|
||||
def test_load_package_policies_rejects_missing_lifecycle(self) -> None:
|
||||
package_dir = self.packages_dir / "missing"
|
||||
package_dir.mkdir()
|
||||
(package_dir / "pyproject.toml").write_text('[project]\nname = "agent-framework-missing"\n')
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "exactly one Development Status"):
|
||||
coverage_checker.load_package_policies(self.packages_dir)
|
||||
|
||||
def test_parse_coverage_aggregates_nested_modules_by_distribution(self) -> None:
|
||||
xml_path = self.write_coverage({
|
||||
"packages/core/agent_framework/_agents.py": [1, 0],
|
||||
"packages/core/agent_framework/_workflows/_workflow.py": [1, 1],
|
||||
})
|
||||
|
||||
package_stats, _, _ = coverage_checker.parse_coverage_xml(xml_path)
|
||||
|
||||
self.assertEqual(package_stats["core"].lines_valid, 4)
|
||||
self.assertEqual(package_stats["core"].lines_covered, 3)
|
||||
|
||||
def test_beta_package_below_threshold_fails(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1, 0]})
|
||||
|
||||
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_missing_beta_package_fails(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({})
|
||||
|
||||
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_alpha_and_exempt_packages_do_not_fail(self) -> None:
|
||||
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
|
||||
self.write_package("devui", "agent-framework-devui", "4 - Beta")
|
||||
self.write_package("lab", "agent-framework-lab", "4 - Beta")
|
||||
xml_path = self.write_coverage({})
|
||||
|
||||
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_beta_package_at_threshold_passes(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1] * 17 + [0] * 3})
|
||||
|
||||
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -121,7 +121,6 @@ jobs:
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
copilot-requests: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 60
|
||||
@@ -171,7 +170,7 @@ jobs:
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ github.token }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
PR_URL: ${{ needs.team_check.outputs.pr_url }}
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
- ".github/workflows/github-automation-tests.yml"
|
||||
push:
|
||||
branches:
|
||||
@@ -14,6 +15,7 @@ on:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
- ".github/workflows/github-automation-tests.yml"
|
||||
|
||||
permissions:
|
||||
@@ -29,5 +31,12 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Run tests
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Run JavaScript tests
|
||||
run: node --test .github/tests/*.js
|
||||
|
||||
- name: Run Python tests
|
||||
run: python .github/tests/test_python_check_coverage.py
|
||||
|
||||
@@ -126,7 +126,6 @@ jobs:
|
||||
environment: integration
|
||||
permissions:
|
||||
contents: read
|
||||
copilot-requests: write
|
||||
id-token: write
|
||||
issues: write
|
||||
timeout-minutes: 60
|
||||
@@ -203,7 +202,7 @@ jobs:
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ github.token }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
# Not seen by the agent prompt; used only to push a paper-trail
|
||||
# branch back to maf-dashboard at run end.
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Check Python test coverage against threshold for enforced targets.
|
||||
|
||||
This script parses a Cobertura XML coverage report and enforces a minimum
|
||||
coverage threshold on specific targets. Targets can be package names
|
||||
(e.g., "packages.core.agent_framework") or individual Python file paths
|
||||
(e.g., "packages/core/agent_framework/observability.py").
|
||||
|
||||
Non-enforced targets are reported for visibility but don't block the build.
|
||||
|
||||
Usage:
|
||||
python python-check-coverage.py <coverage-xml-path> <threshold>
|
||||
|
||||
Example:
|
||||
python python-check-coverage.py python-coverage.xml 85
|
||||
"""
|
||||
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
|
||||
# =============================================================================
|
||||
# ENFORCED TARGETS CONFIGURATION
|
||||
# =============================================================================
|
||||
# Add or remove entries from this set to control which targets must meet
|
||||
# the coverage threshold. Only these targets will fail the build if below
|
||||
# threshold. Other targets are reported for visibility only.
|
||||
#
|
||||
# Target values can be:
|
||||
# - Package paths as they appear in the coverage report
|
||||
# (e.g., "packages.azure-ai.agent_framework_azure_ai")
|
||||
# - Python source file paths as they appear in the coverage report
|
||||
# (e.g., "packages/core/agent_framework/observability.py")
|
||||
# =============================================================================
|
||||
ENFORCED_TARGETS: set[str] = {
|
||||
# Packages (sorted alphabetically)
|
||||
"packages.anthropic.agent_framework_anthropic",
|
||||
"packages.azure-ai-search.agent_framework_azure_ai_search",
|
||||
"packages.core.agent_framework",
|
||||
"packages.core.agent_framework._workflows",
|
||||
"packages.foundry.agent_framework_foundry",
|
||||
"packages.openai.agent_framework_openai",
|
||||
"packages.purview.agent_framework_purview",
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageCoverage:
|
||||
"""Coverage data for a single package."""
|
||||
|
||||
name: str
|
||||
line_rate: float
|
||||
branch_rate: float
|
||||
lines_valid: int
|
||||
lines_covered: int
|
||||
branches_valid: int
|
||||
branches_covered: int
|
||||
|
||||
@property
|
||||
def line_coverage_percent(self) -> float:
|
||||
"""Return line coverage as a percentage."""
|
||||
return self.line_rate * 100
|
||||
|
||||
@property
|
||||
def branch_coverage_percent(self) -> float:
|
||||
"""Return branch coverage as a percentage."""
|
||||
return self.branch_rate * 100
|
||||
|
||||
|
||||
def normalize_coverage_path(path: str) -> str:
|
||||
"""Normalize coverage paths for reliable matching."""
|
||||
return path.replace("\\", "/").lstrip("./")
|
||||
|
||||
|
||||
def parse_coverage_xml(
|
||||
xml_path: str,
|
||||
) -> tuple[dict[str, PackageCoverage], dict[str, PackageCoverage], float, float]:
|
||||
"""Parse Cobertura XML and extract per-package coverage data.
|
||||
|
||||
Args:
|
||||
xml_path: Path to the Cobertura XML coverage report.
|
||||
|
||||
Returns:
|
||||
A tuple of (packages_dict, files_dict, overall_line_rate, overall_branch_rate).
|
||||
"""
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Get overall coverage from root element
|
||||
overall_line_rate = float(root.get("line-rate", 0))
|
||||
overall_branch_rate = float(root.get("branch-rate", 0))
|
||||
|
||||
packages: dict[str, PackageCoverage] = {}
|
||||
file_stats: dict[str, dict[str, int]] = {}
|
||||
|
||||
for package in root.findall(".//package"):
|
||||
package_path = package.get("name", "unknown")
|
||||
|
||||
line_rate = float(package.get("line-rate", 0))
|
||||
branch_rate = float(package.get("branch-rate", 0))
|
||||
|
||||
# Count lines and branches from classes within this package
|
||||
lines_valid = 0
|
||||
lines_covered = 0
|
||||
branches_valid = 0
|
||||
branches_covered = 0
|
||||
|
||||
for class_elem in package.findall(".//class"):
|
||||
file_path = normalize_coverage_path(class_elem.get("filename", ""))
|
||||
if file_path and file_path not in file_stats:
|
||||
file_stats[file_path] = {
|
||||
"lines_valid": 0,
|
||||
"lines_covered": 0,
|
||||
"branches_valid": 0,
|
||||
"branches_covered": 0,
|
||||
}
|
||||
|
||||
for line in class_elem.findall(".//line"):
|
||||
lines_valid += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
lines_covered += 1
|
||||
|
||||
if file_path:
|
||||
file_stats[file_path]["lines_valid"] += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
file_stats[file_path]["lines_covered"] += 1
|
||||
|
||||
# Branch coverage from line elements
|
||||
if line.get("branch") == "true":
|
||||
condition_coverage = line.get("condition-coverage", "")
|
||||
if condition_coverage:
|
||||
# Parse "X% (covered/total)" format
|
||||
try:
|
||||
coverage_parts = (
|
||||
condition_coverage.split("(")[1].rstrip(")").split("/")
|
||||
)
|
||||
branches_covered += int(coverage_parts[0])
|
||||
branches_valid += int(coverage_parts[1])
|
||||
if file_path:
|
||||
file_stats[file_path]["branches_covered"] += int(
|
||||
coverage_parts[0]
|
||||
)
|
||||
file_stats[file_path]["branches_valid"] += int(
|
||||
coverage_parts[1]
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
# Ignore malformed condition-coverage strings; treat this line as having no branch data.
|
||||
pass
|
||||
|
||||
# Use full package path as the key (no aggregation)
|
||||
packages[package_path] = PackageCoverage(
|
||||
name=package_path,
|
||||
line_rate=line_rate if lines_valid == 0 else lines_covered / lines_valid,
|
||||
branch_rate=branch_rate
|
||||
if branches_valid == 0
|
||||
else branches_covered / branches_valid,
|
||||
lines_valid=lines_valid,
|
||||
lines_covered=lines_covered,
|
||||
branches_valid=branches_valid,
|
||||
branches_covered=branches_covered,
|
||||
)
|
||||
|
||||
files: dict[str, PackageCoverage] = {}
|
||||
for file_path, stats in file_stats.items():
|
||||
lines_valid = stats["lines_valid"]
|
||||
lines_covered = stats["lines_covered"]
|
||||
branches_valid = stats["branches_valid"]
|
||||
branches_covered = stats["branches_covered"]
|
||||
|
||||
files[file_path] = PackageCoverage(
|
||||
name=file_path,
|
||||
line_rate=0 if lines_valid == 0 else lines_covered / lines_valid,
|
||||
branch_rate=0 if branches_valid == 0 else branches_covered / branches_valid,
|
||||
lines_valid=lines_valid,
|
||||
lines_covered=lines_covered,
|
||||
branches_valid=branches_valid,
|
||||
branches_covered=branches_covered,
|
||||
)
|
||||
|
||||
return packages, files, overall_line_rate, overall_branch_rate
|
||||
|
||||
|
||||
def format_coverage_value(coverage: float, threshold: float, is_enforced: bool) -> str:
|
||||
"""Format a coverage value with optional pass/fail indicator.
|
||||
|
||||
Args:
|
||||
coverage: Coverage percentage (0-100).
|
||||
threshold: Minimum required coverage percentage.
|
||||
is_enforced: Whether this target is enforced.
|
||||
|
||||
Returns:
|
||||
Formatted string like "85.5%" or "85.5% ✅" or "75.0% ❌".
|
||||
"""
|
||||
formatted = f"{coverage:.1f}%"
|
||||
if is_enforced:
|
||||
icon = "✅" if coverage >= threshold else "❌"
|
||||
formatted = f"{formatted} {icon}"
|
||||
return formatted
|
||||
|
||||
|
||||
def print_coverage_table(
|
||||
packages: dict[str, PackageCoverage],
|
||||
files: dict[str, PackageCoverage],
|
||||
threshold: float,
|
||||
overall_line_rate: float,
|
||||
overall_branch_rate: float,
|
||||
) -> None:
|
||||
"""Print a formatted coverage summary table.
|
||||
|
||||
Args:
|
||||
packages: Dictionary of package name to coverage data.
|
||||
files: Dictionary of file path to coverage data, used for per-file enforcement.
|
||||
threshold: Minimum required coverage percentage.
|
||||
overall_line_rate: Overall line coverage rate (0-1).
|
||||
overall_branch_rate: Overall branch coverage rate (0-1).
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("PYTHON TEST COVERAGE REPORT")
|
||||
print("=" * 80)
|
||||
|
||||
# Overall coverage
|
||||
print(f"\nOverall Line Coverage: {overall_line_rate * 100:.1f}%")
|
||||
print(f"Overall Branch Coverage: {overall_branch_rate * 100:.1f}%")
|
||||
print(f"Threshold: {threshold}%")
|
||||
|
||||
enforced_targets = {normalize_coverage_path(t) for t in ENFORCED_TARGETS}
|
||||
|
||||
# Package table
|
||||
print("\n" + "-" * 110)
|
||||
print(f"{'Package':<80} {'Lines':<15} {'Line Cov':<15}")
|
||||
print("-" * 110)
|
||||
|
||||
# Sort: enforced package targets first, then alphabetically
|
||||
sorted_packages = sorted(
|
||||
packages.values(),
|
||||
key=lambda p: (p.name not in ENFORCED_TARGETS, p.name),
|
||||
)
|
||||
|
||||
for pkg in sorted_packages:
|
||||
is_enforced = normalize_coverage_path(pkg.name) in enforced_targets
|
||||
enforced_marker = "[ENFORCED] " if is_enforced else ""
|
||||
line_cov = format_coverage_value(
|
||||
pkg.line_coverage_percent, threshold, is_enforced
|
||||
)
|
||||
lines_info = f"{pkg.lines_covered}/{pkg.lines_valid}"
|
||||
package_label = f"{enforced_marker}{pkg.name}"
|
||||
|
||||
print(f"{package_label:<80} {lines_info:<15} {line_cov:<15}")
|
||||
|
||||
print("-" * 110)
|
||||
|
||||
# Enforced file/model entries (if configured)
|
||||
enforced_files = [
|
||||
files[target]
|
||||
for target in sorted(enforced_targets)
|
||||
if target in files and target.endswith(".py")
|
||||
]
|
||||
|
||||
if enforced_files:
|
||||
print("\nEnforced Files/Models")
|
||||
print("-" * 110)
|
||||
print(f"{'File':<80} {'Lines':<15} {'Line Cov':<15}")
|
||||
print("-" * 110)
|
||||
|
||||
for file_cov in enforced_files:
|
||||
line_cov = format_coverage_value(
|
||||
file_cov.line_coverage_percent, threshold, True
|
||||
)
|
||||
lines_info = f"{file_cov.lines_covered}/{file_cov.lines_valid}"
|
||||
print(f"[ENFORCED] {file_cov.name:<69} {lines_info:<15} {line_cov:<15}")
|
||||
|
||||
print("-" * 110)
|
||||
|
||||
|
||||
def check_coverage(xml_path: str, threshold: float) -> bool:
|
||||
"""Check if all enforced targets meet the coverage threshold.
|
||||
|
||||
Args:
|
||||
xml_path: Path to the Cobertura XML coverage report.
|
||||
threshold: Minimum required coverage percentage.
|
||||
|
||||
Returns:
|
||||
True if all enforced targets pass, False otherwise.
|
||||
"""
|
||||
packages, files, overall_line_rate, overall_branch_rate = parse_coverage_xml(
|
||||
xml_path
|
||||
)
|
||||
|
||||
print_coverage_table(
|
||||
packages, files, threshold, overall_line_rate, overall_branch_rate
|
||||
)
|
||||
|
||||
# Check enforced targets
|
||||
failed_targets: list[str] = []
|
||||
missing_targets: list[str] = []
|
||||
|
||||
for target_name in ENFORCED_TARGETS:
|
||||
normalized_target = normalize_coverage_path(target_name)
|
||||
package_alias = normalized_target.replace("/", ".")
|
||||
|
||||
target_coverage = None
|
||||
if target_name in packages:
|
||||
target_coverage = packages[target_name]
|
||||
elif normalized_target in files:
|
||||
target_coverage = files[normalized_target]
|
||||
elif package_alias in packages:
|
||||
target_coverage = packages[package_alias]
|
||||
|
||||
if target_coverage is None:
|
||||
missing_targets.append(target_name)
|
||||
continue
|
||||
|
||||
if target_coverage.line_coverage_percent < threshold:
|
||||
failed_targets.append(
|
||||
f"{target_name} ({target_coverage.line_coverage_percent:.1f}%)"
|
||||
)
|
||||
|
||||
# Report results
|
||||
if missing_targets:
|
||||
print(
|
||||
f"\n❌ FAILED: Enforced targets not found in coverage report: {', '.join(missing_targets)}"
|
||||
)
|
||||
return False
|
||||
|
||||
if failed_targets:
|
||||
print(
|
||||
f"\n❌ FAILED: The following enforced targets are below {threshold}% coverage threshold:"
|
||||
)
|
||||
for target in failed_targets:
|
||||
print(f" - {target}")
|
||||
print("\nTo fix: Add more tests to improve coverage for the failing targets.")
|
||||
return False
|
||||
|
||||
if ENFORCED_TARGETS:
|
||||
found_enforced = [
|
||||
target
|
||||
for target in ENFORCED_TARGETS
|
||||
if target in packages or normalize_coverage_path(target) in files
|
||||
]
|
||||
if found_enforced:
|
||||
print(
|
||||
f"\n✅ PASSED: All enforced targets meet the {threshold}% coverage threshold."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point.
|
||||
|
||||
Returns:
|
||||
Exit code: 0 for success, 1 for failure.
|
||||
"""
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
|
||||
print(f"Example: {sys.argv[0]} python-coverage.xml 85")
|
||||
return 1
|
||||
|
||||
xml_path = sys.argv[1]
|
||||
try:
|
||||
threshold = float(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Error: Invalid threshold value: {sys.argv[2]}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
success = check_coverage(xml_path, threshold)
|
||||
return 0 if success else 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Coverage file not found: {xml_path}")
|
||||
return 1
|
||||
except ET.ParseError as e:
|
||||
print(f"Error: Failed to parse coverage XML: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
paths:
|
||||
- "python/packages/**"
|
||||
- "python/tests/unit/**"
|
||||
- "python/scripts/workspace_poe_tasks.py"
|
||||
- ".github/scripts/python_check_coverage.py"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -37,10 +40,10 @@ jobs:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run all tests with coverage report
|
||||
- name: Run aggregate tests with coverage report
|
||||
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 }}
|
||||
run: python ${{ github.workspace }}/.github/scripts/python_check_coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<PackageVersion Include="AGUI.Client" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Server" Version="0.0.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -80,11 +80,11 @@
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" /> <!-- Pin patched OpenAPI.NET to remediate GHSA-v5pm-xwqc-g5wc -->
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
@@ -102,10 +102,10 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.InMemory" Version="1.0.0" />
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.Qdrant" Version="1.0.0" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.5" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -5,10 +5,10 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -7,10 +7,10 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using SampleApp;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Samples;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.Qdrant" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.Qdrant;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.Qdrant;
|
||||
using Qdrant.Client;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
@@ -134,6 +134,6 @@ internal sealed class DocumentationChunk
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
[VectorStoreData]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
[VectorStoreVector(Dimensions: 3072)]
|
||||
[VectorStoreVector(dimensions: 3072)]
|
||||
public string Embedding => this.Text;
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -12,10 +12,10 @@ using System.Text.Json;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using SampleApp;
|
||||
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MAAIW001;OPENAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -65,6 +65,17 @@ public static class Program
|
||||
.AddParticipants([researcherAgent, coderAgent])
|
||||
.WithName("Magentic Orchestration Workflow")
|
||||
.WithDescription("Coordinates a researcher and coder to solve a complex analytical task.")
|
||||
// By default the manager's internally generated messages (task ledger, progress ledger, final answer)
|
||||
// use the built-in English prompts. To have them written in another language, pin a concrete language:
|
||||
// .WithResponseLanguage("French")
|
||||
// For full control you can also override any of the internal prompt templates (placeholders such as
|
||||
// {task}, {team}, and - for the progress ledger - {schema} are substituted by the framework):
|
||||
// .WithPromptOverrides(new MagenticPromptOverrides
|
||||
// {
|
||||
// FinalAnswerPrompt = "Rédige la réponse finale à la demande suivante en français :\n{task}",
|
||||
// })
|
||||
// The built-in English templates you can copy and translate are published on MagenticDefaultPrompts
|
||||
// (e.g. MagenticDefaultPrompts.ProgressLedgerPrompt) - use them as a starting point for your overrides.
|
||||
.RequirePlanSignoff(false)
|
||||
.WithMaxRounds(10)
|
||||
.WithMaxStalls(3)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// The built-in English prompt templates the Magentic manager uses. These are exposed so callers can read them and
|
||||
/// base a <see cref="MagenticPromptOverrides"/> value on the default (for example, translating it or appending a
|
||||
/// language instruction) instead of writing a prompt from scratch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each template uses named single-brace placeholders (e.g. <c>{task}</c>) that the framework substitutes at render
|
||||
/// time; the available placeholders per prompt are documented on the corresponding <see cref="MagenticPromptOverrides"/>
|
||||
/// property. A progress-ledger override must keep the <c>{schema}</c> placeholder so the JSON schema can be injected.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class MagenticDefaultPrompts
|
||||
{
|
||||
/// <summary>The default template for gathering the initial fact sheet. Placeholders: <c>{task}</c>.</summary>
|
||||
public static readonly string TaskLedgerFactsPrompt = """
|
||||
Below I will present you a request.
|
||||
|
||||
Before we begin addressing the request, please answer the following pre-survey to the best of your ability.
|
||||
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be
|
||||
a deep well to draw from.
|
||||
|
||||
Here is the request:
|
||||
|
||||
{task}
|
||||
|
||||
Here is the pre-survey:
|
||||
|
||||
1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that
|
||||
there are none.
|
||||
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found.
|
||||
In some cases, authoritative sources are mentioned in the request itself.
|
||||
3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation)
|
||||
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
|
||||
|
||||
When answering this survey, keep in mind that "facts" will typically be specific names, dates, statistics, etc.
|
||||
Your answer should use headings:
|
||||
|
||||
1. GIVEN OR VERIFIED FACTS
|
||||
2. FACTS TO LOOK UP
|
||||
3. FACTS TO DERIVE
|
||||
4. EDUCATED GUESSES
|
||||
|
||||
DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so.
|
||||
""";
|
||||
|
||||
/// <summary>The default template for updating the fact sheet on a replan. Placeholders: <c>{task}</c>, <c>{old_facts}</c>.</summary>
|
||||
public static readonly string TaskLedgerFactsUpdatePrompt = """
|
||||
As a reminder, we are working to solve the following task:
|
||||
|
||||
{task}
|
||||
|
||||
It is clear we are not making as much progress as we would like, but we may have learned something new.
|
||||
Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful.
|
||||
|
||||
Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts
|
||||
if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact
|
||||
sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update
|
||||
one educated guess or hunch, and explain your reasoning.
|
||||
|
||||
Here is the old fact sheet:
|
||||
|
||||
{old_facts}
|
||||
""";
|
||||
|
||||
/// <summary>The default template for creating the initial plan. Placeholders: <c>{team}</c>.</summary>
|
||||
public static readonly string TaskLedgerPlanPrompt = """
|
||||
Fantastic. To address this request we have assembled the following team:
|
||||
|
||||
{team}
|
||||
|
||||
Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the
|
||||
original request. Remember, there is no requirement to involve all team members. A team member's particular expertise
|
||||
may not be needed for this task.
|
||||
""";
|
||||
|
||||
/// <summary>The default template for updating the plan on a replan. Placeholders: <c>{team}</c>.</summary>
|
||||
public static readonly string TaskLedgerPlanUpdatePrompt = """
|
||||
Please briefly explain what went wrong on this last run
|
||||
(the root cause of the failure), and then come up with a new plan that takes steps and includes hints to overcome prior
|
||||
challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, expressed in
|
||||
bullet-point form, and consider the following team composition:
|
||||
|
||||
{team}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// The default template for the full task ledger (the plan-event text combining facts and plan).
|
||||
/// Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{facts}</c>, <c>{plan}</c>.
|
||||
/// </summary>
|
||||
public static readonly string TaskLedgerFullPrompt = """
|
||||
We are working to address the following user request:
|
||||
|
||||
{task}
|
||||
|
||||
|
||||
To answer this request we have assembled the following team:
|
||||
|
||||
{team}
|
||||
|
||||
|
||||
Here is an initial fact sheet to consider:
|
||||
|
||||
{facts}
|
||||
|
||||
|
||||
Here is the plan to follow as best as possible:
|
||||
|
||||
{plan}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// The default progress-ledger template. Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{questions}</c>,
|
||||
/// <c>{schema}</c>. An override must keep <c>{schema}</c> so the JSON schema the response is parsed against can
|
||||
/// be injected.
|
||||
/// </summary>
|
||||
public static readonly string ProgressLedgerPrompt = """
|
||||
Recall we are working on the following request:
|
||||
|
||||
{task}
|
||||
|
||||
And we have assembled the following team:
|
||||
|
||||
{team}
|
||||
|
||||
To make progress on the request, please answer the following questions, including necessary reasoning:
|
||||
|
||||
{questions}
|
||||
|
||||
Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is.
|
||||
DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA:
|
||||
|
||||
{schema}
|
||||
""";
|
||||
|
||||
/// <summary>The default template for synthesizing the final answer. Placeholders: <c>{task}</c>.</summary>
|
||||
public static readonly string FinalAnswerPrompt = """
|
||||
We are working on the following task:
|
||||
{task}
|
||||
|
||||
We have completed the task.
|
||||
|
||||
The above messages contain the conversation that took place to complete the task.
|
||||
|
||||
Based on the information gathered, provide the final answer to the original request.
|
||||
The answer should be phrased as if you were speaking to the user.
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Optional overrides for the internal prompt templates the Magentic manager uses to plan, track progress, and
|
||||
/// synthesize the final answer. Any property left <see langword="null"/> keeps the built-in English template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Overrides are supplied to <see cref="MagenticWorkflowBuilder.WithPromptOverrides(MagenticPromptOverrides)"/>.
|
||||
/// Each template may contain named single-brace placeholders that the framework substitutes at render time
|
||||
/// (e.g. <c>{task}</c>). Unlike Python's <c>str.format</c>, literal braces (such as JSON in the progress-ledger
|
||||
/// prompt) do <b>not</b> need to be escaped - only the documented placeholders are replaced.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The available placeholders differ per prompt and are documented on each property. A placeholder that is not
|
||||
/// available for a given prompt is left untouched.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To base an override on a built-in template (for example, to translate it), read the corresponding member on
|
||||
/// <see cref="MagenticDefaultPrompts"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <c>MagenticWorkflowBuilder.WithResponseLanguage</c> is also set, its language directive is appended after the
|
||||
/// (possibly overridden) template body, so overrides and the language pin compose.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed record MagenticPromptOverrides
|
||||
{
|
||||
/// <summary>
|
||||
/// Overrides the prompt that gathers the initial fact sheet. Placeholders: <c>{task}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerFactsPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that creates the initial plan. Placeholders: <c>{team}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerPlanPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that renders the full task ledger (the plan-event text combining facts and plan).
|
||||
/// Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{facts}</c>, <c>{plan}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerFullPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that updates the fact sheet during a replan. Placeholders: <c>{task}</c>, <c>{old_facts}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerFactsUpdatePrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that updates the plan during a replan. Placeholders: <c>{team}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerPlanUpdatePrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the progress-ledger prompt. Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{questions}</c>,
|
||||
/// <c>{schema}</c>. The <c>{schema}</c> placeholder is required - the framework injects the JSON schema the
|
||||
/// response is parsed against, so omitting it would break progress-ledger parsing and next-speaker routing.
|
||||
/// </summary>
|
||||
public string? ProgressLedgerPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that synthesizes the final answer. Placeholders: <c>{task}</c>.
|
||||
/// </summary>
|
||||
public string? FinalAnswerPrompt { get; init; }
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
|
||||
string,
|
||||
@@ -33,6 +35,8 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
private int? _maxRounds;
|
||||
private int? _maxResets;
|
||||
private bool _requirePlanSignoff = true;
|
||||
private string? _responseLanguage;
|
||||
private MagenticPromptOverrides? _promptOverrides;
|
||||
|
||||
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
|
||||
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
|
||||
@@ -82,13 +86,72 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the concrete language (e.g. "English", "Chinese") that the Magentic manager's internally generated
|
||||
/// messages - the task ledger, progress ledger, and final answer - must be written in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, the manager is instructed to write all natural-language content in this exact language. This is more
|
||||
/// reliable than relying on the model to infer and match the request language, which some models fail to do for the
|
||||
/// progress ledger's JSON free-text fields, causing those internal messages to appear in an unexpected language.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When left unset (the default), the built-in English prompt templates are used as-is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If a prompt is also overridden via <see cref="WithPromptOverrides(MagenticPromptOverrides)"/>, this language
|
||||
/// directive is appended after that override's body, so the two compose.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This option is experimental and may change or be removed in a future release.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="responseLanguage">
|
||||
/// The language name to use for internally generated messages, or <see langword="null"/> to use the built-in
|
||||
/// English templates as-is.
|
||||
/// </param>
|
||||
/// <returns>This builder instance, for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public MagenticWorkflowBuilder WithResponseLanguage(string? responseLanguage = null)
|
||||
{
|
||||
this._responseLanguage = string.IsNullOrWhiteSpace(responseLanguage) ? null : responseLanguage!.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override any of the Magentic manager's internal prompt templates (task ledger, progress ledger, final answer).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Any property left <see langword="null"/> on <paramref name="promptOverrides"/> keeps the built-in English
|
||||
/// template. Templates use named single-brace placeholders (e.g. <c>{task}</c>) documented on
|
||||
/// <see cref="MagenticPromptOverrides"/>; the framework substitutes them at render time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A progress-ledger override must contain the <c>{schema}</c> placeholder (validated at <see cref="Build"/>) so
|
||||
/// the framework can inject the JSON schema the response is parsed against.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This option is experimental and may change or be removed in a future release.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="promptOverrides">The prompt overrides to apply, or <see langword="null"/> to clear any overrides.</param>
|
||||
/// <returns>This builder instance, for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public MagenticWorkflowBuilder WithPromptOverrides(MagenticPromptOverrides? promptOverrides = null)
|
||||
{
|
||||
this._promptOverrides = promptOverrides;
|
||||
return this;
|
||||
}
|
||||
|
||||
private WorkflowBuilder ReduceToWorkflowBuilder()
|
||||
{
|
||||
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
|
||||
// workflow in unexpected ways.
|
||||
List<AIAgent> team = [.. this._team];
|
||||
|
||||
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff);
|
||||
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff, this._responseLanguage, this._promptOverrides);
|
||||
WorkflowBuilder result = new(orchestrator);
|
||||
|
||||
AIAgentHostOptions options = new()
|
||||
@@ -131,6 +194,13 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
|
||||
}
|
||||
|
||||
if (this._promptOverrides?.ProgressLedgerPrompt is { } progressLedgerPrompt && !progressLedgerPrompt.Contains("{schema}"))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A progress-ledger prompt override must contain the '{schema}' placeholder so the required JSON schema can be injected; " +
|
||||
"otherwise progress-ledger parsing and next-speaker routing would break.");
|
||||
}
|
||||
|
||||
return this.ReduceToWorkflowBuilder().Build();
|
||||
}
|
||||
|
||||
@@ -139,14 +209,14 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
MaxResetCount: this._maxResets,
|
||||
MaxStallCount: this._maxStalls);
|
||||
|
||||
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff, string? responseLanguage, MagenticPromptOverrides? promptOverrides)
|
||||
{
|
||||
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
|
||||
return factory.BindExecutor(nameof(MagenticOrchestrator));
|
||||
|
||||
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
|
||||
{
|
||||
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
|
||||
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff, responseLanguage, promptOverrides));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
|
||||
+11
-3
@@ -77,7 +77,9 @@ public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger pr
|
||||
/// <param name="team"></param>
|
||||
/// <param name="limits"></param>
|
||||
/// <param name="requirePlanSignoff"></param>
|
||||
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
/// <param name="responseLanguage"></param>
|
||||
/// <param name="promptOverrides"></param>
|
||||
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff, string? responseLanguage = null, MagenticPromptOverrides? promptOverrides = null)
|
||||
: ChatProtocolExecutor(nameof(MagenticOrchestrator), s_options, declareCrossRunShareable: false)
|
||||
{
|
||||
private readonly MagenticManager _manager = new(managerAgent);
|
||||
@@ -191,7 +193,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
if (this._taskContext == null)
|
||||
{
|
||||
// First Turn: Initialize the task context and create the initial plan
|
||||
this._taskContext = new(messages, team, limits, emitEvents, []);
|
||||
this._taskContext = new(messages, team, limits, emitEvents, []) { ResponseLanguage = responseLanguage, PromptOverrides = promptOverrides };
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
@@ -384,7 +386,13 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
if (state != null)
|
||||
{
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, []);
|
||||
// ResponseLanguage and PromptOverrides are build-time configuration supplied by the builder, so they
|
||||
// are re-applied here rather than restored from the checkpoint state.
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, [])
|
||||
{
|
||||
ResponseLanguage = responseLanguage,
|
||||
PromptOverrides = promptOverrides,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,21 @@ internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgen
|
||||
|
||||
public List<ChatMessage> ChatHistory { get; internal set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional concrete language (e.g. "English", "Chinese") that the manager's internally generated messages
|
||||
/// must be written in, configured via <c>MagenticWorkflowBuilder.WithResponseLanguage</c>. When
|
||||
/// <see langword="null"/> the built-in English prompts are used as-is. This is build-time configuration
|
||||
/// (re-applied from the builder after a checkpoint restore), not runtime state.
|
||||
/// </summary>
|
||||
public string? ResponseLanguage { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional user-supplied overrides for the manager's internal prompt templates, configured via
|
||||
/// <c>MagenticWorkflowBuilder.WithPromptOverrides</c>. When <see langword="null"/> the built-in templates
|
||||
/// are used. This is build-time configuration (re-applied from the builder after a checkpoint restore).
|
||||
/// </summary>
|
||||
public MagenticPromptOverrides? PromptOverrides { get; internal set; }
|
||||
|
||||
public TaskLedger? TaskLedger { get; internal set; }
|
||||
|
||||
public TaskLimits TaskLimits => limits;
|
||||
|
||||
@@ -1,151 +1,118 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal static class PromptTemplateExtensions
|
||||
{
|
||||
// Matches a single-brace placeholder token, e.g. {task} or {old_facts}. Only {word} sequences are treated as
|
||||
// placeholders, so literal braces in a prompt (such as JSON in an override) are left untouched.
|
||||
private static readonly Regex s_placeholderPattern = new(@"\{(\w+)\}");
|
||||
|
||||
// The built-in English prompt templates live on the public MagenticDefaultPrompts class so callers can read and
|
||||
// base overrides on them. Named single-brace placeholders (e.g. {task}) are substituted at render time.
|
||||
private static string Substitute(string template, params (string Token, string Value)[] values) =>
|
||||
// Single-pass replacement over the template: substituted values are never re-scanned for further
|
||||
// placeholders, so content that happens to contain "{token}" text (e.g. in the task) is not corrupted.
|
||||
s_placeholderPattern.Replace(template, match =>
|
||||
{
|
||||
foreach ((string token, string value) in values)
|
||||
{
|
||||
if (string.Equals(token, match.Groups[1].Value, StringComparison.Ordinal))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Not one of the placeholders available for this prompt - leave the original text untouched.
|
||||
return match.Value;
|
||||
});
|
||||
|
||||
// When a concrete response language is configured via WithResponseLanguage, a directive pinning that language is
|
||||
// appended AFTER the (possibly overridden) prompt body. A concrete language name is followed far more reliably than
|
||||
// a relative "match the request" instruction, especially for the progress ledger's JSON free-text fields (#6987).
|
||||
private static string AppendLanguageDirective(string body, MagenticTaskContext taskContext) =>
|
||||
taskContext.ResponseLanguage is { Length: > 0 } language
|
||||
? $"{body}\n\n{GeneralLanguageDirective(language)}"
|
||||
: body;
|
||||
|
||||
private static string AppendProgressLedgerLanguageDirective(string body, MagenticTaskContext taskContext) =>
|
||||
taskContext.ResponseLanguage is { Length: > 0 } language
|
||||
? $"{body}\n\n{ProgressLedgerLanguageDirective(language)}"
|
||||
: body;
|
||||
|
||||
private static string GeneralLanguageDirective(string language) =>
|
||||
$"Write your entire response in {language}, including any section headings or labels. Do not use any other language.";
|
||||
|
||||
private static string ProgressLedgerLanguageDirective(string language) =>
|
||||
$"When filling in the JSON, write every \"reason\" value and the \"instruction_or_question\" answer in {language}. " +
|
||||
"Do not translate the JSON keys - they must remain exactly as shown above. The \"next_speaker\" answer must " +
|
||||
"remain exactly one of the provided team member names and must not be translated.";
|
||||
|
||||
public static string ToTaskLedgerFactsPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Below I will present you a request.
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerFactsPrompt ?? MagenticDefaultPrompts.TaskLedgerFactsPrompt,
|
||||
("task", taskContext.Task));
|
||||
|
||||
Before we begin addressing the request, please answer the following pre-survey to the best of your ability.
|
||||
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be
|
||||
a deep well to draw from.
|
||||
|
||||
Here is the request:
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
Here is the pre-survey:
|
||||
|
||||
1. Please list any specific facts or figures that are GIVEN in the request itself.It is possible that
|
||||
there are none.
|
||||
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found.
|
||||
In some cases, authoritative sources are mentioned in the request itself.
|
||||
3. Please list any facts that may need to be derived(e.g., via logical deduction, simulation, or computation)
|
||||
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
|
||||
|
||||
When answering this survey, keep in mind that "facts" will typically be specific names, dates, statistics, etc.
|
||||
Your answer should use headings:
|
||||
|
||||
1. GIVEN OR VERIFIED FACTS
|
||||
2. FACTS TO LOOK UP
|
||||
3. FACTS TO DERIVE
|
||||
4. EDUCATED GUESSES
|
||||
|
||||
DO NOT include any other headings or sections in your response.DO NOT list next steps or plans until asked to do so.
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerFactsUpdatePrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
As a reminder, we are working to solve the following task:
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerFactsUpdatePrompt ?? MagenticDefaultPrompts.TaskLedgerFactsUpdatePrompt,
|
||||
("task", taskContext.Task),
|
||||
("old_facts", taskContext.TaskLedger?.CurrentFacts.Text ?? string.Empty));
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
It is clear we are not making as much progress as we would like, but we may have learned something new.
|
||||
Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful.
|
||||
|
||||
Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts
|
||||
if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact
|
||||
sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update
|
||||
one educated guess or hunch, and explain your reasoning.
|
||||
|
||||
Here is the old fact sheet:
|
||||
|
||||
{taskContext.TaskLedger?.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerPlanPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Fantastic. To address this request we have assembled the following team:
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerPlanPrompt ?? MagenticDefaultPrompts.TaskLedgerPlanPrompt,
|
||||
("team", taskContext.TeamDescription));
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the
|
||||
original request. Remember, there is no requirement to involve all team members. A team member's particular expertise
|
||||
may not be needed for this task.
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerPlanUpdatePrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Please briefly explain what went wrong on this last run
|
||||
(the root cause of the failure), and then come up with a new plan that takes steps and includes hints to overcome prior
|
||||
challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, expressed in
|
||||
bullet-point form, and consider the following team composition:
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerPlanUpdatePrompt ?? MagenticDefaultPrompts.TaskLedgerPlanUpdatePrompt,
|
||||
("team", taskContext.TeamDescription));
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerFullPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
We are working to address the following user request:
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
|
||||
To answer this request we have assembled the following team:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
|
||||
Here is an initial fact sheet to consider:
|
||||
|
||||
{taskContext.TaskLedger!.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
|
||||
|
||||
|
||||
Here is the plan to follow as best as possible:
|
||||
|
||||
{taskContext.TaskLedger!.CurrentPlan}
|
||||
""";
|
||||
// Assembly-only prompt (emitted as the plan-event text and used as context); no language directive is appended
|
||||
// because nothing is generated from it - its facts/plan are already localized by their own generation prompts.
|
||||
return Substitute(taskContext.PromptOverrides?.TaskLedgerFullPrompt ?? MagenticDefaultPrompts.TaskLedgerFullPrompt,
|
||||
("task", taskContext.Task),
|
||||
("team", taskContext.TeamDescription),
|
||||
("facts", taskContext.TaskLedger!.CurrentFacts.Text),
|
||||
("plan", taskContext.TaskLedger!.CurrentPlan.Text));
|
||||
}
|
||||
|
||||
public static string ToProgressLedgerPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
(string questions, string schema) = taskContext.ProgressLedger.FormatQuestions();
|
||||
|
||||
return $"""
|
||||
Recall we are working on the following request:
|
||||
string body = Substitute(taskContext.PromptOverrides?.ProgressLedgerPrompt ?? MagenticDefaultPrompts.ProgressLedgerPrompt,
|
||||
("task", taskContext.Task),
|
||||
("team", taskContext.TeamDescription),
|
||||
("questions", questions),
|
||||
("schema", schema));
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
And we have assembled the following team:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
To make progress on the request, please answer the following questions, including necessary reasoning:
|
||||
|
||||
{questions}
|
||||
|
||||
Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is.
|
||||
DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA:
|
||||
|
||||
{schema}
|
||||
""";
|
||||
return AppendProgressLedgerLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToFinalAnswerPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
We are working on the following task:
|
||||
{taskContext.Task}
|
||||
string body = Substitute(taskContext.PromptOverrides?.FinalAnswerPrompt ?? MagenticDefaultPrompts.FinalAnswerPrompt,
|
||||
("task", taskContext.Task));
|
||||
|
||||
We have completed the task.
|
||||
|
||||
The above messages contain the conversation that took place to complete the task.
|
||||
|
||||
Based on the information gathered, provide the final answer to the original request.
|
||||
The answer should be phrased as if you were speaking to the user.
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,5 +75,71 @@ public class MagenticWorkflowBuilderTests
|
||||
Action build = () => builder.Build();
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*Stranger*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_WithResponseLanguage_ReturnsSameBuilderForChaining()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
MagenticWorkflowBuilder builder = new(manager);
|
||||
|
||||
// Act
|
||||
MagenticWorkflowBuilder chained = builder.WithResponseLanguage("English");
|
||||
|
||||
// Assert
|
||||
chained.Should().BeSameAs(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_WithPromptOverrides_ReturnsSameBuilderForChaining()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
MagenticWorkflowBuilder builder = new(manager);
|
||||
|
||||
// Act
|
||||
MagenticWorkflowBuilder chained = builder.WithPromptOverrides(new MagenticPromptOverrides { FinalAnswerPrompt = "custom {task}" });
|
||||
|
||||
// Assert
|
||||
chained.Should().BeSameAs(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_ProgressLedgerOverrideWithoutSchema_ThrowsOnBuild()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
TestEchoAgent worker = new(name: "Worker");
|
||||
|
||||
MagenticWorkflowBuilder builder = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(worker)
|
||||
.RequirePlanSignoff(false)
|
||||
.WithPromptOverrides(new MagenticPromptOverrides { ProgressLedgerPrompt = "Answer for {task} with no schema placeholder" });
|
||||
|
||||
// Act
|
||||
Action build = () => builder.Build();
|
||||
|
||||
// Assert
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*{schema}*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_ProgressLedgerOverrideWithSchema_BuildsSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
TestEchoAgent worker = new(name: "Worker");
|
||||
|
||||
MagenticWorkflowBuilder builder = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(worker)
|
||||
.RequirePlanSignoff(false)
|
||||
.WithPromptOverrides(new MagenticPromptOverrides { ProgressLedgerPrompt = "Answer for {task}\n{schema}" });
|
||||
|
||||
// Act
|
||||
Action build = () => builder.Build();
|
||||
|
||||
// Assert
|
||||
build.Should().NotThrow();
|
||||
}
|
||||
}
|
||||
#pragma warning restore MAAIW001
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Magentic internal prompt templates: default English rendering with placeholder substitution,
|
||||
/// the concrete-language pin from WithResponseLanguage, and user prompt overrides (issue #6987).
|
||||
/// </summary>
|
||||
public class PromptTemplatesTests
|
||||
{
|
||||
// A concrete language name is pinned by the general directive when WithResponseLanguage is set.
|
||||
private const string ConcreteLanguageMarker = "in Esperanto";
|
||||
|
||||
private const string TaskText = "UNIQUE_TASK_TEXT";
|
||||
private const string FactsText = "UNIQUE_FACTS_TEXT";
|
||||
private const string PlanText = "UNIQUE_PLAN_TEXT";
|
||||
|
||||
private static MagenticTaskContext CreateContext(string? responseLanguage = null, MagenticPromptOverrides? overrides = null)
|
||||
{
|
||||
TestEchoAgent researcher = new(name: "Researcher");
|
||||
TestEchoAgent coder = new(name: "Coder");
|
||||
|
||||
MagenticTaskContext context = new(
|
||||
[new(ChatRole.User, TaskText)],
|
||||
[researcher, coder],
|
||||
new TaskLimits(),
|
||||
emitUpdateEvents: null,
|
||||
additionalProgressQuestions: [])
|
||||
{
|
||||
ResponseLanguage = responseLanguage,
|
||||
PromptOverrides = overrides,
|
||||
};
|
||||
|
||||
// Several prompts require a non-null ledger; set one so every prompt can be rendered.
|
||||
context.TaskLedger = new(new(ChatRole.Assistant, FactsText), new(ChatRole.Assistant, PlanText));
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static string RenderProsePrompt(MagenticTaskContext context, string promptName) => promptName switch
|
||||
{
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsPrompt) => context.ToTaskLedgerFactsPrompt(),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsUpdatePrompt) => context.ToTaskLedgerFactsUpdatePrompt(),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanPrompt) => context.ToTaskLedgerPlanPrompt(),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanUpdatePrompt) => context.ToTaskLedgerPlanUpdatePrompt(),
|
||||
nameof(PromptTemplateExtensions.ToFinalAnswerPrompt) => context.ToFinalAnswerPrompt(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(promptName), promptName, "Unknown prose prompt."),
|
||||
};
|
||||
|
||||
public static TheoryData<string> ProsePromptNames() =>
|
||||
[
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsPrompt),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsUpdatePrompt),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanPrompt),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanUpdatePrompt),
|
||||
nameof(PromptTemplateExtensions.ToFinalAnswerPrompt),
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ProsePromptNames))]
|
||||
public void ProsePrompt_Default_IsEnglish_WithoutLanguageDirective(string promptName)
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string prompt = RenderProsePrompt(context, promptName);
|
||||
|
||||
// Assert - no language directive by default (built-in English prompts are used as-is).
|
||||
prompt.Should().NotContain("Write your entire response in");
|
||||
prompt.Should().NotContain("Do not use any other language");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactsPrompt_Default_UsesOriginalEnglishHeadings_AndSubstitutesTask()
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string prompt = context.ToTaskLedgerFactsPrompt();
|
||||
|
||||
// Assert - reverted to the original English template (no per-language heading instruction); task substituted.
|
||||
prompt.Should().Contain("Your answer should use headings:");
|
||||
prompt.Should().Contain("GIVEN OR VERIFIED FACTS");
|
||||
prompt.Should().Contain(TaskText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressLedgerPrompt_Default_HasSchemaContract_WithoutLanguageDirective()
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - schema/routing contract present; no language directive by default.
|
||||
prompt.Should().Contain("DO NOT OUTPUT ANYTHING OTHER THAN JSON");
|
||||
prompt.Should().Contain("next_speaker");
|
||||
prompt.Should().Contain("instruction_or_question");
|
||||
prompt.Should().NotContain("Do not translate the JSON keys");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ProsePromptNames))]
|
||||
public void ProsePrompt_WithResponseLanguage_PinsConcreteLanguage(string promptName)
|
||||
{
|
||||
// Arrange - a distinctive language token that will not collide with other prompt text.
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto");
|
||||
|
||||
// Act
|
||||
string prompt = RenderProsePrompt(context, promptName);
|
||||
|
||||
// Assert - the concrete language directive is appended after the body.
|
||||
prompt.Should().Contain("Write your entire response in Esperanto");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressLedgerPrompt_WithResponseLanguage_PinsConcreteLanguage_AndPreservesSchemaContract()
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto");
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - concrete language pinned for the free-text values...
|
||||
prompt.Should().Contain(ConcreteLanguageMarker);
|
||||
|
||||
// ...while the JSON-key/next_speaker protections and schema contract remain intact.
|
||||
prompt.Should().Contain("Do not translate the JSON keys");
|
||||
prompt.Should().Contain("must not be translated");
|
||||
prompt.Should().Contain("DO NOT OUTPUT ANYTHING OTHER THAN JSON");
|
||||
prompt.Should().Contain("next_speaker");
|
||||
prompt.Should().Contain("instruction_or_question");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullTaskLedgerPrompt_NeverAppendsLanguageDirective_AndSubstitutesFactsAndPlan()
|
||||
{
|
||||
// Arrange - even with a response language configured, the assembly-only full prompt gets no directive.
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto");
|
||||
|
||||
// Act
|
||||
string prompt = context.ToTaskLedgerFullPrompt();
|
||||
|
||||
// Assert
|
||||
prompt.Should().NotContain("Write your entire response in");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().Contain(FactsText);
|
||||
prompt.Should().Contain(PlanText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptOverride_ReplacesBody_AndSubstitutesPlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
MagenticPromptOverrides overrides = new() { TaskLedgerFactsPrompt = "CUSTOM facts request for {task}" };
|
||||
MagenticTaskContext context = CreateContext(overrides: overrides);
|
||||
|
||||
// Act
|
||||
string prompt = context.ToTaskLedgerFactsPrompt();
|
||||
|
||||
// Assert - the override body is used with placeholders substituted, and the default template is gone.
|
||||
prompt.Should().Contain("CUSTOM facts request for");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().NotContain("Ken Jennings-level");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptOverride_ComposesWith_ResponseLanguage()
|
||||
{
|
||||
// Arrange
|
||||
MagenticPromptOverrides overrides = new() { FinalAnswerPrompt = "CUSTOM final answer for {task}" };
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto", overrides: overrides);
|
||||
|
||||
// Act
|
||||
string prompt = context.ToFinalAnswerPrompt();
|
||||
|
||||
// Assert - override body + the concrete language directive appended after it.
|
||||
prompt.Should().Contain("CUSTOM final answer for");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().Contain(ConcreteLanguageMarker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressLedgerOverride_InjectsSchemaViaPlaceholder()
|
||||
{
|
||||
// Arrange
|
||||
MagenticPromptOverrides overrides = new() { ProgressLedgerPrompt = "CUSTOM ledger for {task}\n{schema}" };
|
||||
MagenticTaskContext context = CreateContext(overrides: overrides);
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - the framework injects the JSON schema (keys) into the override via {schema}.
|
||||
prompt.Should().Contain("CUSTOM ledger for");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().Contain("next_speaker");
|
||||
prompt.Should().Contain("instruction_or_question");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Substitute_DoesNotReExpandInsertedContent()
|
||||
{
|
||||
// Arrange - the task text itself contains placeholder-looking tokens that must NOT be re-substituted when
|
||||
// the later {team}/{schema} placeholders are filled (single-pass substitution).
|
||||
TestEchoAgent researcher = new(name: "Researcher");
|
||||
TestEchoAgent coder = new(name: "Coder");
|
||||
MagenticTaskContext context = new(
|
||||
[new(ChatRole.User, "Design a {schema} for the {team} data")],
|
||||
[researcher, coder],
|
||||
new TaskLimits(),
|
||||
emitUpdateEvents: null,
|
||||
additionalProgressQuestions: []);
|
||||
context.TaskLedger = new(new(ChatRole.Assistant, FactsText), new(ChatRole.Assistant, PlanText));
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - the task's literal {schema}/{team} tokens survive verbatim (not clobbered by later replacements)...
|
||||
prompt.Should().Contain("Design a {schema} for the {team} data");
|
||||
// ...while the real template placeholders were still substituted (team description + schema JSON keys).
|
||||
prompt.Should().Contain("Researcher");
|
||||
prompt.Should().Contain("next_speaker");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultPrompts_AreThePublicMagenticDefaultPrompts()
|
||||
{
|
||||
// Arrange - a default (no override) render should be built from the public MagenticDefaultPrompts template,
|
||||
// confirming MagenticDefaultPrompts is the single source of truth callers can base overrides on.
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string factsPrompt = context.ToTaskLedgerFactsPrompt();
|
||||
string finalAnswerPrompt = context.ToFinalAnswerPrompt();
|
||||
|
||||
// Assert - the rendered prompt is the public default with {task} substituted.
|
||||
factsPrompt.Should().Be(MagenticDefaultPrompts.TaskLedgerFactsPrompt.Replace("{task}", context.Task));
|
||||
finalAnswerPrompt.Should().Be(MagenticDefaultPrompts.FinalAnswerPrompt.Replace("{task}", context.Task));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MagenticDefaultPrompts_ExposeExpectedPlaceholders()
|
||||
{
|
||||
// Assert - the published defaults keep the placeholders callers rely on when tailoring an override.
|
||||
MagenticDefaultPrompts.TaskLedgerFactsPrompt.Should().Contain("{task}");
|
||||
MagenticDefaultPrompts.TaskLedgerFactsUpdatePrompt.Should().Contain("{task}").And.Contain("{old_facts}");
|
||||
MagenticDefaultPrompts.TaskLedgerPlanPrompt.Should().Contain("{team}");
|
||||
MagenticDefaultPrompts.TaskLedgerPlanUpdatePrompt.Should().Contain("{team}");
|
||||
MagenticDefaultPrompts.TaskLedgerFullPrompt.Should().Contain("{task}").And.Contain("{team}").And.Contain("{facts}").And.Contain("{plan}");
|
||||
MagenticDefaultPrompts.ProgressLedgerPrompt.Should().Contain("{task}").And.Contain("{team}").And.Contain("{questions}").And.Contain("{schema}");
|
||||
MagenticDefaultPrompts.FinalAnswerPrompt.Should().Contain("{task}");
|
||||
}
|
||||
}
|
||||
+42
-16
@@ -28,7 +28,7 @@ For release work, derive the live tier map at release time from `python/PACKAGE_
|
||||
|
||||
## Inputs to confirm before bumping
|
||||
|
||||
1. **The changeset**: explicit commits/PRs the release covers, OR derive from `git log ${LAST_RELEASED_TAG}..origin/main -- python/`.
|
||||
1. **The changeset**: explicit commits/PRs the release covers, OR derive from `git log ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/`.
|
||||
2. **Per-package CHANGELOG entries**: which packages will get a line in the new release section. This list IS the bump list.
|
||||
3. **Per-released-package semver bump**: for each released-tier package that has a CHANGELOG entry, decide PATCH / MINOR / MAJOR.
|
||||
4. **Date stamp** (only if any alpha/beta is being bumped): default from the `python-package-management`
|
||||
@@ -55,12 +55,20 @@ If the user states target versions or a date explicitly, use exactly what they s
|
||||
git fetch origin main --tags --quiet
|
||||
git fetch upstream main --tags --quiet 2>/dev/null || true
|
||||
git status
|
||||
|
||||
# Fork clones use upstream/main as the authoritative release base; direct clones use origin/main.
|
||||
if git show-ref --verify --quiet refs/remotes/upstream/main; then
|
||||
RELEASE_BASE=upstream/main
|
||||
else
|
||||
RELEASE_BASE=origin/main
|
||||
fi
|
||||
git log -1 --oneline "$RELEASE_BASE"
|
||||
```
|
||||
|
||||
If the user already has a `bump-py-ver-release-*` branch checked out, use it. Otherwise:
|
||||
|
||||
```bash
|
||||
git checkout -b bump-py-ver-release-YYMMDD origin/main
|
||||
git checkout -b bump-py-ver-release-YYMMDD "$RELEASE_BASE"
|
||||
```
|
||||
|
||||
### 2. Build the live tier map
|
||||
@@ -84,20 +92,20 @@ echo "Compare base: $LAST_RELEASED_TAG"
|
||||
List commits and packages touched:
|
||||
|
||||
```bash
|
||||
git log --oneline ${LAST_RELEASED_TAG}..origin/main -- python/ ':!python/CHANGELOG.md'
|
||||
git log --oneline ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/ ':!python/CHANGELOG.md'
|
||||
|
||||
# Per-commit package footprint
|
||||
for sha in $(git log --format='%H' ${LAST_RELEASED_TAG}..origin/main -- python/); do
|
||||
for sha in $(git log --format='%H' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/); do
|
||||
echo "--- $(git show -s --format='%h %s' $sha) ---"
|
||||
git show --name-only --format='' $sha | grep '^python/packages/' | \
|
||||
sed 's|^python/packages/||' | awk -F/ '{print $1}' | sort -u
|
||||
done
|
||||
```
|
||||
|
||||
If the release ultimately tags from `upstream/main` but `origin/main` is behind, also run:
|
||||
When both remotes exist, record whether the fork is behind the authoritative base:
|
||||
|
||||
```bash
|
||||
git log --oneline ${LAST_RELEASED_TAG}..upstream/main -- python/ ':!python/CHANGELOG.md'
|
||||
git rev-list --left-right --count origin/main...upstream/main
|
||||
```
|
||||
|
||||
If user provides an explicit commit/PR list, treat THAT as authoritative.
|
||||
@@ -108,14 +116,14 @@ Aggregate the per-commit footprint into a single union across the whole range. T
|
||||
|
||||
```bash
|
||||
# Union of all touched package directories across the range
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/packages/ \
|
||||
| grep '^python/packages/' \
|
||||
| sed 's|^python/packages/||' \
|
||||
| awk -F/ '{print $1}' \
|
||||
| sort -u
|
||||
|
||||
# Root-level files (drive a root agent-framework entry if substantive)
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} \
|
||||
-- python/pyproject.toml python/agent_framework_meta/ python/README.md \
|
||||
2>/dev/null | grep -v '^$' | sort -u
|
||||
```
|
||||
@@ -175,13 +183,13 @@ Before moving on, prove that every ship-affecting touched package has at least o
|
||||
|
||||
```bash
|
||||
# 1. Touched ship-affecting packages and root package files (from step 3a)
|
||||
TOUCHED_PACKAGES=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \
|
||||
TOUCHED_PACKAGES=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/packages/ \
|
||||
| grep '^python/packages/' \
|
||||
| sed 's|^python/packages/||' \
|
||||
| awk -F/ '{print $1}' \
|
||||
| sort -u)
|
||||
|
||||
ROOT_TOUCHED=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \
|
||||
ROOT_TOUCHED=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} \
|
||||
-- python/pyproject.toml python/agent_framework_meta/ python/README.md \
|
||||
2>/dev/null | grep -v '^$' | sort -u)
|
||||
|
||||
@@ -279,7 +287,7 @@ Spot-check with `grep '^version' python/pyproject.toml python/packages/*/pyproje
|
||||
Only relevant when `core` itself bumped this cycle. Two policies, pick one explicitly with the user:
|
||||
|
||||
- **Conservative (default)**: raise `agent-framework-core>=X.Y.Z` to the new core version on every non-core package that is ALSO bumping this cycle. Leaves packages-not-bumped at their existing floor.
|
||||
- **Strict per-upstream-doc**: only raise the floor on packages that actually consume a new core API introduced in the bump. This requires per-package code inspection. Use only when the user is comfortable letting `validate-dependency-bounds-test` (lower-resolution pass) catch any mistakes.
|
||||
- **Strict per-upstream-doc**: only raise the floor on packages that actually consume a new core API introduced in the bump. This requires per-package code inspection because release probes use the co-released local core and cannot prove compatibility with an older published core floor.
|
||||
|
||||
When raising a core floor, replace only the `>=OLD` half of the bound you intend to change:
|
||||
|
||||
@@ -294,12 +302,29 @@ If `core` did not bump this cycle, do not touch floors.
|
||||
### 7. Validate
|
||||
|
||||
```bash
|
||||
cd python && uv run poe validate-dependency-bounds-test
|
||||
cd python && uv run poe validate-python-release --base-ref "$RELEASE_BASE"
|
||||
```
|
||||
|
||||
Must exit 0. This is the safety net for selective bumping: the lower-resolution pass catches floors set too low for code that depends on new APIs, and the upper pass catches caps that exclude installable versions. If it fails, the output names the offending bound — fix and re-run before committing. This step also regenerates `uv.lock` to match new bounds.
|
||||
Use the same freshly fetched main ref that the release branch was based on (`upstream/main` above; use `origin/main`
|
||||
when that is the authoritative release base). Must exit 0. This task first regenerates `uv.lock`, then discovers the
|
||||
package `pyproject.toml` files changed from that base and runs their published runtime dependencies and
|
||||
non-development extras through lock-independent `lowest-direct` and `highest` import probes. The probes run in
|
||||
parallel, derive the minimum supported Python minor from each package's internal editable closure, and share a hard
|
||||
300-second deadline. Use `--python` only when the release requires an explicit interpreter override.
|
||||
|
||||
If only prereleases changed (no `core` bump, no floor changes), this validation is still required — `uv.lock` regeneration alone justifies the run.
|
||||
This is the release safety net for selective bumping: the lower probe catches unresolvable or unimportable external
|
||||
floors, internal constraints that reject co-released package versions, and the upper probe catches caps that exclude
|
||||
an installable package set. The JSON report records the concrete versions resolved in both scenarios. It does not
|
||||
replace the package-by-package code inspection required by the strict core-floor policy. If it fails, fix the named
|
||||
package/bound and re-run before committing.
|
||||
|
||||
Do not substitute the workspace-wide `validate-dependency-bounds-test` command here. That command runs every
|
||||
package's full tests and Pyright in separate isolated environments and is intentionally reserved for CI or an
|
||||
explicit dependency-range audit. If the release itself changes an external dependency range, also run
|
||||
`validate-dependency-bounds-project --mode both --package <pkg> --dependency <name>` for that dependency.
|
||||
|
||||
If only prereleases changed (no `core` bump, no floor changes), release validation is still required because the
|
||||
lockfile and both ends of each changed package's published dependency metadata must remain installable.
|
||||
|
||||
### 8. Commit (expect hook retry)
|
||||
|
||||
@@ -349,7 +374,7 @@ The push output includes a `Create a pull request for '<branch>' on GitHub by vi
|
||||
do not infer a local timezone from the user's current shell.
|
||||
- **`Co-Authored-By` trailer.** Never add it. Rewrite/amend if it slipped in.
|
||||
- **Stale inventory in this skill.** Always read `python/PACKAGE_STATUS.md` for the live tier map. Do not trust a hardcoded list.
|
||||
- **Divergent origin vs upstream.** If the release tags from `upstream/main` but `origin/main` is behind, check both — warn if they differ and offer to sync.
|
||||
- **Divergent origin vs upstream.** In fork clones, use freshly fetched `upstream/main` consistently for branch creation, changeset discovery, and release validation. A stale `origin/main` must never become the implicit compare base.
|
||||
- **`--pre` README cleanup on promotion.** When a package is promoted to `released` in this cycle, grep for `pip install agent-framework-<pkg> --pre` in READMEs and drop the `--pre` flag.
|
||||
- **RC counter inflation.** Do not increment `1.0.0rcN` without a CHANGELOG entry for that package. The counter tracks iterations, not calendar.
|
||||
|
||||
@@ -357,5 +382,6 @@ The push output includes a `Create a pull request for '<branch>' on GitHub by vi
|
||||
|
||||
- Package lifecycle and versioning source of truth: `python/.github/skills/python-package-management/SKILL.md`
|
||||
- Lifecycle source of truth: `python/PACKAGE_STATUS.md`
|
||||
- Validator: `python/scripts/dependencies/validate_dependency_bounds.py` (runs `lowest-direct` and `highest` resolution smoke tests; catches floors/caps that don't match the code)
|
||||
- Release validator: `python/scripts/dependencies/validate_dependency_bounds.py --mode release` (changed-package,
|
||||
lock-independent `lowest-direct` and `highest` import probes under a five-minute deadline)
|
||||
- Poe task definitions: `python/pyproject.toml` `[tool.poe.tasks]`
|
||||
|
||||
+16
-3
@@ -45,9 +45,13 @@ uv lock --upgrade-package <dependency-name> && uv run poe install
|
||||
# Refresh exact development dependency-group pins, lockfile, and validation in one run
|
||||
uv run poe upgrade-dev-dependencies
|
||||
|
||||
# First, run workspace-wide lower/upper compatibility gates
|
||||
# Release cuts: refresh uv.lock and probe changed packages at both bound extremes.
|
||||
# The release probe has a shared five-minute deadline.
|
||||
uv run poe validate-python-release --base-ref upstream/main
|
||||
|
||||
# Exhaustive test+typing matrix (slow; use for deliberate dependency-range work or CI)
|
||||
uv run poe validate-dependency-bounds-test
|
||||
# Defaults to --package "*"; pass a package to scope test mode
|
||||
# Defaults to --package "*"; scope locally whenever possible.
|
||||
uv run poe validate-dependency-bounds-test --package core
|
||||
|
||||
# Then expand bounds for one dependency in the target package
|
||||
@@ -66,7 +70,16 @@ uv run poe add-dependency-and-validate-bounds --package core --dependency "<depe
|
||||
- Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds with an explicit upper cap (avoid open-ended ranges).
|
||||
- For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may be a patch line, a minor line, or multiple minor lines when checks/tests show the broader lane is compatible.
|
||||
- Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths.
|
||||
- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
|
||||
- For release-only version, lifecycle, pin, and internal-floor edits, use `validate-python-release`. It refreshes
|
||||
`uv.lock`, finds changed package metadata relative to the selected main ref, and runs the changed packages'
|
||||
published runtime dependencies and non-development extras through lock-independent `lowest-direct` and `highest`
|
||||
import probes on the minimum Python minor supported by each package's internal editable closure. The probes run
|
||||
concurrently under one 300-second deadline; pass `--python` only when an explicit interpreter override is needed.
|
||||
- For deliberate external dependency-range changes, use
|
||||
`validate-dependency-bounds-project --mode both` for the target package/dependency to find and validate the actual
|
||||
minimum and maximum constraints. Scope the exhaustive `validate-dependency-bounds-test` matrix to affected
|
||||
packages during local iteration; reserve the workspace-wide form for CI or an intentional full audit. The same
|
||||
project task can drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
|
||||
- Prefer targeted lock updates with `uv lock --upgrade-package <dependency-name>` to reduce `uv.lock` merge conflicts.
|
||||
- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command.
|
||||
- Keep shared tooling and source/type-check support in the root or package `dev` group. Put package-specific test
|
||||
|
||||
+8
-1
@@ -7,10 +7,16 @@ description: >
|
||||
|
||||
# Python Testing
|
||||
|
||||
We strive for at least 85% test coverage across the codebase, with a focus on core packages and critical paths. Tests should be fast, reliable, and maintainable.
|
||||
CI enforces at least 85% line coverage for every package classified Beta or Production/Stable.
|
||||
Alpha packages are report-only, and the DevUI and experimental Lab packages are excluded from
|
||||
aggregate coverage enforcement. Tests should be fast, reliable, and maintainable.
|
||||
When adding new code, check that the relevant sections of the codebase are covered by tests, and add new tests as needed. When modifying existing code, update or add tests to cover the changes.
|
||||
We run tests in two stages, for a PR each commit is tested with unit tests only (using `-m "not integration"`), and the full suite including integration tests is run when merging.
|
||||
|
||||
When an API is marked as deprecated, migrate ordinary tests to its replacement in the same change. Retain only
|
||||
focused tests that validate the deprecated behavior and warning; integration tests, samples, and unrelated unit
|
||||
tests should use the supported API.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
@@ -82,6 +88,7 @@ packages/core/
|
||||
## File Naming
|
||||
|
||||
- Files starting with `test_` are test files — do not use this prefix for helpers
|
||||
- Prefer extending an existing test file that already covers the same component or behavior; create a new file only for a distinct surface without an appropriate existing file
|
||||
- Use `conftest.py` for shared utilities
|
||||
|
||||
## Integration Tests
|
||||
|
||||
+31
-1
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [github-copilot-1.0.0] - 2026-07-23
|
||||
|
||||
### Added
|
||||
- **agent-framework-github-copilot**: Forward input attachments (images, documents, and other inline binary content) to GitHub Copilot as inline blobs ([#7300](https://github.com/microsoft/agent-framework/pull/7300))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-github-copilot**: Promote the package from release candidate to stable
|
||||
|
||||
## [hosting-a2a-1.0.0a260723] - 2026-07-23
|
||||
|
||||
### Added
|
||||
- **agent-framework-hosting-a2a**: Add progressive agent and workflow A2A adapters with native card generation, skill discovery, typed conversion, and mode-aware validation ([#7258](https://github.com/microsoft/agent-framework/pull/7258))
|
||||
|
||||
### Changed
|
||||
- **samples**: Update the app-owned A2A hosting sample to use the progressive adapter surface ([#7258](https://github.com/microsoft/agent-framework/pull/7258))
|
||||
|
||||
## [1.12.1] - 2026-07-22
|
||||
|
||||
### Added
|
||||
- **agent-framework-openai**: Add explicit prompt cache breakpoints for GPT-5.6 models and a usage sample ([#7163](https://github.com/microsoft/agent-framework/pull/7163))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-ag-ui**: Promote the package from release candidate to stable
|
||||
- **agent-framework-core**: Add security guidance for custom MCP Streamable HTTP clients ([#7245](https://github.com/microsoft/agent-framework/pull/7245))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-gemini**: Preserve Gemini 3 thought signatures across function-call replays ([#7095](https://github.com/microsoft/agent-framework/pull/7095))
|
||||
- **agent-framework-core**, **agent-framework-foundry**, **agent-framework-foundry-hosting**, **agent-framework-openai**: Fix stateless replay of reasoning-paired tool calls ([#7233](https://github.com/microsoft/agent-framework/pull/7233))
|
||||
|
||||
## [1.12.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
@@ -1398,7 +1427,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.12.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.12.1...HEAD
|
||||
[1.12.1]: https://github.com/microsoft/agent-framework/compare/python-1.12.0...python-1.12.1
|
||||
[1.12.0]: https://github.com/microsoft/agent-framework/compare/python-1.11.0...python-1.12.0
|
||||
[1.11.0]: https://github.com/microsoft/agent-framework/compare/python-1.10.0...python-1.11.0
|
||||
[1.10.0]: https://github.com/microsoft/agent-framework/compare/python-1.9.0...python-1.10.0
|
||||
|
||||
@@ -157,6 +157,12 @@ uv run poe --directory packages/core test
|
||||
|
||||
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The aggregate `test -A` sweep also uses `pytest-xdist` across the selected packages.
|
||||
|
||||
### Testing deprecations
|
||||
|
||||
When an API is marked as deprecated, update the test suite to use its replacement at the same time. Keep only
|
||||
focused tests that validate the deprecated API and its warning; ordinary behavior, integration, and sample tests
|
||||
should exercise the supported API so deprecation warnings do not accumulate in test runs.
|
||||
|
||||
## Code quality checks
|
||||
|
||||
To run the same checks that run during a commit and the GitHub Action `Python Code Quality`, you can use this command, from the [python](../python) folder:
|
||||
@@ -178,6 +184,10 @@ uv run poe test -A -C
|
||||
|
||||
This will show you which files are not covered by the tests, including the specific lines not covered. Make sure to consider the untested lines from the code you are working on, but feel free to add other tests as well, that is always welcome!
|
||||
|
||||
CI automatically enforces at least 85% line coverage for every package classified Beta or
|
||||
Production/Stable. Alpha packages are reported without blocking, and the DevUI and experimental Lab
|
||||
packages are excluded from aggregate coverage enforcement.
|
||||
|
||||
## Catching up with the latest changes
|
||||
|
||||
There are many people committing to Agent Framework, so it is important to keep your local repository up to date. To do this, you can run the following commands:
|
||||
|
||||
@@ -16,7 +16,7 @@ Status is grouped into these buckets:
|
||||
| --- | --- | --- |
|
||||
| `agent-framework` | `python/` | `released` |
|
||||
| `agent-framework-a2a` | `python/packages/a2a` | `beta` |
|
||||
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `rc` |
|
||||
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `released` |
|
||||
| `agent-framework-anthropic` | `python/packages/anthropic` | `beta` |
|
||||
| `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `beta` |
|
||||
| `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` |
|
||||
@@ -35,7 +35,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-foundry-hosting` | `python/packages/foundry_hosting` | `beta` |
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `beta` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `released` |
|
||||
| `agent-framework-hosting` | `python/packages/hosting` | `alpha` |
|
||||
| `agent-framework-hosting-a2a` | `python/packages/hosting-a2a` | `alpha` |
|
||||
| `agent-framework-hosting-mcp` | `python/packages/hosting-mcp` | `alpha` |
|
||||
|
||||
@@ -335,7 +335,10 @@ A frontend can then hydrate the latest stored snapshot for the scoped thread:
|
||||
|
||||
Endpoint configuration requires `snapshot_scope_resolver` whenever a snapshot store is configured, including when
|
||||
the store is already set on a pre-wrapped `AgentFrameworkAgent` or `AgentFrameworkWorkflow`. The resolver returns
|
||||
the application-defined Snapshot Scope used with the AG-UI Thread id as the storage key.
|
||||
the application-defined Snapshot Scope used with the AG-UI Thread id as the storage key. When using
|
||||
`AgentFrameworkWorkflow(workflow_factory=...)`, the same resolver also scopes the in-memory workflow cache even
|
||||
without a snapshot store; provide it in multi-user deployments so two users who submit the same `threadId` do not
|
||||
share a live `Workflow` instance.
|
||||
|
||||
For hosted agents, request Shared State is also available through `AgentSession.state` during that run, whether or
|
||||
not snapshot persistence is configured. Request values are untrusted per-run context: they overlay ordinary restored
|
||||
|
||||
@@ -111,7 +111,8 @@ def add_agent_framework_fastapi_endpoint(
|
||||
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence is opt-in and requires an
|
||||
explicit Snapshot Scope resolver.
|
||||
snapshot_scope_resolver: Optional resolver for the application-defined Snapshot Scope. Required whenever
|
||||
a snapshot store is configured because an AG-UI Thread id is not an authorization boundary.
|
||||
a snapshot store is configured because an AG-UI Thread id is not an authorization boundary. Also scopes
|
||||
in-memory workflow_factory instances when provided without a snapshot store.
|
||||
keepalive_seconds: Endpoint SSE keepalive interval in seconds. Defaults to 15. Positive values emit fixed
|
||||
SSE comments while the stream is open. None disables keepalive and preserves the non-keepalive response
|
||||
path. Keepalive comments are transport traffic and do not change AG-UI events.
|
||||
@@ -150,15 +151,13 @@ def add_agent_framework_fastapi_endpoint(
|
||||
"""
|
||||
try:
|
||||
input_data = request_body.model_dump(exclude_none=True)
|
||||
snapshot_persistence_active = False
|
||||
snapshot_persistence_active = _get_snapshot_store(protocol_runner) is not None
|
||||
if snapshot_scope_resolver is not None:
|
||||
snapshot_scope = snapshot_scope_resolver(request_body)
|
||||
if isawaitable(snapshot_scope):
|
||||
snapshot_scope = await snapshot_scope
|
||||
input_data[_APPROVAL_SCOPE_INPUT_KEY] = snapshot_scope
|
||||
if _get_snapshot_store(protocol_runner) is not None:
|
||||
input_data[_SNAPSHOT_SCOPE_INPUT_KEY] = snapshot_scope
|
||||
snapshot_persistence_active = True
|
||||
input_data[_SNAPSHOT_SCOPE_INPUT_KEY] = snapshot_scope
|
||||
if default_state:
|
||||
if snapshot_persistence_active:
|
||||
# Defer default application to the runner so defaults only fill keys
|
||||
|
||||
@@ -248,8 +248,9 @@ class AgentFrameworkWorkflow:
|
||||
self.workflow = workflow
|
||||
self._workflow_factory = workflow_factory
|
||||
# Cache keyed by (snapshot_scope, thread_id): the Snapshot Scope is the
|
||||
# authorization boundary, so the same thread id under different scopes
|
||||
# must never share an in-memory workflow instance.
|
||||
# authorization boundary for both snapshots and in-memory workflow_factory
|
||||
# instances, so the same thread id under different scopes must never share
|
||||
# mutable workflow state.
|
||||
self._workflow_by_thread: dict[tuple[str | None, str], Workflow] = {}
|
||||
self.name = name if name is not None else getattr(workflow, "name", "workflow")
|
||||
self.description = description if description is not None else getattr(workflow, "description", "")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0rc9"
|
||||
version = "1.0.0"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
|
||||
@@ -40,6 +40,7 @@ from fastapi.params import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent_framework_ag_ui import (
|
||||
AGUIRequest,
|
||||
AGUIThreadSnapshot,
|
||||
InMemoryAGUIThreadSnapshotStore,
|
||||
add_agent_framework_fastapi_endpoint,
|
||||
@@ -3151,7 +3152,7 @@ async def test_endpoint_error_handling(build_chat_client):
|
||||
client = TestClient(app)
|
||||
|
||||
# Send invalid JSON to trigger parsing error before streaming
|
||||
response = client.post("/failing", data=b"invalid json", headers={"content-type": "application/json"}) # type: ignore
|
||||
response = client.post("/failing", content=b"invalid json", headers={"content-type": "application/json"})
|
||||
|
||||
# Pydantic validation now returns 422 for invalid request body
|
||||
assert response.status_code == 422
|
||||
@@ -5160,3 +5161,75 @@ def test_workflow_factory_cache_is_scoped_by_snapshot_scope():
|
||||
|
||||
runner.clear_thread_workflow("thread-1")
|
||||
assert runner._resolve_workflow("thread-1", "tenant-b") is not workflow_b
|
||||
|
||||
|
||||
async def test_workflow_factory_cache_is_scoped_by_resolver_without_snapshot_store():
|
||||
"""Snapshot Scope resolver scopes live workflow_factory instances even without snapshot persistence."""
|
||||
|
||||
@executor(id="responder")
|
||||
async def responder(message: Any, ctx: WorkflowContext[Any, Any]) -> None:
|
||||
del message
|
||||
await ctx.yield_output("Workflow response")
|
||||
|
||||
created_workflows: list[Any] = []
|
||||
|
||||
def factory(thread_id: str) -> Any:
|
||||
del thread_id
|
||||
workflow = WorkflowBuilder(start_executor=responder).build()
|
||||
created_workflows.append(workflow)
|
||||
return workflow
|
||||
|
||||
def resolve_scope(request: AGUIRequest) -> str:
|
||||
forwarded_props = request.forwarded_props
|
||||
assert forwarded_props is not None
|
||||
tenant = forwarded_props["tenant"]
|
||||
assert isinstance(tenant, str)
|
||||
return tenant
|
||||
|
||||
app = FastAPI()
|
||||
runner = AgentFrameworkWorkflow(workflow_factory=factory)
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app,
|
||||
runner,
|
||||
path="/workflow",
|
||||
snapshot_scope_resolver=resolve_scope,
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response_a = client.post(
|
||||
"/workflow",
|
||||
json={
|
||||
"thread_id": "thread-1",
|
||||
"messages": [{"role": "user", "content": "Hello tenant A"}],
|
||||
"forwardedProps": {"tenant": "tenant-a"},
|
||||
},
|
||||
)
|
||||
response_b = client.post(
|
||||
"/workflow",
|
||||
json={
|
||||
"thread_id": "thread-1",
|
||||
"messages": [{"role": "user", "content": "Hello tenant B"}],
|
||||
"forwardedProps": {"tenant": "tenant-b"},
|
||||
},
|
||||
)
|
||||
response_a_again = client.post(
|
||||
"/workflow",
|
||||
json={
|
||||
"thread_id": "thread-1",
|
||||
"messages": [{"role": "user", "content": "Hello tenant A again"}],
|
||||
"forwardedProps": {"tenant": "tenant-a"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response_a.status_code == 200
|
||||
assert response_b.status_code == 200
|
||||
assert response_a_again.status_code == 200
|
||||
assert len(created_workflows) == 2
|
||||
assert (
|
||||
runner._resolve_workflow("thread-1", "tenant-a") # pyright: ignore[reportPrivateUsage]
|
||||
is created_workflows[0]
|
||||
)
|
||||
assert (
|
||||
runner._resolve_workflow("thread-1", "tenant-b") # pyright: ignore[reportPrivateUsage]
|
||||
is created_workflows[1]
|
||||
)
|
||||
|
||||
@@ -115,6 +115,18 @@ class CapturingRunnerContext(RunnerContext):
|
||||
"""Checkpointing not supported in activity context."""
|
||||
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: str | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
"""Checkpointing not supported in activity context."""
|
||||
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
"""Checkpointing not supported in activity context."""
|
||||
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
|
||||
|
||||
@@ -160,3 +160,36 @@ class TestCapturingRunnerContext:
|
||||
"""Test that apply_checkpoint raises NotImplementedError."""
|
||||
with pytest.raises(NotImplementedError):
|
||||
await context.apply_checkpoint(Mock())
|
||||
|
||||
def test_checkpoint_storage_noops_are_safe(self, context: CapturingRunnerContext) -> None:
|
||||
"""Unsupported checkpoint-storage hooks remain harmless no-ops."""
|
||||
storage = Mock()
|
||||
|
||||
context.set_runtime_checkpoint_storage(storage)
|
||||
context.clear_runtime_checkpoint_storage()
|
||||
|
||||
assert context.has_checkpointing() is False
|
||||
|
||||
def test_yield_output_classifier_can_be_overridden(self, context: CapturingRunnerContext) -> None:
|
||||
"""Custom yield-output classification is delegated to the configured classifier."""
|
||||
context.set_yield_output_classifier(lambda executor_id: None if executor_id == "secret" else "output")
|
||||
|
||||
assert context.classify_yielded_output("secret") is None
|
||||
assert context.classify_yielded_output("visible") == "output"
|
||||
|
||||
async def test_add_request_info_event_tracks_pending_requests(self, context: CapturingRunnerContext) -> None:
|
||||
"""Request-info events are both queued and retained for later correlation."""
|
||||
event = WorkflowEvent("request_info", executor_id="reviewer", data={"question": "approve?"}, request_id="req-1")
|
||||
|
||||
await context.add_request_info_event(event)
|
||||
|
||||
pending = await context.get_pending_request_info_events()
|
||||
queued = await context.drain_events()
|
||||
|
||||
assert pending == {"req-1": event}
|
||||
assert queued == [event]
|
||||
|
||||
async def test_send_request_info_response_raises_not_implemented(self, context: CapturingRunnerContext) -> None:
|
||||
"""Activity contexts cannot resolve HITL responses directly."""
|
||||
with pytest.raises(NotImplementedError, match="orchestrator level"):
|
||||
await context.send_request_info_response("req-1", {"approved": True})
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for the Azure Functions workflow-context adapter."""
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_azurefunctions._workflow import run_workflow_orchestrator
|
||||
from agent_framework_azurefunctions._workflow_af_context import AzureFunctionsWorkflowContext
|
||||
|
||||
|
||||
class _FakeDurableAIAgent:
|
||||
def __init__(self, executor: Any, name: str) -> None:
|
||||
self.executor = executor
|
||||
self.name = name
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
|
||||
def run(self, message: str, *, session: Any) -> dict[str, Any]:
|
||||
self.calls.append((message, session))
|
||||
return {"message": message, "session": session, "executor": self.executor, "name": self.name}
|
||||
|
||||
|
||||
class TestAzureFunctionsWorkflowContext:
|
||||
"""Behavior of the Azure Functions orchestration-context adapter."""
|
||||
|
||||
@pytest.fixture
|
||||
def orchestration_context(self) -> Mock:
|
||||
context = Mock()
|
||||
context.instance_id = "instance-123"
|
||||
context.is_replaying = True
|
||||
context.current_utc_datetime = datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
||||
context.call_activity.return_value = "activity-task"
|
||||
context.call_sub_orchestrator.return_value = "sub-task"
|
||||
context.task_all.return_value = "all-task"
|
||||
context.task_any.return_value = "any-task"
|
||||
context.wait_for_external_event.return_value = "event-task"
|
||||
context.create_timer.return_value = "timer-task"
|
||||
context.new_uuid.return_value = "uuid-123"
|
||||
return context
|
||||
|
||||
def test_exposes_basic_context_properties(self, orchestration_context: Mock) -> None:
|
||||
workflow_context = AzureFunctionsWorkflowContext(orchestration_context)
|
||||
|
||||
assert workflow_context.instance_id == "instance-123"
|
||||
assert workflow_context.is_replaying is True
|
||||
assert workflow_context.supports_event_streaming is False
|
||||
assert workflow_context.current_utc_datetime == orchestration_context.current_utc_datetime
|
||||
|
||||
def test_prepare_agent_task_wraps_session_and_executor(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
orchestration_context: Mock,
|
||||
) -> None:
|
||||
executor_sentinel = object()
|
||||
monkeypatch.setattr(
|
||||
"agent_framework_azurefunctions._workflow_af_context.AzureFunctionsAgentExecutor",
|
||||
lambda context: executor_sentinel if context is orchestration_context else None,
|
||||
)
|
||||
monkeypatch.setattr("agent_framework_azurefunctions._workflow_af_context.DurableAIAgent", _FakeDurableAIAgent)
|
||||
|
||||
workflow_context = AzureFunctionsWorkflowContext(orchestration_context)
|
||||
result = workflow_context.prepare_agent_task("reviewer", "please approve", "orch-9")
|
||||
|
||||
assert result["message"] == "please approve"
|
||||
assert result["executor"] is executor_sentinel
|
||||
assert result["name"] == "reviewer"
|
||||
assert result["session"].durable_session_id.name == "reviewer"
|
||||
assert result["session"].durable_session_id.key == "orch-9"
|
||||
|
||||
def test_delegates_activity_and_orchestrator_primitives(self, orchestration_context: Mock) -> None:
|
||||
workflow_context = AzureFunctionsWorkflowContext(orchestration_context)
|
||||
|
||||
assert workflow_context.prepare_activity_task("activity-name", '{"payload": 1}') == "activity-task"
|
||||
orchestration_context.call_activity.assert_called_once_with("activity-name", '{"payload": 1}')
|
||||
|
||||
assert workflow_context.call_sub_orchestrator("child", {"x": 1}, instance_id="child-1") == "sub-task"
|
||||
orchestration_context.call_sub_orchestrator.assert_called_once_with(
|
||||
"child", input_={"x": 1}, instance_id="child-1"
|
||||
)
|
||||
|
||||
assert workflow_context.task_all(["a", "b"]) == "all-task"
|
||||
orchestration_context.task_all.assert_called_once_with(["a", "b"])
|
||||
|
||||
assert workflow_context.task_any(["a", "b"]) == "any-task"
|
||||
orchestration_context.task_any.assert_called_once_with(["a", "b"])
|
||||
|
||||
assert workflow_context.wait_for_external_event("approval") == "event-task"
|
||||
orchestration_context.wait_for_external_event.assert_called_once_with("approval")
|
||||
|
||||
assert workflow_context.create_timer(orchestration_context.current_utc_datetime) == "timer-task"
|
||||
orchestration_context.create_timer.assert_called_once_with(orchestration_context.current_utc_datetime)
|
||||
|
||||
def test_status_uuid_and_task_helpers_delegate(self, orchestration_context: Mock) -> None:
|
||||
workflow_context = AzureFunctionsWorkflowContext(orchestration_context)
|
||||
|
||||
workflow_context.set_custom_status({"state": "running"})
|
||||
orchestration_context.set_custom_status.assert_called_once_with({"state": "running"})
|
||||
assert workflow_context.new_uuid() == "uuid-123"
|
||||
|
||||
cancellable = Mock()
|
||||
workflow_context.cancel_task(cancellable)
|
||||
cancellable.cancel.assert_called_once_with()
|
||||
|
||||
non_cancellable = object()
|
||||
workflow_context.cancel_task(non_cancellable)
|
||||
|
||||
done_task = Mock()
|
||||
done_task.result = {"answer": 42}
|
||||
assert workflow_context.get_task_result(done_task) == {"answer": 42}
|
||||
assert workflow_context.get_task_result(object()) is None
|
||||
|
||||
|
||||
def test_run_workflow_orchestrator_wraps_context(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The Azure Functions wrapper delegates to the shared durabletask orchestrator."""
|
||||
|
||||
def _shared_runner(context: Any, workflow: Any, initial_message: Any, shared_state: dict[str, Any] | None) -> Any:
|
||||
return context, workflow, initial_message, shared_state
|
||||
|
||||
monkeypatch.setattr("agent_framework_azurefunctions._workflow._run_workflow_orchestrator_shared", _shared_runner)
|
||||
|
||||
df_context = Mock()
|
||||
workflow = Mock()
|
||||
|
||||
wrapped_context, passed_workflow, passed_message, passed_state = run_workflow_orchestrator(
|
||||
df_context,
|
||||
workflow,
|
||||
"hello",
|
||||
{"x": 1},
|
||||
)
|
||||
|
||||
assert isinstance(wrapped_context, AzureFunctionsWorkflowContext)
|
||||
assert wrapped_context.instance_id == df_context.instance_id
|
||||
assert passed_workflow is workflow
|
||||
assert passed_message == "hello"
|
||||
assert passed_state == {"x": 1}
|
||||
@@ -3,12 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from collections import deque
|
||||
from collections.abc import MutableMapping
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, Content, Message
|
||||
from agent_framework import Agent, Content, FunctionTool, Message
|
||||
from agent_framework._settings import SecretString
|
||||
from boto3.session import Session as Boto3Session
|
||||
from botocore.client import BaseClient
|
||||
|
||||
from agent_framework_bedrock import BedrockChatClient
|
||||
from agent_framework_bedrock._chat_client import BedrockSettings
|
||||
|
||||
|
||||
class _StubBedrockRuntime:
|
||||
@@ -234,3 +241,257 @@ def test_parse_usage_returns_none_when_no_recognized_keys() -> None:
|
||||
assert client._parse_usage({"unexpected": 1}) is None
|
||||
assert client._parse_usage({}) is None
|
||||
assert client._parse_usage(None) is None
|
||||
|
||||
|
||||
def test_init_uses_boto3_session_when_runtime_client_not_supplied() -> None:
|
||||
"""BedrockChatClient should build a runtime client from a provided boto3 session."""
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.region_name: str | None = None
|
||||
|
||||
def client(self, service_name: str, *, region_name: str, config: Any) -> _StubBedrockRuntime:
|
||||
self.calls.append({"service_name": service_name, "region_name": region_name, "config": config})
|
||||
return _StubBedrockRuntime()
|
||||
|
||||
session = _FakeSession()
|
||||
|
||||
client = BedrockChatClient(
|
||||
model="amazon.titan-text",
|
||||
region="us-west-2",
|
||||
boto3_session=cast(Boto3Session, session),
|
||||
)
|
||||
|
||||
assert isinstance(client._bedrock_client, _StubBedrockRuntime)
|
||||
assert session.calls == [
|
||||
{
|
||||
"service_name": "bedrock-runtime",
|
||||
"region_name": "us-west-2",
|
||||
"config": session.calls[0]["config"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_create_session_uses_secret_values() -> None:
|
||||
"""Bedrock session creation should unwrap configured secret values."""
|
||||
settings: BedrockSettings = {
|
||||
"region": "eu-west-1",
|
||||
"access_key": SecretString("access"),
|
||||
"secret_key": SecretString("secret"),
|
||||
"session_token": SecretString("token"),
|
||||
}
|
||||
|
||||
with patch("agent_framework_bedrock._chat_client.Boto3Session", return_value=MagicMock()) as session_cls:
|
||||
BedrockChatClient._create_session(settings)
|
||||
|
||||
session_cls.assert_called_once_with(
|
||||
region_name="eu-west-1",
|
||||
aws_access_key_id="access",
|
||||
aws_secret_access_key="secret",
|
||||
aws_session_token="token",
|
||||
)
|
||||
|
||||
|
||||
def test_invoke_converse_requires_mapping_response() -> None:
|
||||
"""Non-mapping Bedrock responses should be rejected."""
|
||||
|
||||
class _BadRuntime:
|
||||
def converse(self, **_: Any) -> list[str]:
|
||||
return ["not", "a", "mapping"]
|
||||
|
||||
from agent_framework.exceptions import ChatClientInvalidResponseException
|
||||
|
||||
client = BedrockChatClient(
|
||||
model="amazon.titan-text",
|
||||
region="us-west-2",
|
||||
client=cast(BaseClient, _BadRuntime()),
|
||||
)
|
||||
|
||||
with pytest.raises(ChatClientInvalidResponseException, match="must be a mapping"):
|
||||
client._invoke_converse({"modelId": "amazon.titan-text"})
|
||||
|
||||
|
||||
def test_prepare_options_requires_model_when_unset() -> None:
|
||||
"""Preparing options without a configured model should raise."""
|
||||
client = _make_client()
|
||||
client.model = None # type: ignore[assignment]
|
||||
|
||||
with pytest.raises(ValueError, match="Bedrock model is required"):
|
||||
client._prepare_options([Message(role="user", contents=[Content.from_text(text="hello")])], {})
|
||||
|
||||
|
||||
def test_prepare_options_adds_instructions_and_sampling_settings() -> None:
|
||||
"""Instructions and inference settings should be translated into Bedrock request fields."""
|
||||
client = _make_client()
|
||||
messages = [
|
||||
Message(role="system", contents=[Content.from_text(text="Original system prompt")]),
|
||||
Message(role="user", contents=[Content.from_text(text="hello")]),
|
||||
]
|
||||
|
||||
request = client._prepare_options(
|
||||
messages,
|
||||
{
|
||||
"instructions": "Runtime instructions",
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9,
|
||||
"stop": ["DONE"],
|
||||
"max_tokens": 5,
|
||||
},
|
||||
)
|
||||
|
||||
assert request["system"] == [{"text": "Runtime instructions"}, {"text": "Original system prompt"}]
|
||||
assert request["inferenceConfig"] == {
|
||||
"maxTokens": 5,
|
||||
"temperature": 0.2,
|
||||
"topP": 0.9,
|
||||
"stopSequences": ["DONE"],
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_options_unsupported_tool_mode_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Unexpected tool modes should raise a clear error."""
|
||||
from agent_framework_bedrock import _chat_client as chat_client_module
|
||||
|
||||
client = _make_client()
|
||||
monkeypatch.setattr(chat_client_module, "validate_tool_mode", lambda _: {"mode": "unexpected"})
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported tool mode for Bedrock: unexpected"):
|
||||
client._prepare_options(
|
||||
[Message(role="user", contents=[Content.from_text(text="hello")])],
|
||||
{"tool_choice": "auto"},
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_bedrock_messages_skips_unsupported_content_and_unmatched_tool_results() -> None:
|
||||
"""Unsupported user content and orphaned tool results should be dropped."""
|
||||
client = _make_client()
|
||||
messages = [
|
||||
Message(role="user", contents=[Content.from_data(data=b"x", media_type="application/octet-stream")]),
|
||||
Message(role="tool", contents=[Content.from_function_result(call_id="call-1", result={"answer": 42})]),
|
||||
Message(role="user", contents=[Content.from_text(text="hello")]),
|
||||
]
|
||||
|
||||
prompts, conversation = client._prepare_bedrock_messages(messages)
|
||||
|
||||
assert prompts == []
|
||||
assert conversation == [{"role": "user", "content": [{"text": "hello"}]}]
|
||||
|
||||
|
||||
def test_align_tool_results_handles_pending_edge_cases() -> None:
|
||||
"""Tool result alignment should preserve valid blocks and drop invalid or extra results."""
|
||||
client = _make_client()
|
||||
mixed_blocks = cast(
|
||||
list[dict[str, Any]],
|
||||
[
|
||||
"keep-me",
|
||||
{"text": "note"},
|
||||
{"toolResult": {"content": []}},
|
||||
{"toolResult": {"content": []}},
|
||||
],
|
||||
)
|
||||
|
||||
aligned = client._align_tool_results_with_pending(
|
||||
mixed_blocks,
|
||||
deque(["call-1"]),
|
||||
)
|
||||
unmatched = client._align_tool_results_with_pending(
|
||||
[{"toolResult": {"toolUseId": "other", "content": []}}],
|
||||
deque(["call-1"]),
|
||||
)
|
||||
|
||||
assert aligned[0] == "keep-me"
|
||||
assert aligned[1] == {"text": "note"}
|
||||
assert aligned[2]["toolResult"]["toolUseId"] == "call-1"
|
||||
assert len(aligned) == 3
|
||||
assert unmatched == []
|
||||
|
||||
|
||||
def test_convert_content_to_bedrock_block_handles_errors_and_missing_items() -> None:
|
||||
"""Function result conversion should serialize items, rich content warnings, and fallback results."""
|
||||
client = _make_client()
|
||||
rich_result = Content.from_function_result(
|
||||
call_id="call-1",
|
||||
result=[Content.from_text(text="summary"), Content.from_data(data=b"x", media_type="image/png")],
|
||||
exception="tool failed",
|
||||
)
|
||||
fallback_result = Content.from_function_result(call_id="call-2", result={"answer": 42})
|
||||
fallback_result.items = None
|
||||
|
||||
rich_block = client._convert_content_to_bedrock_block(rich_result)
|
||||
fallback_block = client._convert_content_to_bedrock_block(fallback_result)
|
||||
|
||||
assert rich_block == {
|
||||
"toolResult": {
|
||||
"toolUseId": "call-1",
|
||||
"content": [{"text": "summary"}, {"text": "tool failed"}],
|
||||
"status": "error",
|
||||
}
|
||||
}
|
||||
assert fallback_block == {
|
||||
"toolResult": {
|
||||
"toolUseId": "call-2",
|
||||
"content": [{"json": {"answer": 42}}],
|
||||
"status": "success",
|
||||
}
|
||||
}
|
||||
assert client._convert_content_to_bedrock_block(Content.from_data(data=b"x", media_type="text/plain")) is None
|
||||
|
||||
|
||||
def test_tool_result_helpers_cover_text_json_and_sequence_values() -> None:
|
||||
"""Tool result helpers should normalize text, JSON, sequences, and custom objects."""
|
||||
client = _make_client()
|
||||
|
||||
class _Serializable:
|
||||
def to_dict(self) -> dict[str, int]:
|
||||
return {"value": 1}
|
||||
|
||||
assert client._convert_tool_result_to_blocks("plain text") == [{"text": "plain text"}]
|
||||
assert client._convert_prepared_tool_result_to_blocks([{"answer": 1}, "done"]) == [
|
||||
{"json": {"answer": 1}},
|
||||
{"text": "done"},
|
||||
]
|
||||
assert client._convert_prepared_tool_result_to_blocks([]) == [{"text": ""}]
|
||||
assert client._normalize_tool_result_value(("a", 2)) == {"json": ["a", 2]}
|
||||
assert client._normalize_tool_result_value(Content.from_text(text="hello")) == {"text": "hello"}
|
||||
assert client._normalize_tool_result_value(_Serializable()) == {"json": {"value": 1}}
|
||||
|
||||
|
||||
def test_prepare_tools_parse_message_contents_and_finish_reason_helpers() -> None:
|
||||
"""Helper methods should ignore unsupported values and preserve Bedrock response semantics."""
|
||||
client = _make_client()
|
||||
mixed_tools = cast(
|
||||
list[FunctionTool | MutableMapping[str, Any]],
|
||||
[
|
||||
object(),
|
||||
{"toolSpec": {"name": "keep", "description": "desc", "inputSchema": {"json": {}}}},
|
||||
],
|
||||
)
|
||||
|
||||
prepared_tools = client._prepare_tools(mixed_tools)
|
||||
error_result = client._parse_message_contents([{"toolResult": {"status": "failure", "content": [{"text": "bad"}]}}])
|
||||
unsupported_result = client._parse_message_contents([{"image": "ignored"}])
|
||||
|
||||
assert prepared_tools == {
|
||||
"tools": [{"toolSpec": {"name": "keep", "description": "desc", "inputSchema": {"json": {}}}}]
|
||||
}
|
||||
assert client._generate_tool_call_id().startswith("tool-call-")
|
||||
assert error_result[0].exception == "Bedrock tool result status: failure"
|
||||
assert error_result[0].result == "bad"
|
||||
assert unsupported_result == []
|
||||
assert client._map_finish_reason(None) is None
|
||||
assert client._convert_bedrock_tool_result_to_value(None) is None
|
||||
assert client._convert_bedrock_tool_result_to_value([{"text": "ok"}]) == "ok"
|
||||
assert client._convert_bedrock_tool_result_to_value([{"json": {"x": 1}}, 7]) == [{"x": 1}, 7]
|
||||
assert client._convert_bedrock_tool_result_to_value({"json": {"x": 1}}) == {"x": 1}
|
||||
assert client._convert_bedrock_tool_result_to_value({"text": "ok"}) == "ok"
|
||||
|
||||
|
||||
def test_parse_message_contents_requires_tool_use_name() -> None:
|
||||
"""Malformed toolUse blocks should raise a client response error."""
|
||||
from agent_framework.exceptions import ChatClientInvalidResponseException
|
||||
|
||||
client = _make_client()
|
||||
|
||||
with pytest.raises(ChatClientInvalidResponseException, match="missing required tool name"):
|
||||
client._parse_message_contents([{"toolUse": {"toolUseId": "call-1"}}])
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
|
||||
from agent_framework import (
|
||||
Content,
|
||||
@@ -240,7 +240,7 @@ class ThreadItemConverter:
|
||||
content = converter.tag_to_message_content(tag)
|
||||
# Returns: Content.from_text(text="<TAG>Name:John Doe</TAG>")
|
||||
"""
|
||||
name = getattr(tag.data, "name", tag.text if hasattr(tag, "text") else "unknown")
|
||||
name = tag.data.get("name", tag.text) if isinstance(tag.data, Mapping) else getattr(tag.data, "name", tag.text)
|
||||
return Content.from_text(text=f"<TAG>Name:{name}</TAG>")
|
||||
|
||||
def task_to_input(self, item: TaskItem) -> Message | list[Message] | None:
|
||||
@@ -370,13 +370,17 @@ class ThreadItemConverter:
|
||||
.. code-block:: python
|
||||
|
||||
# Widget item
|
||||
from chatkit.widgets import Card, Text
|
||||
from chatkit.widgets import WidgetTemplate
|
||||
|
||||
widget_item = WidgetItem(
|
||||
id="widget_1",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
widget=Card(children=[Text(value="Hello")]),
|
||||
widget=WidgetTemplate({
|
||||
"version": "1.0",
|
||||
"name": "greeting",
|
||||
"template": '{"type":"Card","children":[{"type":"Text","value":"Hello"}]}',
|
||||
}).build(),
|
||||
)
|
||||
message = converter.widget_to_input(widget_item)
|
||||
# Returns message with JSON representation of the widget
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
"""Tests for ChatKit to Agent Framework converter utilities."""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -88,8 +90,6 @@ class TestThreadItemConverter:
|
||||
|
||||
async def test_to_agent_input_multiple_content_parts(self, converter):
|
||||
"""Test converting user message with multiple text content parts."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import UserMessageItem
|
||||
|
||||
input_item = UserMessageItem(
|
||||
@@ -110,6 +110,27 @@ class TestThreadItemConverter:
|
||||
assert len(result) == 1
|
||||
assert result[0].text == "Hello world!"
|
||||
|
||||
async def test_to_agent_input_with_quoted_text_for_last_message(self, converter):
|
||||
"""Test quoted text is prepended as context for the last user message."""
|
||||
from chatkit.types import UserMessageItem
|
||||
|
||||
input_item = UserMessageItem(
|
||||
id="msg_quoted",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="user_message",
|
||||
content=[UserMessageTextContent(text="Please summarize this")],
|
||||
attachments=[],
|
||||
quoted_text="Important excerpt",
|
||||
inference_options=InferenceOptions(),
|
||||
)
|
||||
|
||||
result = await converter.to_agent_input(input_item)
|
||||
|
||||
assert [message.role for message in result] == ["user", "user"]
|
||||
assert result[0].text == "The user is referring to this in particular:\nImportant excerpt"
|
||||
assert result[1].text == "Please summarize this"
|
||||
|
||||
def test_hidden_context_to_input(self, converter):
|
||||
"""Test converting hidden context item to Message."""
|
||||
hidden_item = Mock()
|
||||
@@ -135,8 +156,7 @@ class TestThreadItemConverter:
|
||||
|
||||
result = converter.tag_to_message_content(tag)
|
||||
assert result.type == "text"
|
||||
# Since data is a dict, getattr won't work, so it will fall back to text
|
||||
assert result.text == "<TAG>Name:john</TAG>"
|
||||
assert result.text == "<TAG>Name:John Doe</TAG>"
|
||||
|
||||
def test_tag_to_message_content_no_name(self, converter):
|
||||
"""Test converting tag with no name to message content."""
|
||||
@@ -154,6 +174,16 @@ class TestThreadItemConverter:
|
||||
assert result.type == "text"
|
||||
assert result.text == "<TAG>Name:jane</TAG>"
|
||||
|
||||
def test_tag_to_message_content_prefers_name_attribute(self, converter):
|
||||
"""Test converting tag content when the backing object exposes a name attribute."""
|
||||
tag = Mock()
|
||||
tag.data = SimpleNamespace(name="Jane Doe")
|
||||
tag.text = "fallback"
|
||||
|
||||
result = converter.tag_to_message_content(tag)
|
||||
|
||||
assert result.text == "<TAG>Name:Jane Doe</TAG>"
|
||||
|
||||
async def test_attachment_to_message_content_file_without_fetcher(self, converter):
|
||||
"""Test that FileAttachment without data fetcher returns None."""
|
||||
from chatkit.types import FileAttachment
|
||||
@@ -207,10 +237,30 @@ class TestThreadItemConverter:
|
||||
assert result.type == "data"
|
||||
assert result.media_type == "application/pdf"
|
||||
|
||||
async def test_attachment_to_message_content_fetcher_failure_falls_back_to_preview_url(self) -> None:
|
||||
"""Test failed attachment fetch falls back to image preview URLs."""
|
||||
from chatkit.types import ImageAttachment
|
||||
|
||||
async def fetch_data(_: str) -> bytes:
|
||||
raise RuntimeError("storage unavailable")
|
||||
|
||||
converter = ThreadItemConverter(attachment_data_fetcher=fetch_data)
|
||||
attachment = ImageAttachment(
|
||||
id="img_fallback",
|
||||
name="photo.jpg",
|
||||
mime_type="image/jpeg",
|
||||
type="image",
|
||||
preview_url=AnyUrl("https://example.com/fallback.jpg"),
|
||||
)
|
||||
|
||||
result = await converter.attachment_to_message_content(attachment)
|
||||
|
||||
assert result is not None
|
||||
assert result.type == "uri"
|
||||
assert result.uri == "https://example.com/fallback.jpg"
|
||||
|
||||
async def test_to_agent_input_with_image_attachment(self):
|
||||
"""Test converting user message with text and image attachment."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import ImageAttachment, UserMessageItem
|
||||
|
||||
attachment = ImageAttachment(
|
||||
@@ -250,8 +300,6 @@ class TestThreadItemConverter:
|
||||
|
||||
async def test_to_agent_input_with_file_attachment_and_fetcher(self):
|
||||
"""Test converting user message with file attachment using data fetcher."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import FileAttachment, UserMessageItem
|
||||
|
||||
attachment = FileAttachment(
|
||||
@@ -291,8 +339,6 @@ class TestThreadItemConverter:
|
||||
|
||||
def test_task_to_input(self, converter):
|
||||
"""Test converting TaskItem to Message."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import CustomTask, TaskItem
|
||||
|
||||
task_item = TaskItem(
|
||||
@@ -311,8 +357,6 @@ class TestThreadItemConverter:
|
||||
|
||||
def test_task_to_input_no_custom_task(self, converter):
|
||||
"""Test that non-custom tasks return None."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import TaskItem, ThoughtTask
|
||||
|
||||
task_item = TaskItem(
|
||||
@@ -328,8 +372,6 @@ class TestThreadItemConverter:
|
||||
|
||||
def test_workflow_to_input(self, converter):
|
||||
"""Test converting WorkflowItem to ChatMessages."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import CustomTask, Workflow, WorkflowItem
|
||||
|
||||
workflow_item = WorkflowItem(
|
||||
@@ -353,10 +395,34 @@ class TestThreadItemConverter:
|
||||
assert "Step 1: First step" in result[0].text
|
||||
assert "Step 2: Second step" in result[1].text
|
||||
|
||||
def test_workflow_to_input_skips_non_custom_tasks(self, converter):
|
||||
"""Test workflows ignore unsupported or empty tasks but keep valid custom tasks."""
|
||||
from chatkit.types import CustomTask, ThoughtTask, Workflow, WorkflowItem
|
||||
|
||||
workflow_item = WorkflowItem(
|
||||
id="wf_skip",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="workflow",
|
||||
workflow=Workflow(
|
||||
type="custom",
|
||||
tasks=[
|
||||
ThoughtTask(type="thought", title="Thinking", content="Working"),
|
||||
CustomTask(type="custom"),
|
||||
CustomTask(type="custom", title="Step", content="Done"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
result = converter.workflow_to_input(workflow_item)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].text is not None
|
||||
assert "Step: Done" in result[0].text
|
||||
|
||||
def test_workflow_to_input_empty(self, converter):
|
||||
"""Test that workflows with no custom tasks return None."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import Workflow, WorkflowItem
|
||||
|
||||
workflow_item = WorkflowItem(
|
||||
@@ -372,17 +438,19 @@ class TestThreadItemConverter:
|
||||
|
||||
def test_widget_to_input(self, converter):
|
||||
"""Test converting WidgetItem to Message."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import WidgetItem
|
||||
from chatkit.widgets import Card, Text # ty: ignore[deprecated]
|
||||
from chatkit.widgets import WidgetTemplate
|
||||
|
||||
widget_item = WidgetItem(
|
||||
id="widget_1",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="widget",
|
||||
widget=Card(key="card1", children=[Text(value="Hello")]), # ty: ignore[deprecated]
|
||||
widget=WidgetTemplate({
|
||||
"version": "1.0",
|
||||
"name": "greeting",
|
||||
"template": '{"type":"Card","key":"card1","children":[{"type":"Text","value":"Hello"}]}',
|
||||
}).build(),
|
||||
)
|
||||
|
||||
result = converter.widget_to_input(widget_item)
|
||||
@@ -391,6 +459,207 @@ class TestThreadItemConverter:
|
||||
assert "widget_1" in result.text
|
||||
assert "graphical UI widget" in result.text
|
||||
|
||||
def test_widget_to_input_serialization_failure_returns_none(self, converter):
|
||||
"""Test widget conversion skips widgets that cannot be serialized."""
|
||||
widget_item = Mock()
|
||||
widget_item.id = "widget_broken"
|
||||
widget_item.widget = Mock()
|
||||
widget_item.widget.model_dump_json.side_effect = RuntimeError("boom")
|
||||
|
||||
assert converter.widget_to_input(widget_item) is None
|
||||
|
||||
async def test_assistant_message_to_input_handles_empty_and_text_content(self, converter):
|
||||
"""Test assistant messages convert text content and skip empty messages."""
|
||||
from chatkit.types import AssistantMessageContent, AssistantMessageItem
|
||||
|
||||
assistant_item = AssistantMessageItem(
|
||||
id="assistant_1",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="assistant_message",
|
||||
content=[
|
||||
AssistantMessageContent(type="output_text", text="Hello", annotations=[]),
|
||||
AssistantMessageContent(type="output_text", text=" world", annotations=[]),
|
||||
],
|
||||
)
|
||||
empty_item = AssistantMessageItem(
|
||||
id="assistant_2",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="assistant_message",
|
||||
content=[],
|
||||
)
|
||||
|
||||
result = await converter.assistant_message_to_input(assistant_item)
|
||||
|
||||
assert isinstance(result, Message)
|
||||
assert result.role == "assistant"
|
||||
assert result.text == "Hello world"
|
||||
assert await converter.assistant_message_to_input(empty_item) is None
|
||||
|
||||
async def test_client_tool_call_to_input_handles_pending_and_completed(self, converter):
|
||||
"""Test client tool call conversion only emits completed tool calls."""
|
||||
import json
|
||||
|
||||
from chatkit.types import ClientToolCallItem
|
||||
|
||||
pending_item = ClientToolCallItem(
|
||||
id="tool_pending",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="client_tool_call",
|
||||
status="pending",
|
||||
call_id="call_pending",
|
||||
name="get_weather",
|
||||
arguments={"location": "SEA"},
|
||||
)
|
||||
completed_item = ClientToolCallItem(
|
||||
id="tool_done",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="client_tool_call",
|
||||
status="completed",
|
||||
call_id="call_done",
|
||||
name="get_weather",
|
||||
arguments={"location": "SEA"},
|
||||
output={"temperature": 72},
|
||||
)
|
||||
|
||||
assert await converter.client_tool_call_to_input(pending_item) is None
|
||||
|
||||
result = await converter.client_tool_call_to_input(completed_item)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert result[0].role == "assistant"
|
||||
assert result[0].contents[0].parse_arguments() == {"location": "SEA"}
|
||||
assert result[1].role == "tool"
|
||||
assert json.loads(result[1].contents[0].result) == {"temperature": 72}
|
||||
|
||||
async def test_end_of_turn_to_input_returns_none(self, converter):
|
||||
"""Test end-of-turn markers are skipped."""
|
||||
from chatkit.types import EndOfTurnItem
|
||||
|
||||
end_item = EndOfTurnItem(
|
||||
id="end_1",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="end_of_turn",
|
||||
)
|
||||
|
||||
assert await converter.end_of_turn_to_input(end_item) is None
|
||||
|
||||
async def test_to_agent_input_dispatches_supported_variants(self, converter):
|
||||
"""Test thread item dispatch converts supported items and skips unsupported variants."""
|
||||
from chatkit.types import (
|
||||
AssistantMessageContent,
|
||||
AssistantMessageItem,
|
||||
ClientToolCallItem,
|
||||
CustomTask,
|
||||
EndOfTurnItem,
|
||||
GeneratedImageItem,
|
||||
HiddenContextItem,
|
||||
SDKHiddenContextItem,
|
||||
StructuredInputItem,
|
||||
TaskItem,
|
||||
WidgetItem,
|
||||
Workflow,
|
||||
WorkflowItem,
|
||||
)
|
||||
from chatkit.widgets import WidgetTemplate
|
||||
|
||||
thread_items = [
|
||||
AssistantMessageItem(
|
||||
id="assistant_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="assistant_message",
|
||||
content=[AssistantMessageContent(type="output_text", text="Assistant", annotations=[])],
|
||||
),
|
||||
ClientToolCallItem(
|
||||
id="tool_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="client_tool_call",
|
||||
status="completed",
|
||||
call_id="dispatch_call",
|
||||
name="search",
|
||||
arguments={"query": "docs"},
|
||||
output={"result": "ok"},
|
||||
),
|
||||
EndOfTurnItem(id="end_dispatch", thread_id="thread_1", created_at=datetime.now(), type="end_of_turn"),
|
||||
WidgetItem(
|
||||
id="widget_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="widget",
|
||||
widget=WidgetTemplate({
|
||||
"version": "1.0",
|
||||
"name": "dispatch",
|
||||
"template": (
|
||||
'{"type":"Card","key":"card_dispatch","children":[{"type":"Text","value":"Dispatch"}]}'
|
||||
),
|
||||
}).build(),
|
||||
),
|
||||
WorkflowItem(
|
||||
id="workflow_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="workflow",
|
||||
workflow=Workflow(type="custom", tasks=[CustomTask(type="custom", title="Step", content="Done")]),
|
||||
),
|
||||
TaskItem(
|
||||
id="task_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="task",
|
||||
task=CustomTask(type="custom", title="Analysis", content="Completed"),
|
||||
),
|
||||
HiddenContextItem(
|
||||
id="hidden_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="hidden_context_item",
|
||||
content="secret",
|
||||
),
|
||||
SDKHiddenContextItem(
|
||||
id="sdk_hidden_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="sdk_hidden_context",
|
||||
content="sdk secret",
|
||||
),
|
||||
GeneratedImageItem(id="generated_dispatch", thread_id="thread_1", created_at=datetime.now()),
|
||||
StructuredInputItem(
|
||||
id="structured_dispatch",
|
||||
thread_id="thread_1",
|
||||
created_at=datetime.now(),
|
||||
type="structured_input",
|
||||
inputs=[],
|
||||
),
|
||||
object(),
|
||||
]
|
||||
|
||||
result = await converter.to_agent_input(thread_items)
|
||||
|
||||
assert [message.role for message in result] == [
|
||||
"assistant",
|
||||
"assistant",
|
||||
"tool",
|
||||
"user",
|
||||
"user",
|
||||
"user",
|
||||
"system",
|
||||
"system",
|
||||
]
|
||||
assert result[0].text == "Assistant"
|
||||
assert result[2].contents[0].result is not None
|
||||
assert "widget_dispatch" in result[3].text
|
||||
assert "Step: Done" in result[4].text
|
||||
assert "Analysis: Completed" in result[5].text
|
||||
assert result[6].text == "<HIDDEN_CONTEXT>secret</HIDDEN_CONTEXT>"
|
||||
assert result[7].text == "<HIDDEN_CONTEXT>sdk secret</HIDDEN_CONTEXT>"
|
||||
|
||||
|
||||
class TestSimpleToAgentInput:
|
||||
"""Tests for simple_to_agent_input helper function."""
|
||||
@@ -402,8 +671,6 @@ class TestSimpleToAgentInput:
|
||||
|
||||
async def test_simple_to_agent_input_with_text(self):
|
||||
"""Test simple conversion with text content."""
|
||||
from datetime import datetime
|
||||
|
||||
from chatkit.types import UserMessageItem
|
||||
|
||||
input_item = UserMessageItem(
|
||||
|
||||
@@ -2096,6 +2096,12 @@ def _collect_approval_responses(
|
||||
return fcc_todo
|
||||
|
||||
|
||||
def _is_approval_placeholder_result(content: Content) -> bool:
|
||||
"""Whether a function_result is the stand-in emitted while approval is pending."""
|
||||
result = getattr(content, "result", None)
|
||||
return isinstance(result, str) and "[APPROVAL_PENDING]" in result
|
||||
|
||||
|
||||
def _replace_approval_contents_with_results(
|
||||
messages: list[Message],
|
||||
fcc_todo: dict[str, Content],
|
||||
@@ -2119,12 +2125,30 @@ def _replace_approval_contents_with_results(
|
||||
# Track which call_ids had their placeholders replaced
|
||||
placeholders_replaced: set[str] = set()
|
||||
|
||||
for msg in messages:
|
||||
# First pass - collect existing function call IDs to avoid duplicates
|
||||
existing_call_ids = {
|
||||
content.call_id for content in msg.contents if content.type == "function_call" and content.call_id
|
||||
}
|
||||
# Collect *pending* function call IDs across all messages to avoid duplicates. The
|
||||
# function call and its approval request are frequently carried in separate messages
|
||||
# (e.g. when a hosting layer replays them as separate items on an approval round trip),
|
||||
# so scoping this per-message would let the same call_id be restored twice and leave
|
||||
# the copy without a result unanswered.
|
||||
#
|
||||
# Calls that already carry a real result are excluded: reusing a call_id for a later
|
||||
# invocation is supported, and a completed pair must not suppress the fresh request —
|
||||
# that would drop the new call and attach its result to the old one. Placeholder
|
||||
# results still count as pending, since the call they answer is the one being restored.
|
||||
answered_call_ids = {
|
||||
content.call_id
|
||||
for msg in messages
|
||||
for content in msg.contents
|
||||
if content.type == "function_result" and content.call_id and not _is_approval_placeholder_result(content)
|
||||
}
|
||||
existing_call_ids = {
|
||||
content.call_id
|
||||
for msg in messages
|
||||
for content in msg.contents
|
||||
if content.type == "function_call" and content.call_id and content.call_id not in answered_call_ids
|
||||
}
|
||||
|
||||
for msg in messages:
|
||||
# Track approval requests that should be removed (duplicates)
|
||||
contents_to_remove: list[int] = []
|
||||
|
||||
@@ -2140,6 +2164,8 @@ def _replace_approval_contents_with_results(
|
||||
elif content.function_call is not None:
|
||||
# Put back the function call content only if it doesn't exist
|
||||
msg.contents[content_idx] = content.function_call
|
||||
if content.function_call.call_id:
|
||||
existing_call_ids.add(content.function_call.call_id)
|
||||
elif content.type == "function_approval_response":
|
||||
# Skip hosted tool approvals — they must pass through to the API unchanged
|
||||
if _is_hosted_tool_approval(content):
|
||||
@@ -2169,12 +2195,7 @@ def _replace_approval_contents_with_results(
|
||||
msg.role = "tool"
|
||||
elif content.type == "function_result":
|
||||
# Check if this is a placeholder result that should be replaced
|
||||
if (
|
||||
hasattr(content, "result")
|
||||
and isinstance(content.result, str)
|
||||
and "[APPROVAL_PENDING]" in content.result
|
||||
and content.call_id in result_by_call_id
|
||||
):
|
||||
if _is_approval_placeholder_result(content) and content.call_id in result_by_call_id:
|
||||
# Replace placeholder with actual result
|
||||
msg.contents[content_idx] = result_by_call_id[content.call_id]
|
||||
placeholders_replaced.add(content.call_id)
|
||||
|
||||
@@ -811,6 +811,24 @@ class FunctionalWorkflow:
|
||||
execution is not allowed).
|
||||
"""
|
||||
self._validate_run_params(message, responses, checkpoint_id)
|
||||
# Warn (but don't block) when a fresh message or a checkpoint restore begins while a prior
|
||||
# run left request_info events pending. Mirrors Workflow.run. Delivering responses is the
|
||||
# normal way to complete the pending cycle and is intentionally not warned.
|
||||
if (message is not None or checkpoint_id is not None) and self._last_pending_request_ids:
|
||||
logger.warning(
|
||||
"Workflow %s received %s while %d request_info event(s) are still pending from an "
|
||||
"unfinished request/response cycle; %s. Deliver responses (responses=...) to complete "
|
||||
"the pending cycle before starting new input.",
|
||||
self.name,
|
||||
"a fresh message" if message is not None else "a checkpoint restore",
|
||||
len(self._last_pending_request_ids),
|
||||
(
|
||||
"those requests remain answerable, but this run advances workflow state, so a "
|
||||
"response that arrives later may apply to a workflow that has moved on"
|
||||
if message is not None
|
||||
else "those pending requests will be overwritten by the checkpoint's state"
|
||||
),
|
||||
)
|
||||
if responses and checkpoint_id is None:
|
||||
# Require at least one response key to match a currently-pending
|
||||
# request; prevents silent replay against stale state while still
|
||||
|
||||
@@ -245,7 +245,11 @@ class RunnerImpl:
|
||||
self._state.commit()
|
||||
|
||||
async def create_checkpoint_if_enabled(self) -> None:
|
||||
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
|
||||
"""Create a checkpoint and save the checkpoint to the configured storage if one is configured.
|
||||
|
||||
Note:
|
||||
1. This method has no effect if checkpointing is not enabled in the context.
|
||||
"""
|
||||
if not self._ctx.has_checkpointing():
|
||||
return
|
||||
|
||||
@@ -340,6 +344,57 @@ class RunnerImpl:
|
||||
logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}")
|
||||
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e
|
||||
|
||||
async def build_checkpoint(self) -> WorkflowCheckpoint:
|
||||
"""Create a checkpoint object.
|
||||
|
||||
Returns:
|
||||
A ``WorkflowCheckpoint``.
|
||||
"""
|
||||
# Persist executor snapshots into committed shared state before exporting it.
|
||||
await self._prepare_checkpoint_state()
|
||||
return await self._ctx.build_checkpoint(
|
||||
self._workflow_name,
|
||||
self._graph_signature_hash,
|
||||
self._state,
|
||||
None,
|
||||
self._iteration,
|
||||
)
|
||||
|
||||
async def restore_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Restore runner state from an in-memory ``WorkflowCheckpoint`` object.
|
||||
|
||||
Unlike :meth:`restore_from_checkpoint`, this does not load from a storage
|
||||
backend; it applies a checkpoint the caller already holds - for example, a
|
||||
child workflow checkpoint embedded in a parent ``WorkflowExecutor``'s state.
|
||||
|
||||
Restores shared state, executor snapshots, in-flight messages, and pending
|
||||
request_info events, then marks the runner as resumed.
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint whose state should be restored.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the checkpoint's graph signature does not
|
||||
match this runner's workflow, or if restoration otherwise fails.
|
||||
"""
|
||||
if self._graph_signature_hash != checkpoint.graph_signature_hash:
|
||||
raise WorkflowCheckpointException(
|
||||
"Workflow graph has changed since the checkpoint was created. "
|
||||
"Please rebuild the original workflow before resuming."
|
||||
)
|
||||
|
||||
try:
|
||||
# Clear first so import_state (which merges) does not leak stale keys from a
|
||||
# prior run on this Workflow instance.
|
||||
self._state.clear()
|
||||
self._state.import_state(checkpoint.state)
|
||||
await self._restore_executor_states()
|
||||
await self._ctx.apply_checkpoint(checkpoint)
|
||||
self._mark_resumed(checkpoint)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore from checkpoint {checkpoint.checkpoint_id}: {e}")
|
||||
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint.checkpoint_id}") from e
|
||||
|
||||
async def _save_executor_states(self) -> None:
|
||||
"""Populate executor state by calling checkpoint hooks on executors."""
|
||||
for exec_id, executor in self._executors.items():
|
||||
|
||||
@@ -195,6 +195,34 @@ class RunnerContext(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
"""Build a checkpoint and return it for the caller to own.
|
||||
|
||||
The checkpoint is constructed in memory and handed back to the caller; nothing is
|
||||
persisted and no checkpoint storage is required.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow for which the checkpoint is being created.
|
||||
graph_signature_hash: Hash of the workflow graph topology to
|
||||
validate checkpoint compatibility during restore.
|
||||
state: The state to include in the checkpoint.
|
||||
previous_checkpoint_id: The ID of the previous checkpoint, if any, to form a checkpoint chain.
|
||||
iteration_count: The current iteration count of the workflow.
|
||||
metadata: Optional metadata to associate with the checkpoint.
|
||||
|
||||
Returns:
|
||||
A ``WorkflowCheckpoint`` of the current context state.
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
@@ -204,7 +232,7 @@ class RunnerContext(Protocol):
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> CheckpointID:
|
||||
"""Create a checkpoint of the current workflow state.
|
||||
"""Persist a checkpoint of the current workflow state to configured storage and return its ID.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow for which the checkpoint is being created.
|
||||
@@ -219,6 +247,9 @@ class RunnerContext(Protocol):
|
||||
|
||||
Returns:
|
||||
The ID of the created checkpoint.
|
||||
|
||||
Raises:
|
||||
ValueError: If checkpoint storage is not configured.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -381,6 +412,27 @@ class InProcRunnerContext:
|
||||
def has_checkpointing(self) -> bool:
|
||||
return self._get_effective_checkpoint_storage() is not None
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
return WorkflowCheckpoint(
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash=graph_signature_hash,
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
# Copy the per-source lists so the snapshot is isolated from later context mutations.
|
||||
messages={source_id: list(messages) for source_id, messages in self._messages.items()},
|
||||
state=state.export_state(),
|
||||
pending_request_info_events=dict(self._pending_request_info_events),
|
||||
iteration_count=iteration_count,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
@@ -394,15 +446,13 @@ class InProcRunnerContext:
|
||||
if not storage:
|
||||
raise ValueError("Checkpoint storage not configured")
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash=graph_signature_hash,
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
messages=dict(self._messages),
|
||||
state=state.export_state(),
|
||||
pending_request_info_events=dict(self._pending_request_info_events),
|
||||
iteration_count=iteration_count,
|
||||
metadata=metadata or {},
|
||||
checkpoint = await self.build_checkpoint(
|
||||
workflow_name,
|
||||
graph_signature_hash,
|
||||
state,
|
||||
previous_checkpoint_id,
|
||||
iteration_count,
|
||||
metadata,
|
||||
)
|
||||
checkpoint_id = await storage.save(checkpoint)
|
||||
logger.debug(f"Created checkpoint {checkpoint_id}")
|
||||
|
||||
@@ -173,7 +173,7 @@ class WorkflowRunResult(list[WorkflowEvent]):
|
||||
class OutputDesignation:
|
||||
"""Immutable rule for labeling executor yields as terminal, intermediate, or hidden outputs.
|
||||
|
||||
``outputs`` is ``None`` in omitted-selection compatibility mode (every yield is terminal). In explicit mode,
|
||||
``outputs`` is ``None`` in the default all-output mode (every yield is terminal). In explicit mode,
|
||||
``outputs`` and ``intermediates`` are disjoint executor ID sets; unlisted executor
|
||||
yields are hidden from caller-facing output/intermediate events.
|
||||
Package-internal value type owned by ``Workflow``; not exported from ``agent_framework``.
|
||||
@@ -305,9 +305,8 @@ class Workflow(DictConvertible):
|
||||
better observability and management.
|
||||
description: Optional description of what the workflow does. If the workflow is built using
|
||||
WorkflowBuilder, this will be the description of the builder.
|
||||
output_from: List of executor IDs designated as workflow outputs, or
|
||||
``None`` for omitted-selection compatibility behavior when ``intermediate_output_from`` is also
|
||||
``None``.
|
||||
output_from: List of executor IDs designated as workflow outputs, or ``None`` for the default
|
||||
all-output behavior when ``intermediate_output_from`` is also ``None``.
|
||||
intermediate_output_from: List of executor IDs designated as intermediate outputs.
|
||||
In explicit designation mode, unlisted executor yields are hidden from
|
||||
caller-facing output/intermediate events.
|
||||
@@ -334,7 +333,7 @@ class Workflow(DictConvertible):
|
||||
self.graph_signature = self._compute_graph_signature()
|
||||
self.graph_signature_hash = self._hash_graph_signature(self.graph_signature)
|
||||
|
||||
# Single value type encodes omitted-selection compatibility vs explicit output-designation policy.
|
||||
# Single value type encodes default all-output vs explicit output-designation policy.
|
||||
output_designation_ids = (
|
||||
frozenset(output_from)
|
||||
if output_from is not None
|
||||
@@ -433,8 +432,8 @@ class Workflow(DictConvertible):
|
||||
def get_output_executors(self) -> list[Executor]:
|
||||
"""Get the list of output executors in the workflow.
|
||||
|
||||
In omitted-selection compatibility mode (no explicit ``output_from``), returns every
|
||||
executor in the workflow. In explicit mode, returns only the designated output executors.
|
||||
In the default all-output mode, returns every executor in the workflow. In explicit mode,
|
||||
returns only the designated output executors.
|
||||
"""
|
||||
designated = self._output_designation.outputs
|
||||
if designated is None:
|
||||
@@ -815,10 +814,9 @@ class Workflow(DictConvertible):
|
||||
# runner context has fully drained from any prior run. If it still
|
||||
# has in-flight executor messages, the prior run didn't complete -
|
||||
# the caller must either resume from a checkpoint or wait for the
|
||||
# prior run to drain. (Pending request_info events are intentionally
|
||||
# NOT blocked here: a follow-up run with message=... is the normal
|
||||
# way to deliver a response to those pending requests, e.g. via
|
||||
# WorkflowAgent._process_pending_requests.)
|
||||
# prior run to drain. Pending request_info events are intentionally
|
||||
# NOT blocked here (they are answered via a follow-up ``responses=...``
|
||||
# run); the warning below surfaces the abandon/overwrite cases instead.
|
||||
# NOTE: _validate_run_params already enforces that ``message`` is
|
||||
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
|
||||
# so we don't need to re-check those here.
|
||||
@@ -831,6 +829,33 @@ class Workflow(DictConvertible):
|
||||
"checkpointing; there is no in-process recovery path."
|
||||
)
|
||||
|
||||
# Warn (but don't block) when a fresh message or a checkpoint restore begins while the
|
||||
# workflow still has pending request_info events from an unfinished request/response
|
||||
# cycle. A fresh ``message`` does NOT drop those pending requests - they remain pending and
|
||||
# can still be answered later - but the new run advances executor and shared state, so when
|
||||
# a response for an earlier request eventually arrives the workflow may have moved on,
|
||||
# yielding inconsistent results. A ``checkpoint_id`` restore instead replaces the context's
|
||||
# pending requests with the checkpoint's state. Delivering ``responses`` is the normal way to
|
||||
# answer pending requests and is intentionally not warned. Mirrors the WorkflowExecutor
|
||||
# warning for overlapping sub-workflow executions.
|
||||
if message is not None or checkpoint_id is not None:
|
||||
pending_request_info_events = await self._runner.context.get_pending_request_info_events()
|
||||
if pending_request_info_events:
|
||||
logger.warning(
|
||||
"Workflow %s received %s while %d request_info event(s) are still pending from an "
|
||||
"unfinished request/response cycle; %s. Deliver responses (responses=...) to complete "
|
||||
"the pending cycle before starting new input.",
|
||||
self.id,
|
||||
"a fresh message" if message is not None else "a checkpoint restore",
|
||||
len(pending_request_info_events),
|
||||
(
|
||||
"those requests remain pending, but this run advances executor and shared state, "
|
||||
"so a response that arrives later may apply to a workflow that has moved on"
|
||||
if message is not None
|
||||
else "those pending requests will be overwritten by the checkpoint's state"
|
||||
),
|
||||
)
|
||||
|
||||
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
import warnings
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -120,14 +119,13 @@ class WorkflowBuilder:
|
||||
Pass ``"all_other"`` to select every executor with declared workflow output types
|
||||
that is not selected by ``output_from``.
|
||||
If neither ``output_from`` nor ``intermediate_output_from`` is provided,
|
||||
omitted-selection compatibility behavior applies and every ``yield_output`` produces
|
||||
``type='output'``. If either is provided, explicit mode applies: listed
|
||||
every ``yield_output`` produces ``type='output'``. If either is provided,
|
||||
explicit mode applies: listed
|
||||
workflow-output executors emit ``output``, listed intermediate executors emit
|
||||
``intermediate``, and unlisted executor yields are hidden.
|
||||
|
||||
Output selection behavior:
|
||||
- Omit both selections: every ``yield_output`` emits ``output`` for compatibility,
|
||||
with a deprecation warning.
|
||||
- Omit both selections: every ``yield_output`` emits ``output``.
|
||||
- ``output_from="all"``: every output-capable executor emits ``output``.
|
||||
- ``output_from=[A]``: only A emits ``output``; other executor payloads are hidden.
|
||||
- ``output_from=[A], intermediate_output_from="all_other"``: A emits ``output``;
|
||||
@@ -156,8 +154,7 @@ class WorkflowBuilder:
|
||||
# being created for the same agent.
|
||||
self._agent_wrappers: dict[str, Executor] = {}
|
||||
|
||||
# ``None`` for both means omitted-selection compatibility behavior
|
||||
# (every yield_output produces type='output').
|
||||
# ``None`` for both means the default all-output behavior.
|
||||
# If either is provided, explicit mode applies and unlisted executor yields are hidden.
|
||||
self._output_from: _OutputSelection = self._coerce_output_from(output_from)
|
||||
self._intermediate_output_from: _IntermediateOutputSelection = self._coerce_intermediate_output_from(
|
||||
@@ -794,7 +791,7 @@ class WorkflowBuilder:
|
||||
print(events.get_outputs()) # []
|
||||
print(events.get_intermediate_outputs()) # outputs from planner and answerer
|
||||
|
||||
# Explicitly preserve all-output behavior without relying on omitted-selection compatibility.
|
||||
# Explicitly select all output-capable executors.
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=planner, output_from="all").add_edge(planner, answerer).build()
|
||||
)
|
||||
@@ -812,16 +809,6 @@ class WorkflowBuilder:
|
||||
"Starting executor must be set via the start_executor constructor parameter before building."
|
||||
)
|
||||
|
||||
if self._output_from is None and self._intermediate_output_from is None:
|
||||
warnings.warn(
|
||||
"WorkflowBuilder built without explicit output_from or intermediate_output_from; "
|
||||
"every yield_output produces type='output' for compatibility. Pass output_from='all', "
|
||||
"output_from=[...], or intermediate_output_from=[...] to opt into explicit designation - "
|
||||
"explicit designation will be required in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
start_executor = self._start_executor
|
||||
executors = self._executors
|
||||
edge_groups = self._edge_groups
|
||||
|
||||
@@ -4,14 +4,12 @@ import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import (
|
||||
WorkflowEvent,
|
||||
@@ -36,7 +34,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
"""Context for tracking a single sub-workflow execution."""
|
||||
"""Legacy per-execution bookkeeping.
|
||||
|
||||
Retained only to decode checkpoints written before the sub-workflow's own checkpoint was
|
||||
embedded (see ``WorkflowExecutor.on_checkpoint_restore``). It is no longer used at runtime -
|
||||
the wrapped sub-workflow is the single source of truth for its pending requests.
|
||||
"""
|
||||
|
||||
# The ID of the execution context
|
||||
execution_id: str
|
||||
@@ -161,11 +164,9 @@ class WorkflowExecutor(Executor):
|
||||
# The response handler expects a SubWorkflowResponseMessage wrapping the response data.
|
||||
|
||||
### State Management
|
||||
WorkflowExecutor maintains execution state across request/response cycles:
|
||||
- Tracks pending requests by request_id
|
||||
- Accumulates responses until all expected responses are received
|
||||
- Resumes sub-workflow execution with complete response batch
|
||||
- Handles concurrent executions and multiple pending requests
|
||||
WorkflowExecutor keeps no request/response bookkeeping of its own. The wrapped sub-workflow
|
||||
is the single source of truth for its pending requests; responses are forwarded to it and
|
||||
validated against its own pending request_info events.
|
||||
|
||||
## Type System Integration
|
||||
WorkflowExecutor inherits its type signature from the wrapped workflow:
|
||||
@@ -194,46 +195,21 @@ class WorkflowExecutor(Executor):
|
||||
- Converts to error event in parent context
|
||||
- Provides detailed error information including sub-workflow ID
|
||||
|
||||
## Concurrent Execution Support
|
||||
WorkflowExecutor fully supports multiple concurrent sub-workflow executions:
|
||||
|
||||
### Per-Execution State Isolation
|
||||
Each sub-workflow invocation creates an isolated ExecutionContext:
|
||||
## Overlapping Executions
|
||||
A ``WorkflowExecutor`` wraps a single shared sub-workflow instance and keeps no per-execution
|
||||
state. If a new input arrives while the sub-workflow still has pending request_info events from
|
||||
an unfinished request/response cycle, the new input advances the shared sub-workflow state and
|
||||
can interfere with that cycle - a response arriving later may apply to a sub-workflow that has
|
||||
moved on. This is allowed but logs a warning, and is only safe when the wrapped workflow (and
|
||||
its executors) are stateless.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Multiple concurrent invocations are supported
|
||||
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
|
||||
|
||||
# Each invocation gets its own execution context
|
||||
# Execution 1: processes input_1 independently
|
||||
# Execution 2: processes input_2 independently
|
||||
# No state interference between executions
|
||||
|
||||
### Request/Response Coordination
|
||||
Responses are correctly routed to the originating execution:
|
||||
- Each execution tracks its own pending requests and expected responses
|
||||
- Request-to-execution mapping ensures responses reach the correct sub-workflow
|
||||
- Response accumulation is isolated per execution
|
||||
- Automatic cleanup when execution completes
|
||||
|
||||
### Memory Management
|
||||
- Unlimited concurrent executions supported
|
||||
- Each execution has unique UUID-based identification
|
||||
- Cleanup of completed execution contexts
|
||||
- Thread-safe state management for concurrent access
|
||||
|
||||
### Important Considerations
|
||||
**Shared Workflow Instance**: All concurrent executions use the same underlying workflow instance.
|
||||
For proper isolation, ensure that the wrapped workflow and its executors are stateless.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Avoid: Stateful executor with instance variables
|
||||
# Avoid: stateful executor whose instance variables are shared across overlapping runs
|
||||
class StatefulExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="stateful")
|
||||
self.data = [] # This will be shared across concurrent executions!
|
||||
self.data = [] # Shared across overlapping sub-workflow executions!
|
||||
|
||||
## Integration with Parent Workflows
|
||||
Parent workflows can intercept sub-workflow requests:
|
||||
@@ -255,12 +231,18 @@ class WorkflowExecutor(Executor):
|
||||
# Forward to external handler
|
||||
await ctx.request_info(request.source_event, response_type=request.source_event.response_type)
|
||||
|
||||
## Checkpointing
|
||||
The provided sub workflow may not have its own checkpoint storage. The sub workflow checkpointed states will
|
||||
be managed by the parent workflow.
|
||||
|
||||
## Implementation Notes
|
||||
- Sub-workflows run to completion before processing their results
|
||||
- Event processing is atomic - all outputs are forwarded before requests
|
||||
- Response accumulation ensures sub-workflows receive complete response batches
|
||||
- Execution state is maintained for proper resumption after external requests
|
||||
- Concurrent executions are fully isolated and do not interfere with each other
|
||||
- Sub-workflows run to completion (or to idle-with-pending-requests) before their results are processed
|
||||
- Event processing is ordered - outputs are forwarded before requests
|
||||
- Responses are forwarded to the sub-workflow as they arrive; the sub-workflow tracks its own
|
||||
pending requests and resumes when they are answered
|
||||
- The WorkflowExecutor keeps no per-execution bookkeeping; the sub-workflow is the single source
|
||||
of truth for its pending requests. Starting a new execution while the sub-workflow still has
|
||||
pending requests logs a warning and is only safe when the wrapped workflow is stateless
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -274,19 +256,18 @@ class WorkflowExecutor(Executor):
|
||||
"""Initialize the WorkflowExecutor.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to execute as a sub-workflow.
|
||||
workflow: The workflow to execute as a sub-workflow. This workflow instance (including
|
||||
the executor instances within it) must be unique. If the same instances are shared
|
||||
across multiple WorkflowExecutor instances, it may lead to incorrect behavior.
|
||||
id: Unique identifier for this executor.
|
||||
allow_direct_output: Whether to allow direct output from the sub-workflow.
|
||||
By default, outputs from the sub-workflow are sent to
|
||||
other executors in the parent workflow as messages.
|
||||
When this is set to true, the outputs are yielded
|
||||
directly from the WorkflowExecutor to the parent
|
||||
workflow's event stream.
|
||||
propagate_request: Whether to propagate requests from the sub-workflow to the
|
||||
parent workflow. If set to true, requests from the sub-workflow
|
||||
will be propagated as the original WorkflowEvent to the parent
|
||||
workflow. Otherwise, they will be wrapped in a SubWorkflowRequestMessage,
|
||||
which should be handled by an executor in the parent workflow.
|
||||
allow_direct_output: Whether to allow direct output from the sub-workflow. By default,
|
||||
outputs from the sub-workflow are sent to other executors in the parent workflow as
|
||||
messages. When this is set to true, the outputs are yielded directly from the
|
||||
WorkflowExecutor to the parent workflow's event stream.
|
||||
propagate_request: Whether to propagate requests from the sub-workflow to the parent
|
||||
workflow. If set to true, requests from the sub-workflow will be propagated as the
|
||||
original WorkflowEvent to the parent workflow. Otherwise, they will be wrapped in a
|
||||
SubWorkflowRequestMessage, which should be handled by an executor in the parent workflow.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Additional keyword arguments passed to the parent constructor.
|
||||
@@ -294,13 +275,17 @@ class WorkflowExecutor(Executor):
|
||||
super().__init__(id, **kwargs)
|
||||
self.workflow = workflow
|
||||
self.allow_direct_output = allow_direct_output
|
||||
|
||||
# Track execution contexts for concurrent sub-workflow executions
|
||||
self._execution_contexts: dict[str, ExecutionContext] = {} # execution_id -> ExecutionContext
|
||||
# Map request_id to execution_id for response routing
|
||||
self._request_to_execution: dict[str, str] = {} # request_id -> execution_id
|
||||
self._propagate_request = propagate_request
|
||||
|
||||
if self.workflow._runner_context.has_checkpointing(): # type: ignore
|
||||
logger.warning(
|
||||
"Sub workflow %s has its own checkpoint storage configured. "
|
||||
"Sub workflow states are checkpointed by the parent workflow at superstep boundaries. "
|
||||
"Additional checkpointing is only needed if you need to persist sub workflow state "
|
||||
"independently of the parent workflow. ",
|
||||
self.workflow.id,
|
||||
)
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the input types based on the underlying workflow's input types plus WorkflowExecutor-specific types.
|
||||
@@ -351,11 +336,10 @@ class WorkflowExecutor(Executor):
|
||||
# Always handle SubWorkflowResponseMessage
|
||||
return True
|
||||
|
||||
if (
|
||||
message.original_request_info_event is not None
|
||||
and message.original_request_info_event.request_id in self._request_to_execution
|
||||
):
|
||||
# Handle propagated responses for known requests
|
||||
if message.original_request_info_event is not None:
|
||||
# A propagated response is target-routed back to the executor that issued the request,
|
||||
# so if one reaches this WorkflowExecutor it belongs to our sub-workflow. _handle_response
|
||||
# validates it against the sub-workflow's pending requests and ignores anything unknown.
|
||||
return True
|
||||
|
||||
# For other messages, only handle if the wrapped workflow can accept them as input
|
||||
@@ -372,58 +356,52 @@ class WorkflowExecutor(Executor):
|
||||
input_data: The input data to send to the sub-workflow.
|
||||
ctx: The workflow context from the parent.
|
||||
"""
|
||||
# Create execution context for this sub-workflow run
|
||||
execution_id = str(uuid.uuid4())
|
||||
execution_context = ExecutionContext(
|
||||
execution_id=execution_id,
|
||||
collected_responses={},
|
||||
expected_response_count=0,
|
||||
pending_requests={},
|
||||
# The sub-workflow is a single shared instance. If it still has pending request_info events
|
||||
# from an unfinished request/response cycle, a new input advances its shared state and can
|
||||
# interfere with that cycle - a response arriving later may apply to a sub-workflow that has
|
||||
# moved on. We allow it (the sub-workflow may be stateless) but warn so the risk is visible.
|
||||
pending_requests = await self.workflow._runner_context.get_pending_request_info_events() # pyright: ignore[reportPrivateUsage]
|
||||
if pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received a new input message while its sub-workflow "
|
||||
f"({self.workflow.id}) still has {len(pending_requests)} pending request(s) from an "
|
||||
f"unfinished request/response cycle. The sub-workflow is a single shared instance, so the "
|
||||
f"new input advances shared state and can interfere with the in-flight cycle. Ensure the "
|
||||
f"sub-workflow is stateless, or complete the pending cycle before sending new input."
|
||||
)
|
||||
|
||||
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id}")
|
||||
|
||||
# Get kwargs from parent workflow's State to propagate to subworkflow
|
||||
parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
|
||||
# Extract invocation kwargs recognised by Workflow.run()
|
||||
# The state stores resolved format (with __global__ wrapper for global kwargs).
|
||||
# Unwrap __global__ before passing to the subworkflow so it gets re-resolved
|
||||
# against the subworkflow's own executor IDs.
|
||||
fi_kwargs: dict[str, Any] | None = None
|
||||
ci_kwargs: dict[str, Any] | None = None
|
||||
for key in ("function_invocation_kwargs", "client_kwargs"):
|
||||
resolved = parent_kwargs.get(key)
|
||||
if isinstance(resolved, dict):
|
||||
# Unwrap global sentinel; pass per-executor dicts as-is
|
||||
unwrapped: dict[str, Any] = resolved.get(GLOBAL_KWARGS_KEY, resolved) # type: ignore
|
||||
if key == "function_invocation_kwargs":
|
||||
fi_kwargs = unwrapped # type: ignore
|
||||
else:
|
||||
ci_kwargs = unwrapped # type: ignore
|
||||
|
||||
# Run the sub-workflow and collect all events, passing parent kwargs
|
||||
result = await self.workflow.run(
|
||||
input_data,
|
||||
function_invocation_kwargs=fi_kwargs, # type: ignore
|
||||
client_kwargs=ci_kwargs, # type: ignore
|
||||
)
|
||||
self._execution_contexts[execution_id] = execution_context
|
||||
|
||||
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
|
||||
logger.debug(f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} completed with {len(result)} events")
|
||||
|
||||
try:
|
||||
# Get kwargs from parent workflow's State to propagate to subworkflow
|
||||
parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
|
||||
# Extract invocation kwargs recognised by Workflow.run()
|
||||
# The state stores resolved format (with __global__ wrapper for global kwargs).
|
||||
# Unwrap __global__ before passing to the subworkflow so it gets re-resolved
|
||||
# against the subworkflow's own executor IDs.
|
||||
fi_kwargs: dict[str, Any] | None = None
|
||||
ci_kwargs: dict[str, Any] | None = None
|
||||
for key in ("function_invocation_kwargs", "client_kwargs"):
|
||||
resolved = parent_kwargs.get(key)
|
||||
if isinstance(resolved, dict):
|
||||
# Unwrap global sentinel; pass per-executor dicts as-is
|
||||
unwrapped: dict[str, Any] = resolved.get(GLOBAL_KWARGS_KEY, resolved) # type: ignore
|
||||
if key == "function_invocation_kwargs":
|
||||
fi_kwargs = unwrapped # type: ignore
|
||||
else:
|
||||
ci_kwargs = unwrapped # type: ignore
|
||||
|
||||
# Run the sub-workflow and collect all events, passing parent kwargs
|
||||
result = await self.workflow.run(
|
||||
input_data,
|
||||
function_invocation_kwargs=fi_kwargs, # type: ignore
|
||||
client_kwargs=ci_kwargs, # type: ignore
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} "
|
||||
f"execution {execution_id} completed with {len(result)} events"
|
||||
)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if execution_id in self._execution_contexts:
|
||||
exec_ctx = self._execution_contexts[execution_id]
|
||||
if not exec_ctx.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, ctx)
|
||||
|
||||
@handler
|
||||
async def handle_message_wrapped_request_response(
|
||||
@@ -433,8 +411,8 @@ class WorkflowExecutor(Executor):
|
||||
) -> None:
|
||||
"""Handle response from parent for a forwarded request.
|
||||
|
||||
This handler accumulates responses and only resumes the sub-workflow
|
||||
when all expected responses have been received for that execution.
|
||||
Forwards the response to the sub-workflow, which resumes and validates it against its
|
||||
own pending requests.
|
||||
|
||||
Args:
|
||||
response: The response to a previous request.
|
||||
@@ -474,61 +452,44 @@ class WorkflowExecutor(Executor):
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
"""Get the current state of the WorkflowExecutor for checkpointing purposes."""
|
||||
return {
|
||||
"execution_contexts": {
|
||||
execution_id: execution_context for execution_id, execution_context in self._execution_contexts.items()
|
||||
},
|
||||
"request_to_execution": dict(self._request_to_execution),
|
||||
# The sub-workflow's own checkpoint carries everything needed to resume: shared state,
|
||||
# executor snapshots, in-flight messages, and pending request_info events. The
|
||||
# WorkflowExecutor keeps no separate request/response bookkeeping of its own. The
|
||||
# sub-workflow is quiescent here: it ran to idle within this parent superstep before
|
||||
# the parent checkpoints.
|
||||
"sub_workflow_checkpoint": await self.workflow._runner.build_checkpoint(), # pyright: ignore[reportPrivateUsage]
|
||||
}
|
||||
|
||||
@override
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
"""Restore the WorkflowExecutor state from a checkpoint snapshot."""
|
||||
# Validate the state contains the right keys
|
||||
if "execution_contexts" not in state:
|
||||
raise KeyError("Missing 'execution_contexts' in WorkflowExecutor state.")
|
||||
if "request_to_execution" not in state:
|
||||
raise KeyError("Missing 'request_to_execution' in WorkflowExecutor state.")
|
||||
# The storage backend fully materializes the checkpoint on load, checkpointed data arrives as live objects.
|
||||
sub_workflow_checkpoint = state.get("sub_workflow_checkpoint")
|
||||
if sub_workflow_checkpoint is not None:
|
||||
await self.workflow._runner.restore_checkpoint(sub_workflow_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
return
|
||||
|
||||
# Validate the execution contexts stored in the state have the right keys and values
|
||||
execution_contexts: dict[str, ExecutionContext] | None = None
|
||||
try:
|
||||
execution_contexts = {
|
||||
key: decode_checkpoint_value(value) for key, value in state["execution_contexts"].items()
|
||||
}
|
||||
except Exception as ex:
|
||||
raise RuntimeError("Failed to deserialize execution context.") from ex
|
||||
|
||||
if not all(
|
||||
isinstance(key, str) and isinstance(value, ExecutionContext) for key, value in execution_contexts.items()
|
||||
):
|
||||
raise ValueError("Execution contexts must have 'str' as key and 'ExecutionContext' as value.")
|
||||
if not all(key == value.execution_id for key, value in execution_contexts.items()):
|
||||
raise ValueError("Execution contexts must have matching keys and IDs.")
|
||||
|
||||
# Validate the request_to_execution map contain the right data
|
||||
request_to_execution = state["request_to_execution"]
|
||||
if not all(isinstance(key, str) and isinstance(value, str) for key, value in request_to_execution.items()):
|
||||
raise ValueError("Request to execution map must have 'str' as key and 'str' as value.")
|
||||
if not all(value in execution_contexts for value in request_to_execution.values()):
|
||||
raise ValueError(
|
||||
"'request_to_execution` contains unknown execution ID that is not part of the execution contexts."
|
||||
)
|
||||
|
||||
self._execution_contexts = execution_contexts
|
||||
self._request_to_execution = request_to_execution
|
||||
|
||||
# Add the `request_info_event`s back to the sub workflow.
|
||||
# This is only a temporary solution to rehydrate the sub workflow with the requests.
|
||||
# The proper way would be to rehydrate the workflow from a checkpoint on a Workflow
|
||||
# API instead of the '_runner_context' object that should be hidden. And the sub workflow
|
||||
# should be rehydrated from a checkpoint object instead of from a subset of the state.
|
||||
# TODO(@taochen): Issue #1614 - how to handle the case when the parent workflow has checkpointing
|
||||
# set up but not the sub workflow?
|
||||
request_info_events = [
|
||||
request_info_event
|
||||
for execution_context in self._execution_contexts.values()
|
||||
for request_info_event in execution_context.pending_requests.values()
|
||||
]
|
||||
# Backward-compatibility fallback for checkpoints written before the sub-workflow checkpoint
|
||||
# was embedded. Those stored per-execution bookkeeping; recover only the pending
|
||||
# request_info events so the sub-workflow re-emits its pending requests. The sub-workflow's
|
||||
# deeper executor/shared state cannot be restored from these older checkpoints.
|
||||
legacy_execution_contexts = state.get("execution_contexts")
|
||||
if not legacy_execution_contexts:
|
||||
return
|
||||
request_info_events: list[WorkflowEvent[Any]] = []
|
||||
for execution_context in legacy_execution_contexts.values():
|
||||
if isinstance(execution_context, ExecutionContext):
|
||||
request_info_events.extend(execution_context.pending_requests.values())
|
||||
if execution_context.collected_responses:
|
||||
logger.warning(
|
||||
"WorkflowExecutor %s restored legacy checkpoint with collected responses for "
|
||||
"execution_id %s. The sub-workflow is the single source of truth for its pending "
|
||||
"requests, so these responses will be ignored. Resume instead from a checkpoint created "
|
||||
"prior to any responses being collected if legacy request/response state must be "
|
||||
"preserved. Legacy execution contexts for sub-workflows will be removed in a future release.",
|
||||
self.id,
|
||||
execution_context.execution_id,
|
||||
)
|
||||
await asyncio.gather(*[
|
||||
self.workflow._runner_context.add_request_info_event(event) # pyright: ignore[reportPrivateUsage]
|
||||
for event in request_info_events
|
||||
@@ -537,7 +498,6 @@ class WorkflowExecutor(Executor):
|
||||
async def _process_workflow_result(
|
||||
self,
|
||||
result: WorkflowRunResult,
|
||||
execution_context: ExecutionContext,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Process the result from a workflow execution.
|
||||
@@ -547,7 +507,6 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
Args:
|
||||
result: The workflow execution result.
|
||||
execution_context: The execution context for this sub-workflow run.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
# Collect all events from the workflow
|
||||
@@ -586,10 +545,6 @@ class WorkflowExecutor(Executor):
|
||||
for event in request_info_events:
|
||||
request_id = event.request_id
|
||||
response_type = event.response_type
|
||||
# Track the pending request in execution context
|
||||
execution_context.pending_requests[request_id] = event
|
||||
# Map request to execution for response routing
|
||||
self._request_to_execution[request_id] = execution_context.execution_id
|
||||
if self._propagate_request:
|
||||
# In a workflow where the parent workflow does not handle the request, the request
|
||||
# should be propagated via the `request_info` mechanism to an external source. And
|
||||
@@ -600,9 +555,6 @@ class WorkflowExecutor(Executor):
|
||||
# request and handle it directly, a message should be sent.
|
||||
await ctx.send_message(SubWorkflowRequestMessage(source_event=event, executor_id=self.id))
|
||||
|
||||
# Update expected response count for this execution
|
||||
execution_context.expected_response_count = len(request_info_events)
|
||||
|
||||
# Handle final state
|
||||
if workflow_run_state == WorkflowRunState.FAILED:
|
||||
# Find the failed event (type='failed').
|
||||
@@ -621,26 +573,18 @@ class WorkflowExecutor(Executor):
|
||||
await ctx.add_event(error_event)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE:
|
||||
# Sub-workflow is idle - nothing more to do now
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
logger.debug(f"Sub-workflow {self.workflow.id} is idle")
|
||||
elif workflow_run_state == WorkflowRunState.CANCELLED:
|
||||
# Sub-workflow was cancelled - treat as completion
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} was cancelled with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
logger.debug(f"Sub-workflow {self.workflow.id} was cancelled")
|
||||
elif workflow_run_state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
|
||||
# Sub-workflow is still running with pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} "
|
||||
f"pending requests with {len(self._execution_contexts)} active executions"
|
||||
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} pending requests"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
# Sub-workflow is idle but has pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with pending requests: "
|
||||
f"{len(request_info_events)} with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
logger.debug(f"Sub-workflow {self.workflow.id} is idle with pending requests: {len(request_info_events)}")
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected workflow run state: {workflow_run_state}")
|
||||
|
||||
@@ -650,48 +594,17 @@ class WorkflowExecutor(Executor):
|
||||
response: Any,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
execution_id = self._request_to_execution.get(request_id)
|
||||
if not execution_id or execution_id not in self._execution_contexts:
|
||||
# The sub-workflow is the source of truth for what it is awaiting. Validate the response
|
||||
# against its pending requests and ignore anything unknown or already handled.
|
||||
pending_requests = await self.workflow._runner_context.get_pending_request_info_events() # pyright: ignore[reportPrivateUsage]
|
||||
if request_id not in pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: {request_id}. "
|
||||
"This response will be ignored."
|
||||
f"WorkflowExecutor {self.id} received a response for an unknown or already-handled "
|
||||
f"request_id: {request_id}. This response will be ignored."
|
||||
)
|
||||
return
|
||||
|
||||
execution_context = self._execution_contexts[execution_id]
|
||||
|
||||
# Check if we have this pending request in the execution context
|
||||
if request_id not in execution_context.pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: "
|
||||
f"{request_id} in execution {execution_id}, ignoring"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove the request from pending list and request mapping
|
||||
execution_context.pending_requests.pop(request_id, None)
|
||||
self._request_to_execution.pop(request_id, None)
|
||||
|
||||
# Accumulate the response in this execution's context
|
||||
execution_context.collected_responses[request_id] = response
|
||||
# Check if we have all expected responses for this execution
|
||||
if len(execution_context.collected_responses) < execution_context.expected_response_count:
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
|
||||
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
|
||||
)
|
||||
return # Wait for more responses
|
||||
|
||||
# Send all collected responses to the sub-workflow
|
||||
responses_to_send = dict(execution_context.collected_responses)
|
||||
execution_context.collected_responses.clear() # Clear for next batch
|
||||
|
||||
try:
|
||||
# Resume the sub-workflow with all collected responses
|
||||
result = await self.workflow.run(responses=responses_to_send)
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if not execution_context.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
# Forward the response to the sub-workflow, which resumes and validates it against its own
|
||||
# pending requests, then process whatever the sub-workflow produces.
|
||||
result = await self.workflow.run(responses={request_id: response})
|
||||
await self._process_workflow_result(result, ctx)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.12.0"
|
||||
version = "1.12.1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -2338,6 +2338,42 @@ def test_replace_approval_contents_with_results_uses_result_call_ids_without_pla
|
||||
]
|
||||
|
||||
|
||||
def test_replace_approval_contents_with_results_allows_reused_call_id_after_completion() -> None:
|
||||
"""A completed call must not suppress a later approval request that reuses its id.
|
||||
|
||||
Re-approving the same ``(call_id, function)`` is supported behaviour. If the dedupe
|
||||
matched every occurrence of the id, the fresh request would be dropped and its result
|
||||
attached to the already-answered call, leaving one call with two results.
|
||||
"""
|
||||
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
|
||||
|
||||
completed_call = Content.from_function_call(call_id="call_reused", name="run_skill_script", arguments="{}")
|
||||
completed_result = Content.from_function_result(call_id="call_reused", result="first output")
|
||||
_, request, response = _build_approved_tool_roundtrip(
|
||||
call_id="call_reused", approval_id="approval_2", tool_name="run_skill_script"
|
||||
)
|
||||
|
||||
messages = [
|
||||
Message(role="assistant", contents=[completed_call]),
|
||||
Message(role="tool", contents=[completed_result]),
|
||||
Message(role="assistant", contents=[request]),
|
||||
Message(role="user", contents=[response]),
|
||||
]
|
||||
|
||||
_replace_approval_contents_with_results(
|
||||
messages,
|
||||
_collect_approval_responses(messages),
|
||||
[Content.from_function_result(call_id="call_reused", result="second output")],
|
||||
)
|
||||
|
||||
function_calls = [c for m in messages for c in m.contents if c.type == "function_call"]
|
||||
assert [c.call_id for c in function_calls] == ["call_reused", "call_reused"]
|
||||
results = [c for m in messages for c in m.contents if c.type == "function_result"]
|
||||
assert [(c.call_id, c.result) for c in results] == [
|
||||
("call_reused", "first output"),
|
||||
("call_reused", "second output"),
|
||||
]
|
||||
|
||||
def test_replace_approval_contents_with_results_uses_result_call_ids_for_placeholders() -> None:
|
||||
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
|
||||
|
||||
|
||||
@@ -257,6 +257,43 @@ class TestHITL:
|
||||
assert outputs == ["Final: Looks great!"]
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
async def test_fresh_message_while_pending_requests_warns(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A fresh message while request_info events are pending is allowed but logs a warning."""
|
||||
|
||||
@workflow
|
||||
async def review_wf(doc: str, ctx: RunContext) -> str:
|
||||
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
|
||||
return f"Final: {feedback}"
|
||||
|
||||
result1 = await review_wf.run("my doc")
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
# Starting fresh input while a request is pending does not abandon it, but advances
|
||||
# workflow state so a later response may apply to a moved-on workflow -> warn (but proceed).
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await review_wf.run("another doc")
|
||||
|
||||
assert "request_info event(s) are still pending" in caplog.text
|
||||
assert "a fresh message" in caplog.text
|
||||
|
||||
async def test_responses_while_pending_requests_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Delivering responses is the normal completion path and must not warn."""
|
||||
|
||||
@workflow
|
||||
async def review_wf(doc: str, ctx: RunContext) -> str:
|
||||
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
|
||||
return f"Final: {feedback}"
|
||||
|
||||
result1 = await review_wf.run("my doc")
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result2 = await review_wf.run(responses={"req1": "Looks great!"})
|
||||
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE
|
||||
assert "still pending" not in caplog.text
|
||||
|
||||
async def test_untyped_ctx_parameter(self):
|
||||
"""ctx is injected by parameter name even without a RunContext annotation."""
|
||||
|
||||
|
||||
@@ -35,23 +35,17 @@ async def _downstream(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("from-downstream")
|
||||
|
||||
|
||||
def test_designation_unset_emits_deprecation_warning() -> None:
|
||||
"""State A: WorkflowBuilder built without explicit designation warns."""
|
||||
with pytest.warns(DeprecationWarning, match="output_from or intermediate_output_from") as warning_info:
|
||||
def test_designation_unset_does_not_warn() -> None:
|
||||
"""Omitted designation is the supported all-output default."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", DeprecationWarning)
|
||||
WorkflowBuilder(start_executor=_emit_one).build()
|
||||
assert str(warning_info[0].message) == (
|
||||
"WorkflowBuilder built without explicit output_from or intermediate_output_from; "
|
||||
"every yield_output produces type='output' for compatibility. Pass output_from='all', "
|
||||
"output_from=[...], or intermediate_output_from=[...] to opt into explicit designation - "
|
||||
"explicit designation will be required in a future version."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_designation_unset_preserves_compatibility_all_output_behavior() -> None:
|
||||
"""Omitted designation keeps compatibility all-output behavior while warning."""
|
||||
with pytest.warns(DeprecationWarning, match="output_from or intermediate_output_from"):
|
||||
workflow = WorkflowBuilder(start_executor=_start).add_edge(_start, _downstream).build()
|
||||
"""Omitted designation emits all workflow outputs."""
|
||||
workflow = WorkflowBuilder(start_executor=_start).add_edge(_start, _downstream).build()
|
||||
|
||||
result = await workflow.run([Message(role="user", contents=["hi"])])
|
||||
|
||||
|
||||
@@ -512,6 +512,98 @@ async def test_runner_reset_iteration_count():
|
||||
assert runner._iteration == 0 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_capture_and_restore_checkpoint_object_roundtrip():
|
||||
"""build_checkpoint() then restore_checkpoint() must roundtrip.
|
||||
|
||||
Shared state and executor snapshots are captured into an in-memory ``WorkflowCheckpoint``
|
||||
and restored from it without any storage backend (the path the parent WorkflowExecutor
|
||||
uses to checkpoint a nested sub-workflow).
|
||||
"""
|
||||
|
||||
class CounterExecutor(Executor):
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id=id)
|
||||
self.count = 0
|
||||
|
||||
@handler
|
||||
async def handle(self, message: MockMessage, ctx: WorkflowContext[Any, int]) -> None:
|
||||
self.count += message.data
|
||||
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
return {"count": self.count}
|
||||
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
self.count = int(state.get("count", 0))
|
||||
|
||||
executor = CounterExecutor(id="counter")
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor.id: executor}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Establish some state to capture.
|
||||
executor.count = 7
|
||||
state.set("shared_key", "shared_value")
|
||||
state.commit()
|
||||
|
||||
checkpoint = await runner.build_checkpoint()
|
||||
assert checkpoint.graph_signature_hash == "test_hash"
|
||||
|
||||
# Mutate after capture; restoring must roll back to the captured snapshot.
|
||||
executor.count = 999
|
||||
state.set("shared_key", "mutated")
|
||||
state.commit()
|
||||
|
||||
await runner.restore_checkpoint(checkpoint)
|
||||
|
||||
assert executor.count == 7
|
||||
assert state.get("shared_key") == "shared_value"
|
||||
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_build_checkpoint_includes_in_flight_messages():
|
||||
"""build_checkpoint() must snapshot in-flight messages non-destructively."""
|
||||
executor = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor.id: executor}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="START"))
|
||||
|
||||
checkpoint = await runner.build_checkpoint()
|
||||
|
||||
# The in-flight message is captured in the snapshot ...
|
||||
assert list(checkpoint.messages.keys()) == ["START"]
|
||||
assert len(checkpoint.messages["START"]) == 1
|
||||
# ... without draining it from the runner (capture is non-destructive).
|
||||
assert await ctx.has_messages() is True
|
||||
|
||||
|
||||
async def test_runner_build_checkpoint_do_not_advance_previous_checkpoint_id():
|
||||
"""build_checkpoint() must not advance _previous_checkpoint_id so a later capture chains to it."""
|
||||
executor = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor.id: executor}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Pre-condition: nothing captured yet, so there is no parent to chain back to.
|
||||
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
first = await runner.build_checkpoint()
|
||||
assert first.previous_checkpoint_id is None
|
||||
|
||||
# Capturing advances the tracked checkpoint id to the newly-created checkpoint ...
|
||||
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_restore_checkpoint_rejects_graph_mismatch():
|
||||
"""restore_checkpoint() must reject a checkpoint from a different graph."""
|
||||
runner = Runner([], {}, State(), InProcRunnerContext(), "test_name", graph_signature_hash="hash-a")
|
||||
|
||||
foreign = WorkflowCheckpoint(workflow_name="test_name", graph_signature_hash="hash-b")
|
||||
with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
|
||||
await runner.restore_checkpoint(foreign)
|
||||
|
||||
|
||||
class CheckpointingContext(InProcRunnerContext):
|
||||
"""A context that supports checkpointing for testing."""
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
@@ -15,6 +17,7 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
@@ -465,6 +468,62 @@ async def test_concurrent_sub_workflow_execution() -> None:
|
||||
# (This is implicitly tested by the fact that we got correct results for all emails)
|
||||
|
||||
|
||||
async def test_sub_workflow_warns_on_overlapping_execution(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A new input while a prior sub-workflow execution is awaiting responses logs a warning.
|
||||
|
||||
Overlapping executions share one sub-workflow instance and its state, so WorkflowExecutor
|
||||
allows the new execution but warns that it is only safe when the wrapped workflow is stateless.
|
||||
"""
|
||||
|
||||
class TwoInputParent(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="two_input_parent")
|
||||
self._pending: dict[str, SubWorkflowRequestMessage] = {}
|
||||
|
||||
@handler
|
||||
async def start(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
for email in emails:
|
||||
await ctx.send_message(EmailValidationRequest(email=email))
|
||||
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
sub_workflow_request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
domain_request = sub_workflow_request.source_event.data
|
||||
assert isinstance(domain_request, DomainCheckRequest)
|
||||
self._pending[domain_request.id] = sub_workflow_request
|
||||
await ctx.request_info(domain_request, bool)
|
||||
|
||||
@handler
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None: ...
|
||||
|
||||
parent = TwoInputParent()
|
||||
workflow_executor = WorkflowExecutor(create_email_validation_workflow(), "email_workflow")
|
||||
main_workflow = (
|
||||
WorkflowBuilder(start_executor=parent)
|
||||
.add_edge(parent, workflow_executor)
|
||||
.add_edge(workflow_executor, parent)
|
||||
.build()
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework._workflows._workflow_executor"):
|
||||
result = await main_workflow.run(["a@domain1.com", "b@domain2.com"])
|
||||
|
||||
# Two inputs are delivered to the same WorkflowExecutor in one superstep: the second execution
|
||||
# starts while the sub-workflow still has a pending request, producing exactly one overlap
|
||||
# warning from the WorkflowExecutor. (The substring is unique to the WorkflowExecutor warning so
|
||||
# it is not confused with the core Workflow.run pending-request warning.)
|
||||
assert len(result.get_request_info_events()) == 2
|
||||
overlap_warnings = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING and "new input message while its sub-workflow" in record.getMessage()
|
||||
]
|
||||
assert len(overlap_warnings) == 1
|
||||
|
||||
|
||||
# region Checkpoint-related message types and executors for sub-workflow tests
|
||||
|
||||
|
||||
@@ -619,6 +678,59 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
|
||||
assert request_events[0].data.prompt == "Second request"
|
||||
|
||||
|
||||
async def test_sub_workflow_checkpoint_restore_preserves_sub_workflow_state() -> None:
|
||||
"""Resuming a sub-workflow mid-progress must restore its internal executor state.
|
||||
|
||||
Regression guard for the issue where only the WorkflowExecutor's bookkeeping (pending
|
||||
requests) was checkpointed, so a sub-workflow executor that accumulates state across
|
||||
multiple request/response cycles (here ``TwoStepSubWorkflowExecutor._responses``) lost
|
||||
that state on resume. With the sub-workflow's own checkpoint embedded in the parent
|
||||
checkpoint, the second response now completes the two-step flow instead of triggering a
|
||||
spurious third request.
|
||||
"""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
# Step 1: run until the first request.
|
||||
workflow1 = _build_checkpoint_test_workflow(storage)
|
||||
first_request_id: str | None = None
|
||||
async for event in workflow1.run("test_value", stream=True):
|
||||
if event.type == "request_info":
|
||||
first_request_id = event.request_id
|
||||
assert first_request_id is not None
|
||||
|
||||
# Step 2: answer the first request so the sub-workflow accumulates internal state
|
||||
# (``_responses == ["first_answer"]``) and emits the second request. This mid-progress
|
||||
# point is what we checkpoint and resume from - the case the no-duplicate test (which
|
||||
# checkpoints at the first request, before any state accrues) does not cover.
|
||||
second_request_id: str | None = None
|
||||
async for event in workflow1.run(stream=True, responses={first_request_id: "first_answer"}):
|
||||
if event.type == "request_info":
|
||||
second_request_id = event.request_id
|
||||
assert second_request_id is not None
|
||||
|
||||
# Resume from the latest checkpoint (captured after the second request was made).
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow1.name)
|
||||
checkpoint_id = max(checkpoints, key=lambda cp: cp.iteration_count).checkpoint_id
|
||||
|
||||
workflow2 = _build_checkpoint_test_workflow(storage)
|
||||
resumed_second_request_id: str | None = None
|
||||
async for event in workflow2.run(checkpoint_id=checkpoint_id, stream=True):
|
||||
if event.type == "request_info":
|
||||
resumed_second_request_id = event.request_id
|
||||
assert resumed_second_request_id is not None
|
||||
assert resumed_second_request_id == second_request_id
|
||||
|
||||
# Step 3: answer the second request. With the sub-workflow's state restored, the two-step
|
||||
# executor completes instead of emitting a spurious third request. If the internal state
|
||||
# were lost, the second answer would be treated as a first answer and a third request
|
||||
# ("Second request") would be emitted.
|
||||
result = await workflow2.run(responses={resumed_second_request_id: "second_answer"})
|
||||
assert result.get_request_info_events() == [], (
|
||||
"Sub-workflow internal state was lost on resume: a spurious extra request was emitted"
|
||||
)
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_sub_workflow_intermediate_outputs_propagate_to_parent() -> None:
|
||||
"""A child workflow's intermediate emissions must bubble up through the parent.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import logging
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterable, Awaitable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
@@ -109,6 +110,38 @@ class MockExecutorRequestApproval(Executor):
|
||||
await ctx.send_message(NumberMessage(data=data))
|
||||
|
||||
|
||||
async def test_fresh_message_while_pending_advances_state_without_abandoning_requests(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A fresh message while a request is pending is allowed but hazardous.
|
||||
|
||||
A fresh ``message`` does NOT abandon the pending request - it can still be answered
|
||||
later - but the new run advances executor state, so a response for the earlier request
|
||||
applies to a workflow that has moved on. The run is allowed and a warning is emitted.
|
||||
"""
|
||||
executor = MockExecutorRequestApproval(id="approver")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Turn 1: request approval for data=1 -> workflow idles with a pending request.
|
||||
result1 = await workflow.run(NumberMessage(data=1))
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
original_request_id = result1.get_request_info_events()[0].request_id
|
||||
|
||||
# Turn 2: a fresh message for data=2 while the first request is still pending. This is
|
||||
# allowed but warns, and advances the executor's stored state from 1 to 2.
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result2 = await workflow.run(NumberMessage(data=2))
|
||||
assert "request_info event(s) are still pending" in caplog.text
|
||||
assert "a fresh message" in caplog.text
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
# Turn 3: the ORIGINAL request is still answerable, proving the fresh message did not
|
||||
# abandon it. But because the executor state moved on to 2, the response applies to the
|
||||
# moved-on state and yields 2, not the original 1.
|
||||
result3 = await workflow.run(responses={original_request_id: ApprovalMessage(approved=True)})
|
||||
assert result3.get_outputs() == [2]
|
||||
|
||||
|
||||
async def test_workflow_run_streaming() -> None:
|
||||
"""Test the workflow run stream."""
|
||||
executor_a = IncrementExecutor(id="executor_a")
|
||||
|
||||
+6
-6
@@ -2452,9 +2452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2709,9 +2709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1250,17 +1250,17 @@ balanced-match@^1.0.0:
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.12"
|
||||
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz"
|
||||
integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==
|
||||
version "1.1.16"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.16.tgz#723d3a30c0558c225abc9fc479a73e14e26c3c2f"
|
||||
integrity sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz"
|
||||
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.2.tgz#0bba2271feb7d458b0d31ad13625aaa4754431e2"
|
||||
integrity sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
|
||||
|
||||
@@ -109,6 +109,17 @@ class CapturingRunnerContext(RunnerContext):
|
||||
) -> str:
|
||||
raise NotImplementedError("Checkpointing is not supported in activity context")
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: str | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
raise NotImplementedError("Checkpointing is not supported in activity context")
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
raise NotImplementedError("Checkpointing is not supported in activity context")
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for the persistent async bridge used by durable handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import agent_framework_durabletask._async_bridge as async_bridge
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_bridge_globals() -> Iterator[None]:
|
||||
old_loop = async_bridge._loop
|
||||
old_thread = async_bridge._thread
|
||||
old_lock = async_bridge._lock
|
||||
|
||||
async_bridge._loop = None
|
||||
async_bridge._thread = None
|
||||
async_bridge._lock = threading.Lock()
|
||||
|
||||
yield
|
||||
|
||||
new_loop = async_bridge._loop
|
||||
new_thread = async_bridge._thread
|
||||
if new_loop is not None and not new_loop.is_closed():
|
||||
new_loop.call_soon_threadsafe(new_loop.stop)
|
||||
if new_thread is not None and new_thread.is_alive():
|
||||
new_thread.join(timeout=1)
|
||||
if new_loop is not None and not new_loop.is_closed():
|
||||
new_loop.close()
|
||||
|
||||
async_bridge._loop = old_loop
|
||||
async_bridge._thread = old_thread
|
||||
async_bridge._lock = old_lock
|
||||
|
||||
|
||||
def test_ensure_loop_reuses_existing_live_loop() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = Mock()
|
||||
thread.is_alive.return_value = True
|
||||
async_bridge._loop = loop
|
||||
async_bridge._thread = thread
|
||||
|
||||
assert async_bridge._ensure_loop() is loop
|
||||
|
||||
|
||||
def test_ensure_loop_replaces_orphaned_loop() -> None:
|
||||
orphaned_loop = asyncio.new_event_loop()
|
||||
dead_thread = Mock()
|
||||
dead_thread.is_alive.return_value = False
|
||||
async_bridge._loop = orphaned_loop
|
||||
async_bridge._thread = dead_thread
|
||||
|
||||
new_loop = async_bridge._ensure_loop()
|
||||
|
||||
assert new_loop is not orphaned_loop
|
||||
assert orphaned_loop.is_closed() is True
|
||||
assert async_bridge._thread is not None
|
||||
assert async_bridge._thread.is_alive() is True
|
||||
|
||||
|
||||
def test_run_agent_coroutine_executes_on_shared_loop() -> None:
|
||||
async def _compute() -> str:
|
||||
await asyncio.sleep(0)
|
||||
return "done"
|
||||
|
||||
assert async_bridge.run_agent_coroutine(_compute()) == "done"
|
||||
assert async_bridge._loop is not None
|
||||
assert async_bridge._thread is not None
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for the standalone durabletask workflow-context adapter."""
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_durabletask._workflows.dt_context import DurableTaskWorkflowContext
|
||||
|
||||
|
||||
class _FakeDurableAIAgent:
|
||||
def __init__(self, executor: Any, name: str) -> None:
|
||||
self.executor = executor
|
||||
self.name = name
|
||||
|
||||
def run(self, message: str, *, session: Any) -> dict[str, Any]:
|
||||
return {"message": message, "session": session, "executor": self.executor, "name": self.name}
|
||||
|
||||
|
||||
class _FakeTask:
|
||||
def __init__(self, result: Any) -> None:
|
||||
self._result = result
|
||||
|
||||
def get_result(self) -> Any:
|
||||
return self._result
|
||||
|
||||
|
||||
class TestDurableTaskWorkflowContext:
|
||||
"""Behavior of the durabletask-host workflow-context adapter."""
|
||||
|
||||
@pytest.fixture
|
||||
def orchestration_context(self) -> Mock:
|
||||
context = Mock()
|
||||
context.instance_id = "instance-456"
|
||||
context.is_replaying = False
|
||||
context.current_utc_datetime = datetime(2025, 2, 3, 4, 5, 6, tzinfo=timezone.utc)
|
||||
context.call_activity.return_value = "activity-task"
|
||||
context.call_sub_orchestrator.return_value = "sub-task"
|
||||
context.wait_for_external_event.return_value = "event-task"
|
||||
context.create_timer.return_value = "timer-task"
|
||||
context.new_uuid.return_value = "uuid-456"
|
||||
return context
|
||||
|
||||
def test_exposes_basic_context_properties(self, orchestration_context: Mock) -> None:
|
||||
workflow_context = DurableTaskWorkflowContext(orchestration_context)
|
||||
|
||||
assert workflow_context.instance_id == "instance-456"
|
||||
assert workflow_context.is_replaying is False
|
||||
assert workflow_context.supports_event_streaming is True
|
||||
assert workflow_context.current_utc_datetime == orchestration_context.current_utc_datetime
|
||||
|
||||
def test_prepare_agent_task_wraps_session_and_executor(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
orchestration_context: Mock,
|
||||
) -> None:
|
||||
monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.DurableAIAgent", _FakeDurableAIAgent)
|
||||
|
||||
workflow_context = DurableTaskWorkflowContext(orchestration_context)
|
||||
result = workflow_context.prepare_agent_task("reviewer", "please approve", "orch-12")
|
||||
|
||||
assert result["message"] == "please approve"
|
||||
assert result["name"] == "reviewer"
|
||||
assert result["session"].durable_session_id.name == "reviewer"
|
||||
assert result["session"].durable_session_id.key == "orch-12"
|
||||
assert result["executor"] is workflow_context._executor
|
||||
|
||||
def test_delegates_activity_and_orchestrator_primitives(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
orchestration_context: Mock,
|
||||
) -> None:
|
||||
monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.when_all", lambda tasks: ("all", tasks))
|
||||
monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.when_any", lambda tasks: ("any", tasks))
|
||||
|
||||
workflow_context = DurableTaskWorkflowContext(orchestration_context)
|
||||
|
||||
assert workflow_context.prepare_activity_task("activity-name", '{"payload": 1}') == "activity-task"
|
||||
orchestration_context.call_activity.assert_called_once_with("activity-name", input='{"payload": 1}')
|
||||
|
||||
assert workflow_context.call_sub_orchestrator("child", {"x": 1}, instance_id="child-2") == "sub-task"
|
||||
orchestration_context.call_sub_orchestrator.assert_called_once_with(
|
||||
"child", input={"x": 1}, instance_id="child-2"
|
||||
)
|
||||
|
||||
assert workflow_context.task_all(["a", "b"]) == ("all", ["a", "b"])
|
||||
assert workflow_context.task_any(["a", "b"]) == ("any", ["a", "b"])
|
||||
|
||||
assert workflow_context.wait_for_external_event("approval") == "event-task"
|
||||
orchestration_context.wait_for_external_event.assert_called_once_with("approval")
|
||||
|
||||
assert workflow_context.create_timer(orchestration_context.current_utc_datetime) == "timer-task"
|
||||
orchestration_context.create_timer.assert_called_once_with(orchestration_context.current_utc_datetime)
|
||||
|
||||
def test_status_uuid_and_task_helpers_delegate(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
orchestration_context: Mock,
|
||||
) -> None:
|
||||
monkeypatch.setattr("agent_framework_durabletask._workflows.dt_context.Task", _FakeTask)
|
||||
workflow_context = DurableTaskWorkflowContext(orchestration_context)
|
||||
|
||||
workflow_context.set_custom_status({"state": "running"})
|
||||
orchestration_context.set_custom_status.assert_called_once_with({"state": "running"})
|
||||
assert workflow_context.new_uuid() == "uuid-456"
|
||||
|
||||
cancellable = Mock()
|
||||
workflow_context.cancel_task(cancellable)
|
||||
cancellable.cancel.assert_called_once_with()
|
||||
|
||||
workflow_context.cancel_task(object())
|
||||
|
||||
assert workflow_context.get_task_result(_FakeTask({"answer": 42})) == {"answer": 42}
|
||||
assert workflow_context.get_task_result(Mock(result="fallback")) == "fallback"
|
||||
@@ -0,0 +1,363 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for shared durable workflow-orchestrator helper functions."""
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
AgentResponse,
|
||||
Executor,
|
||||
Message,
|
||||
)
|
||||
from agent_framework._workflows._edge import FanInEdgeGroup, SingleEdgeGroup
|
||||
from agent_framework._workflows._state import State
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_durabletask._workflows.orchestrator import (
|
||||
SOURCE_HITL_RESPONSE,
|
||||
ExecutorResult,
|
||||
PendingHITLRequest,
|
||||
TaskType,
|
||||
_check_fan_in_ready,
|
||||
_collect_hitl_requests,
|
||||
_deserialize_hitl_response,
|
||||
_prepare_activity_task,
|
||||
_prepare_agent_task,
|
||||
_prepare_all_tasks,
|
||||
_process_activity_result,
|
||||
_route_hitl_response,
|
||||
_route_result_messages,
|
||||
_select_primary_input_type,
|
||||
execute_hitl_response_handler,
|
||||
)
|
||||
from agent_framework_durabletask._workflows.serialization import serialize_value
|
||||
|
||||
|
||||
class _ApprovalModel(BaseModel):
|
||||
approved: bool
|
||||
|
||||
|
||||
def _agent_response(text: str) -> AgentExecutorResponse:
|
||||
assistant = Message(role="assistant", contents=[text])
|
||||
return AgentExecutorResponse(
|
||||
executor_id="exec",
|
||||
agent_response=AgentResponse(messages=[assistant]),
|
||||
full_conversation=[assistant],
|
||||
)
|
||||
|
||||
|
||||
class TestPrepareTaskHelpers:
|
||||
"""Preparation helpers scope names and package activity input correctly."""
|
||||
|
||||
def test_prepare_agent_task_scopes_executor_id_and_extracts_message_text(self) -> None:
|
||||
ctx = Mock()
|
||||
ctx.instance_id = "instance-1"
|
||||
ctx.prepare_agent_task.return_value = "agent-task"
|
||||
request = AgentExecutorRequest(messages=[Message(role="user", contents=["hello there"])])
|
||||
|
||||
task = _prepare_agent_task(ctx, "reviewer", request, "moderation")
|
||||
|
||||
assert task == "agent-task"
|
||||
ctx.prepare_agent_task.assert_called_once_with("moderation-reviewer", "hello there", "instance-1")
|
||||
|
||||
def test_prepare_activity_task_serializes_message_state_and_host_context(self) -> None:
|
||||
ctx = Mock()
|
||||
ctx.prepare_activity_task.return_value = "activity-task"
|
||||
|
||||
task = _prepare_activity_task(
|
||||
ctx,
|
||||
"router",
|
||||
{"payload": 1},
|
||||
"start",
|
||||
{"existing": True},
|
||||
"moderation",
|
||||
{
|
||||
"root_instance_id": "root-1",
|
||||
"root_workflow_name": "outer-workflow",
|
||||
"request_path_prefix": "review~0~",
|
||||
},
|
||||
)
|
||||
|
||||
assert task == "activity-task"
|
||||
activity_name, activity_input_json = ctx.prepare_activity_task.call_args[0]
|
||||
assert activity_name == "dafx-moderation-router"
|
||||
|
||||
activity_input = json.loads(activity_input_json)
|
||||
assert activity_input["executor_id"] == "router"
|
||||
assert activity_input["message"] == serialize_value({"payload": 1})
|
||||
assert activity_input["shared_state_snapshot"] == {"existing": True}
|
||||
assert activity_input["source_executor_ids"] == ["start"]
|
||||
assert activity_input["host_context"] == {
|
||||
"instance_id": "root-1",
|
||||
"workflow_name": "outer-workflow",
|
||||
"request_path_prefix": "review~0~",
|
||||
}
|
||||
|
||||
def test_prepare_all_tasks_groups_agent_messages_for_sequential_followup(self) -> None:
|
||||
ctx = Mock()
|
||||
ctx.instance_id = "instance-9"
|
||||
ctx.prepare_agent_task.return_value = "agent-task"
|
||||
ctx.prepare_activity_task.return_value = "activity-task"
|
||||
|
||||
agent_executor = Mock(spec=AgentExecutor)
|
||||
agent_executor.id = "reviewer"
|
||||
activity_executor = Mock(spec=Executor)
|
||||
activity_executor.id = "router"
|
||||
|
||||
workflow = Mock()
|
||||
workflow.name = "moderation"
|
||||
workflow.executors = {
|
||||
"reviewer": agent_executor,
|
||||
"router": activity_executor,
|
||||
}
|
||||
|
||||
tasks, metadata, remaining = _prepare_all_tasks(
|
||||
ctx,
|
||||
workflow,
|
||||
{
|
||||
"reviewer": [("first", "start"), ("second", "other")],
|
||||
"router": [(False, "reviewer")],
|
||||
},
|
||||
{"x": 1},
|
||||
[0],
|
||||
{
|
||||
"root_instance_id": "root-9",
|
||||
"root_workflow_name": "moderation",
|
||||
"request_path_prefix": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert tasks == ["activity-task", "agent-task"]
|
||||
assert [item.task_type for item in metadata] == [TaskType.ACTIVITY, TaskType.AGENT]
|
||||
assert remaining == [("reviewer", "second", "other")]
|
||||
|
||||
|
||||
class TestHitlHelpers:
|
||||
"""HITL helper functions sanitize, reconstruct, and route responses."""
|
||||
|
||||
def test_deserialize_hitl_response_handles_none_scalar_and_marker_rejection(self) -> None:
|
||||
assert _deserialize_hitl_response(None, None) is None
|
||||
assert _deserialize_hitl_response("approved", None) == "approved"
|
||||
assert _deserialize_hitl_response({"__pickled__": "evil"}, None) is None
|
||||
|
||||
def test_deserialize_hitl_response_reconstructs_typed_payload(self) -> None:
|
||||
result = _deserialize_hitl_response({"approved": True}, f"{__name__}:_ApprovalModel")
|
||||
|
||||
assert isinstance(result, _ApprovalModel)
|
||||
assert result.approved is True
|
||||
|
||||
def test_deserialize_hitl_response_returns_sanitized_dict_when_type_unknown(self) -> None:
|
||||
payload = {"approved": False}
|
||||
|
||||
assert _deserialize_hitl_response(payload, "missing.module:Type") == payload
|
||||
|
||||
async def test_execute_hitl_response_handler_invokes_selected_handler(self) -> None:
|
||||
handler = AsyncMock()
|
||||
executor = Mock()
|
||||
executor.id = "reviewer"
|
||||
executor._find_response_handler.return_value = handler
|
||||
shared_state = State()
|
||||
runner_context = Mock()
|
||||
|
||||
await execute_hitl_response_handler(
|
||||
executor,
|
||||
{
|
||||
"original_request": {"question": "approve?"},
|
||||
"response": {"approved": True},
|
||||
"response_type": f"{__name__}:_ApprovalModel",
|
||||
},
|
||||
shared_state,
|
||||
runner_context,
|
||||
)
|
||||
|
||||
assert handler.await_args is not None
|
||||
response, workflow_context = handler.await_args.args
|
||||
assert isinstance(response, _ApprovalModel)
|
||||
assert response.approved is True
|
||||
assert workflow_context._executor is executor
|
||||
assert workflow_context._runner_context is runner_context
|
||||
assert workflow_context.state is shared_state
|
||||
executor._find_response_handler.assert_called_once()
|
||||
|
||||
async def test_execute_hitl_response_handler_returns_when_no_handler_exists(self) -> None:
|
||||
executor = Mock()
|
||||
executor.id = "reviewer"
|
||||
executor._find_response_handler.return_value = None
|
||||
|
||||
await execute_hitl_response_handler(
|
||||
executor,
|
||||
{"original_request": {"question": "approve?"}, "response": "yes", "response_type": None},
|
||||
State(),
|
||||
Mock(),
|
||||
)
|
||||
|
||||
executor._find_response_handler.assert_called_once()
|
||||
|
||||
def test_collect_hitl_requests_records_pending_entries(self) -> None:
|
||||
pending: dict[str, PendingHITLRequest] = {}
|
||||
|
||||
_collect_hitl_requests(
|
||||
ExecutorResult(
|
||||
executor_id="reviewer",
|
||||
output_message=None,
|
||||
activity_result={
|
||||
"pending_request_info_events": [
|
||||
{
|
||||
"request_id": "req-1",
|
||||
"data": {"question": "approve?"},
|
||||
"request_type": "ApprovalRequest",
|
||||
"response_type": "ApprovalResponse",
|
||||
}
|
||||
]
|
||||
},
|
||||
task_type=TaskType.ACTIVITY,
|
||||
),
|
||||
pending,
|
||||
)
|
||||
|
||||
assert pending["req-1"] == PendingHITLRequest(
|
||||
request_id="req-1",
|
||||
source_executor_id="reviewer",
|
||||
request_data={"question": "approve?"},
|
||||
request_type="ApprovalRequest",
|
||||
response_type="ApprovalResponse",
|
||||
)
|
||||
|
||||
def test_route_hitl_response_enqueues_message_for_source_executor(self) -> None:
|
||||
pending_messages: dict[str, list[tuple[Any, str]]] = {}
|
||||
|
||||
_route_hitl_response(
|
||||
PendingHITLRequest(
|
||||
request_id="req-2",
|
||||
source_executor_id="reviewer",
|
||||
request_data={"question": "approve?"},
|
||||
request_type="ApprovalRequest",
|
||||
response_type="ApprovalResponse",
|
||||
),
|
||||
{"approved": True},
|
||||
pending_messages,
|
||||
)
|
||||
|
||||
assert pending_messages == {
|
||||
"reviewer": [
|
||||
(
|
||||
{
|
||||
"request_id": "req-2",
|
||||
"original_request": {"question": "approve?"},
|
||||
"response": {"approved": True},
|
||||
"response_type": "ApprovalResponse",
|
||||
},
|
||||
f"{SOURCE_HITL_RESPONSE}_req-2",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class TestResultRoutingHelpers:
|
||||
"""Result-processing helpers update state and feed routing queues correctly."""
|
||||
|
||||
def test_process_activity_result_applies_state_updates_and_outputs(self) -> None:
|
||||
shared_state = {"keep": 1, "drop": 2}
|
||||
workflow_outputs: list[Any] = []
|
||||
|
||||
result = _process_activity_result(
|
||||
json.dumps({
|
||||
"shared_state_updates": {"added": 3},
|
||||
"shared_state_deletes": ["drop"],
|
||||
"outputs": ["out-1"],
|
||||
}),
|
||||
"router",
|
||||
shared_state,
|
||||
workflow_outputs,
|
||||
)
|
||||
|
||||
assert result.task_type == TaskType.ACTIVITY
|
||||
assert shared_state == {"keep": 1, "added": 3}
|
||||
assert workflow_outputs == ["out-1"]
|
||||
|
||||
def test_route_result_messages_handles_output_messages_explicit_targets_and_fanin(self) -> None:
|
||||
fan_in_group = FanInEdgeGroup(source_ids=["router", "other"], target_id="joined")
|
||||
edge_group = SingleEdgeGroup(source_id="router", target_id="next", condition=lambda _message: True)
|
||||
workflow = Mock()
|
||||
workflow.edge_groups = [fan_in_group, edge_group]
|
||||
|
||||
next_pending_messages: dict[str, list[tuple[Any, str]]] = {}
|
||||
fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]] = {fan_in_group.id: defaultdict(list)}
|
||||
|
||||
_route_result_messages(
|
||||
ExecutorResult(
|
||||
executor_id="router",
|
||||
output_message=_agent_response("assistant said hello"),
|
||||
activity_result={
|
||||
"sent_messages": [
|
||||
{
|
||||
"message": serialize_value(0),
|
||||
"target_id": "explicit",
|
||||
"source_id": "router",
|
||||
}
|
||||
]
|
||||
},
|
||||
task_type=TaskType.ACTIVITY,
|
||||
),
|
||||
workflow,
|
||||
next_pending_messages,
|
||||
fan_in_pending,
|
||||
)
|
||||
|
||||
assert next_pending_messages["next"][0][1] == "router"
|
||||
assert next_pending_messages["explicit"] == [(0, "router")]
|
||||
assert fan_in_pending[fan_in_group.id]["router"][0][1] == "router"
|
||||
|
||||
def test_check_fan_in_ready_delivers_aggregated_messages(self) -> None:
|
||||
fan_in_group = FanInEdgeGroup(source_ids=["a", "b"], target_id="joined")
|
||||
workflow = Mock()
|
||||
workflow.edge_groups = [fan_in_group]
|
||||
fan_in_pending = {
|
||||
fan_in_group.id: {
|
||||
"a": [("from-a", "a")],
|
||||
"b": [("from-b", "b")],
|
||||
}
|
||||
}
|
||||
next_pending_messages: dict[str, list[tuple[Any, str]]] = {}
|
||||
|
||||
_check_fan_in_ready(workflow, fan_in_pending, next_pending_messages)
|
||||
|
||||
assert next_pending_messages == {"joined": [(["from-a", "from-b"], "a")]}
|
||||
assert fan_in_pending[fan_in_group.id] == defaultdict(list)
|
||||
|
||||
def test_check_fan_in_ready_waits_for_all_sources(self) -> None:
|
||||
fan_in_group = FanInEdgeGroup(source_ids=["a", "b"], target_id="joined")
|
||||
workflow = Mock()
|
||||
workflow.edge_groups = [fan_in_group]
|
||||
fan_in_pending = {fan_in_group.id: {"a": [("from-a", "a")]}}
|
||||
next_pending_messages: dict[str, list[tuple[Any, str]]] = {}
|
||||
|
||||
_check_fan_in_ready(workflow, fan_in_pending, next_pending_messages)
|
||||
|
||||
assert next_pending_messages == {}
|
||||
|
||||
|
||||
class TestPrimaryInputSelection:
|
||||
"""Primary-input type selection skips non-concrete declarations."""
|
||||
|
||||
def test_returns_first_concrete_type(self) -> None:
|
||||
executor = Mock()
|
||||
executor.input_types = ["not-a-type", dict, str]
|
||||
|
||||
assert _select_primary_input_type(executor) is dict
|
||||
|
||||
def test_returns_none_when_no_concrete_type_exists(self) -> None:
|
||||
executor = Mock()
|
||||
executor.input_types = ["not-a-type", Mock()]
|
||||
|
||||
assert _select_primary_input_type(executor) is None
|
||||
@@ -0,0 +1,103 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for the durabletask workflow runner context."""
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import WorkflowEvent, WorkflowMessage
|
||||
from agent_framework._workflows._state import State
|
||||
|
||||
from agent_framework_durabletask._workflows.runner_context import (
|
||||
HOST_METADATA_INSTANCE_ID,
|
||||
HOST_METADATA_REQUEST_PATH_PREFIX,
|
||||
HOST_METADATA_WORKFLOW_NAME,
|
||||
CapturingRunnerContext,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context() -> CapturingRunnerContext:
|
||||
return CapturingRunnerContext()
|
||||
|
||||
|
||||
async def test_send_and_drain_messages(context: CapturingRunnerContext) -> None:
|
||||
message = WorkflowMessage(data="hello", target_id="target", source_id="source")
|
||||
|
||||
await context.send_message(message)
|
||||
|
||||
assert await context.has_messages() is True
|
||||
assert await context.drain_messages() == {"source": [message]}
|
||||
assert await context.has_messages() is False
|
||||
|
||||
|
||||
async def test_events_can_be_queued_and_read(context: CapturingRunnerContext) -> None:
|
||||
event = WorkflowEvent("output", executor_id="exec", data="payload")
|
||||
|
||||
await context.add_event(event)
|
||||
|
||||
assert await context.has_events() is True
|
||||
assert await context.next_event() == event
|
||||
assert await context.has_events() is False
|
||||
|
||||
|
||||
def test_checkpointing_is_unsupported(context: CapturingRunnerContext) -> None:
|
||||
storage = Mock()
|
||||
|
||||
context.set_runtime_checkpoint_storage(storage)
|
||||
context.clear_runtime_checkpoint_storage()
|
||||
|
||||
assert context.has_checkpointing() is False
|
||||
|
||||
|
||||
async def test_checkpoint_methods_raise(context: CapturingRunnerContext) -> None:
|
||||
with pytest.raises(NotImplementedError, match="Checkpointing is not supported"):
|
||||
await context.create_checkpoint("workflow", "sig", State(), None, 1)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Checkpointing is not supported"):
|
||||
await context.load_checkpoint("checkpoint-1")
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Checkpointing is not supported"):
|
||||
await context.apply_checkpoint(Mock())
|
||||
|
||||
|
||||
def test_workflow_configuration_can_be_reset(context: CapturingRunnerContext) -> None:
|
||||
context.set_workflow_id("workflow-123")
|
||||
context.set_streaming(True)
|
||||
context.set_host_metadata({
|
||||
HOST_METADATA_INSTANCE_ID: "root-instance",
|
||||
HOST_METADATA_WORKFLOW_NAME: "wf",
|
||||
HOST_METADATA_REQUEST_PATH_PREFIX: "sub~0~",
|
||||
})
|
||||
context.set_yield_output_classifier(lambda executor_id: None if executor_id == "secret" else "intermediate")
|
||||
|
||||
assert context.is_streaming() is True
|
||||
assert context.host_metadata == {
|
||||
HOST_METADATA_INSTANCE_ID: "root-instance",
|
||||
HOST_METADATA_WORKFLOW_NAME: "wf",
|
||||
HOST_METADATA_REQUEST_PATH_PREFIX: "sub~0~",
|
||||
}
|
||||
assert context.classify_yielded_output("secret") is None
|
||||
assert context.classify_yielded_output("visible") == "intermediate"
|
||||
|
||||
context.reset_for_new_run()
|
||||
|
||||
assert context.is_streaming() is False
|
||||
|
||||
|
||||
async def test_request_info_events_are_tracked(context: CapturingRunnerContext) -> None:
|
||||
event = WorkflowEvent("request_info", executor_id="review", data={"question": "approve?"}, request_id="req-9")
|
||||
|
||||
await context.add_request_info_event(event)
|
||||
|
||||
assert await context.get_pending_request_info_events() == {"req-9": event}
|
||||
assert await context.drain_events() == [event]
|
||||
|
||||
|
||||
async def test_request_info_response_is_not_supported(context: CapturingRunnerContext) -> None:
|
||||
with pytest.raises(NotImplementedError, match="orchestrator level"):
|
||||
await context.send_request_info_response("req-9", {"approved": True})
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.10.2"
|
||||
version = "1.10.3"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
from contextlib import _AsyncGeneratorContextManager # pyright: ignore[reportPrivateUsage]
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
@@ -18,15 +20,19 @@ from agent_framework import (
|
||||
SkillsSourceContext,
|
||||
)
|
||||
from azure.ai.agentserver.core import get_request_context
|
||||
from typing_extensions import override
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from datetime import timedelta
|
||||
|
||||
from agent_framework import Skill
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials import AccessToken, TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Microsoft Entra scope for Foundry data-plane access.
|
||||
@@ -74,24 +80,50 @@ def _toolbox_name_from_endpoint(endpoint: str) -> str:
|
||||
class _ToolboxAuth(httpx.Auth):
|
||||
"""Injects a fresh bearer token and the platform call-id on every request.
|
||||
|
||||
``auth_flow`` runs for *every* outbound request (connection handshake as well
|
||||
as tool calls), so the bearer token is always present. The per-request
|
||||
``x-agent-foundry-call-id`` is read from the request-scoped context populated
|
||||
by the hosting endpoint; it resolves to a fresh value on each request and is
|
||||
absent (no header) for protocol ``1.0.0`` or local development.
|
||||
Both the synchronous (``sync_auth_flow``) and asynchronous (``async_auth_flow``)
|
||||
httpx auth hooks are implemented, so the same auth works regardless of which
|
||||
transport the toolbox client uses. Each runs for *every* outbound request
|
||||
(connection handshake as well as tool calls), so the bearer token is always
|
||||
present. Both synchronous :class:`~azure.core.credentials.TokenCredential` and
|
||||
asynchronous :class:`~azure.core.credentials_async.AsyncTokenCredential`
|
||||
credentials are supported: the async flow awaits an async credential's
|
||||
``get_token``, while the sync flow requires a synchronous credential. The
|
||||
per-request ``x-agent-foundry-call-id`` is read from the request-scoped context
|
||||
populated by the hosting endpoint; it resolves to a fresh value on each request
|
||||
and is absent (no header) for protocol ``1.0.0`` or local development.
|
||||
"""
|
||||
|
||||
def __init__(self, credential: TokenCredential, scope: str) -> None:
|
||||
def __init__(self, credential: AzureCredentialTypes, scope: str) -> None:
|
||||
self._credential = credential
|
||||
self._scope = scope
|
||||
|
||||
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
# azure-core credentials cache the token internally and only refresh near
|
||||
# expiry, so calling get_token per request is cheap.
|
||||
token = self._credential.get_token(self._scope).token
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
def _apply_headers(self, request: httpx.Request, token: AccessToken) -> None:
|
||||
request.headers["Authorization"] = f"Bearer {token.token}"
|
||||
for key, value in get_request_context().platform_headers().items():
|
||||
request.headers[key] = value
|
||||
|
||||
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
# azure-core credentials cache the token internally and only refresh near
|
||||
# expiry, so calling get_token per request is cheap.
|
||||
token = self._credential.get_token(self._scope)
|
||||
if inspect.isawaitable(token):
|
||||
close = getattr(token, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
raise RuntimeError(
|
||||
"An async credential cannot be used with the synchronous auth flow; "
|
||||
"use a synchronous TokenCredential or drive the toolbox with an httpx.AsyncClient."
|
||||
)
|
||||
self._apply_headers(request, token)
|
||||
yield request
|
||||
|
||||
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
# Sync credentials return the token directly; async credentials return an
|
||||
# awaitable to await.
|
||||
token = self._credential.get_token(self._scope)
|
||||
if inspect.isawaitable(token):
|
||||
token = await token
|
||||
self._apply_headers(request, token)
|
||||
yield request
|
||||
|
||||
|
||||
@@ -138,7 +170,7 @@ class FoundryToolbox(MCPStreamableHTTPTool):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: TokenCredential,
|
||||
credential: AzureCredentialTypes,
|
||||
*,
|
||||
url: str | None = None,
|
||||
name: str | None = None,
|
||||
@@ -174,6 +206,9 @@ class FoundryToolbox(MCPStreamableHTTPTool):
|
||||
auth=_ToolboxAuth(credential, token_scope),
|
||||
timeout=timeout,
|
||||
)
|
||||
self._credential = credential
|
||||
self._token_scope = token_scope
|
||||
self._timeout = timeout
|
||||
|
||||
super().__init__(
|
||||
name=tool_name,
|
||||
@@ -183,8 +218,22 @@ class FoundryToolbox(MCPStreamableHTTPTool):
|
||||
load_tools=load_tools,
|
||||
)
|
||||
|
||||
@override
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
"""Get an authenticated MCP HTTP client.
|
||||
|
||||
Recreates the underlying HTTP client if it was previously closed.
|
||||
"""
|
||||
if self._httpx_client is None:
|
||||
self._httpx_client = httpx.AsyncClient(
|
||||
auth=_ToolboxAuth(self._credential, self._token_scope),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return super().get_mcp_client()
|
||||
|
||||
@override
|
||||
async def close(self) -> None:
|
||||
"""Close the MCP session and the toolbox-owned HTTP client."""
|
||||
"""Close the MCP session and toolbox HTTP client while preserving credentials and timeout for reconnection."""
|
||||
try:
|
||||
await super().close()
|
||||
finally:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260721"
|
||||
version = "1.0.0b260722"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -98,4 +98,4 @@ cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_hosting --cov-r
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
"""Unit tests for FoundryToolbox."""
|
||||
|
||||
@@ -20,7 +21,7 @@ from azure.ai.agentserver.core import (
|
||||
)
|
||||
|
||||
from agent_framework_foundry_hosting import FoundryToolbox
|
||||
from agent_framework_foundry_hosting._toolbox import ( # pyright: ignore[reportPrivateUsage]
|
||||
from agent_framework_foundry_hosting._toolbox import (
|
||||
_FoundryToolboxSkillsSource,
|
||||
_resolve_toolbox_endpoint,
|
||||
_toolbox_name_from_endpoint,
|
||||
@@ -57,6 +58,18 @@ class _FakeCredential:
|
||||
return _FakeAccessToken(self._token)
|
||||
|
||||
|
||||
class _FakeAsyncCredential:
|
||||
"""Minimal stand-in for azure.core.credentials_async.AsyncTokenCredential."""
|
||||
|
||||
def __init__(self, token: str = "fake-token") -> None:
|
||||
self._token = token
|
||||
self.scopes: list[str] = []
|
||||
|
||||
async def get_token(self, *scopes: str, **kwargs: object) -> _FakeAccessToken:
|
||||
self.scopes.extend(scopes)
|
||||
return _FakeAccessToken(self._token)
|
||||
|
||||
|
||||
def test_resolve_endpoint_prefers_explicit_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("TOOLBOX_ENDPOINT", "https://host/toolboxes/tb/mcp?api-version=v1")
|
||||
assert _resolve_toolbox_endpoint() == "https://host/toolboxes/tb/mcp?api-version=v1"
|
||||
@@ -106,36 +119,65 @@ def test_init_derives_name_and_defaults() -> None:
|
||||
assert toolbox.load_prompts_flag is False
|
||||
|
||||
|
||||
def test_auth_flow_injects_bearer_token() -> None:
|
||||
async def test_auth_flow_injects_bearer_token() -> None:
|
||||
cred = _FakeCredential("abc123")
|
||||
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore
|
||||
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
|
||||
|
||||
flow = auth.auth_flow(request)
|
||||
prepared = next(flow)
|
||||
prepared = await anext(auth.async_auth_flow(request))
|
||||
|
||||
assert prepared.headers["Authorization"] == "Bearer abc123"
|
||||
assert cred.scopes == ["https://ai.azure.com/.default"]
|
||||
|
||||
|
||||
def test_auth_flow_forwards_call_id_when_present() -> None:
|
||||
async def test_auth_flow_injects_bearer_token_async_credential() -> None:
|
||||
cred = _FakeAsyncCredential("async123")
|
||||
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore
|
||||
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
|
||||
|
||||
prepared = await anext(auth.async_auth_flow(request))
|
||||
|
||||
assert prepared.headers["Authorization"] == "Bearer async123"
|
||||
assert cred.scopes == ["https://ai.azure.com/.default"]
|
||||
|
||||
|
||||
def test_sync_auth_flow_injects_bearer_token() -> None:
|
||||
cred = _FakeCredential("sync123")
|
||||
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore
|
||||
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
|
||||
|
||||
prepared = next(auth.sync_auth_flow(request))
|
||||
|
||||
assert prepared.headers["Authorization"] == "Bearer sync123"
|
||||
assert cred.scopes == ["https://ai.azure.com/.default"]
|
||||
|
||||
|
||||
def test_sync_auth_flow_rejects_async_credential() -> None:
|
||||
auth = _ToolboxAuth(_FakeAsyncCredential(), "scope") # type: ignore
|
||||
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
|
||||
|
||||
with pytest.raises(RuntimeError, match="async credential"):
|
||||
next(auth.sync_auth_flow(request))
|
||||
|
||||
|
||||
async def test_auth_flow_forwards_call_id_when_present() -> None:
|
||||
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
|
||||
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
|
||||
|
||||
token = set_request_context(FoundryAgentRequestContext(call_id="call-xyz"))
|
||||
try:
|
||||
prepared = next(auth.auth_flow(request))
|
||||
prepared = await anext(auth.async_auth_flow(request))
|
||||
finally:
|
||||
reset_request_context(token)
|
||||
|
||||
assert prepared.headers["x-agent-foundry-call-id"] == "call-xyz"
|
||||
|
||||
|
||||
def test_auth_flow_omits_call_id_when_absent() -> None:
|
||||
async def test_auth_flow_omits_call_id_when_absent() -> None:
|
||||
auth = _ToolboxAuth(_FakeCredential(), "scope") # type: ignore
|
||||
request = httpx.Request("POST", "https://h/toolboxes/tb/mcp")
|
||||
|
||||
prepared = next(auth.auth_flow(request))
|
||||
prepared = await anext(auth.async_auth_flow(request))
|
||||
|
||||
assert "x-agent-foundry-call-id" not in prepared.headers
|
||||
|
||||
@@ -145,9 +187,9 @@ async def test_close_closes_owned_http_client() -> None:
|
||||
_FakeCredential(), # type: ignore
|
||||
url="https://h/toolboxes/tb/mcp",
|
||||
)
|
||||
client = toolbox._httpx_client # pyright: ignore[reportPrivateUsage]
|
||||
client = toolbox._httpx_client
|
||||
assert client is not None
|
||||
client.aclose = AsyncMock() # type: ignore[method-assign]
|
||||
client.aclose = AsyncMock() # zuban: ignore
|
||||
|
||||
await toolbox.close()
|
||||
|
||||
@@ -174,9 +216,9 @@ def test_as_skills_provider_requires_approval_by_default() -> None:
|
||||
)
|
||||
provider = toolbox.as_skills_provider()
|
||||
# By default every skill tool keeps its approval requirement.
|
||||
assert provider._disable_load_skill_approval is False # pyright: ignore[reportPrivateUsage]
|
||||
assert provider._disable_read_skill_resource_approval is False # pyright: ignore[reportPrivateUsage]
|
||||
assert provider._disable_run_skill_script_approval is False # pyright: ignore[reportPrivateUsage]
|
||||
assert provider._disable_load_skill_approval is False
|
||||
assert provider._disable_read_skill_resource_approval is False
|
||||
assert provider._disable_run_skill_script_approval is False
|
||||
|
||||
|
||||
def test_as_skills_provider_forwards_approval_overrides() -> None:
|
||||
@@ -191,9 +233,9 @@ def test_as_skills_provider_forwards_approval_overrides() -> None:
|
||||
)
|
||||
# Overrides flow through to the underlying SkillsProvider so an unattended
|
||||
# host (no AgentSession) can load skills without an approval round-trip.
|
||||
assert provider._disable_load_skill_approval is True # pyright: ignore[reportPrivateUsage]
|
||||
assert provider._disable_read_skill_resource_approval is True # pyright: ignore[reportPrivateUsage]
|
||||
assert provider._disable_run_skill_script_approval is True # pyright: ignore[reportPrivateUsage]
|
||||
assert provider._disable_load_skill_approval is True
|
||||
assert provider._disable_read_skill_resource_approval is True
|
||||
assert provider._disable_run_skill_script_approval is True
|
||||
|
||||
|
||||
async def test_skills_source_requires_connection() -> None:
|
||||
@@ -248,9 +290,9 @@ async def test_skills_source_requires_connection_via_provider() -> None:
|
||||
source = _FoundryToolboxSkillsSource(toolbox)
|
||||
# Discovery captures the bound provider; a later reconnect gap (session is None)
|
||||
# surfaces the same clear error when the provider is resolved.
|
||||
toolbox.session = None # type: ignore
|
||||
toolbox.session = None
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
source._require_session() # pyright: ignore[reportPrivateUsage]
|
||||
source._require_session()
|
||||
|
||||
|
||||
class _FakeSkill:
|
||||
@@ -291,7 +333,7 @@ async def test_as_skills_provider_caches_by_default(monkeypatch: pytest.MonkeyPa
|
||||
provider = toolbox.as_skills_provider()
|
||||
context = _source_context()
|
||||
for _ in range(3):
|
||||
await provider._source.get_skills(context) # pyright: ignore[reportPrivateUsage]
|
||||
await provider._source.get_skills(context)
|
||||
|
||||
# By default the toolbox index is read once and reused across agent runs.
|
||||
assert read_count[0] == 1
|
||||
@@ -308,7 +350,7 @@ async def test_as_skills_provider_disable_caching_rereads_every_run(monkeypatch:
|
||||
provider = toolbox.as_skills_provider(disable_caching=True)
|
||||
context = _source_context()
|
||||
for _ in range(3):
|
||||
await provider._source.get_skills(context) # pyright: ignore[reportPrivateUsage]
|
||||
await provider._source.get_skills(context)
|
||||
|
||||
# With caching disabled the index is re-read on every agent run.
|
||||
assert read_count[0] == 3
|
||||
@@ -329,6 +371,65 @@ async def test_as_skills_provider_cache_refresh_interval_rereads_after_staleness
|
||||
provider = toolbox.as_skills_provider(cache_refresh_interval=timedelta(0))
|
||||
context = _source_context()
|
||||
for _ in range(3):
|
||||
await provider._source.get_skills(context) # pyright: ignore[reportPrivateUsage]
|
||||
await provider._source.get_skills(context)
|
||||
|
||||
assert read_count[0] == 3
|
||||
|
||||
|
||||
class TestFoundryToolboxReconnection:
|
||||
async def test_close_preserves_credential_for_reconnection(self) -> None:
|
||||
"""After close(), get_mcp_client() should recreate an authenticated client."""
|
||||
cred = _FakeCredential("reconnect-token")
|
||||
toolbox = FoundryToolbox(
|
||||
cred, # type: ignore
|
||||
url="https://h/toolboxes/recon/mcp",
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
assert toolbox._credential is cred
|
||||
assert toolbox._token_scope == "https://ai.azure.com/.default"
|
||||
assert toolbox._timeout == 60.0
|
||||
|
||||
assert toolbox._httpx_client is not None
|
||||
assert isinstance(toolbox._httpx_client.auth, _ToolboxAuth)
|
||||
original_auth = toolbox._httpx_client.auth
|
||||
|
||||
client = toolbox._httpx_client
|
||||
client.aclose = AsyncMock() # zuban: ignore
|
||||
await toolbox.close()
|
||||
|
||||
client.aclose.assert_awaited_once()
|
||||
assert toolbox._httpx_client is None
|
||||
|
||||
assert toolbox._credential is cred
|
||||
assert toolbox._timeout == 60.0
|
||||
|
||||
ctx_manager = toolbox.get_mcp_client()
|
||||
assert toolbox._httpx_client is not None
|
||||
assert isinstance(toolbox._httpx_client.auth, _ToolboxAuth)
|
||||
|
||||
new_auth = toolbox._httpx_client.auth
|
||||
assert new_auth is not original_auth
|
||||
assert new_auth._credential is cred
|
||||
|
||||
assert hasattr(ctx_manager, "__aenter__")
|
||||
assert hasattr(ctx_manager, "__aexit__")
|
||||
|
||||
await toolbox.close()
|
||||
|
||||
async def test_close_idempotent_with_reconnection(self) -> None:
|
||||
"""Multiple close() calls don't break reconnection."""
|
||||
cred = _FakeCredential()
|
||||
toolbox = FoundryToolbox(
|
||||
cred, # type: ignore
|
||||
url="https://h/toolboxes/idem/mcp",
|
||||
)
|
||||
|
||||
await toolbox.close()
|
||||
await toolbox.close()
|
||||
|
||||
toolbox.get_mcp_client()
|
||||
assert toolbox._httpx_client is not None
|
||||
assert isinstance(toolbox._httpx_client.auth, _ToolboxAuth)
|
||||
|
||||
await toolbox.close()
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260721"
|
||||
version = "1.0.0b260722"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-github-copilot --pre
|
||||
pip install agent-framework-github-copilot
|
||||
```
|
||||
|
||||
## GitHub Copilot Agent
|
||||
|
||||
@@ -30,8 +30,12 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import FunctionTool, ToolTypes
|
||||
from agent_framework._types import AgentRunInputs, normalize_tools
|
||||
from agent_framework.exceptions import AgentException
|
||||
from agent_framework._types import (
|
||||
AgentRunInputs,
|
||||
_get_data_bytes_as_str, # pyright: ignore[reportPrivateUsage]
|
||||
normalize_tools,
|
||||
)
|
||||
from agent_framework.exceptions import AgentException, ContentError
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -47,6 +51,8 @@ try:
|
||||
from copilot import CopilotClient, CopilotSession, RuntimeConnection
|
||||
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
|
||||
from copilot.session import (
|
||||
Attachment,
|
||||
BlobAttachment,
|
||||
MCPServerConfig,
|
||||
PermissionRequestResult,
|
||||
PreToolUseHandler,
|
||||
@@ -656,10 +662,11 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
prompt = "\n".join([message.text for message in context_messages])
|
||||
if session_context.instructions:
|
||||
prompt = "\n".join(session_context.instructions) + "\n" + prompt
|
||||
attachments = self._prepare_attachments_for_copilot(context_messages)
|
||||
|
||||
unsubscribe = copilot_session.on(usage_event_handler)
|
||||
try:
|
||||
response_event = await copilot_session.send_and_wait(prompt, timeout=timeout)
|
||||
response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout)
|
||||
except Exception as ex:
|
||||
raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
|
||||
finally:
|
||||
@@ -762,6 +769,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
prompt = "\n".join([message.text for message in context_messages])
|
||||
if session_context.instructions:
|
||||
prompt = "\n".join(session_context.instructions) + "\n" + prompt
|
||||
attachments = self._prepare_attachments_for_copilot(context_messages)
|
||||
|
||||
queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue()
|
||||
|
||||
@@ -844,7 +852,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
unsubscribe = copilot_session.on(event_handler)
|
||||
|
||||
try:
|
||||
await copilot_session.send(prompt)
|
||||
await copilot_session.send(prompt, attachments=attachments)
|
||||
|
||||
while (item := await queue.get()) is not None:
|
||||
if isinstance(item, Exception):
|
||||
@@ -916,6 +924,56 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
elif opts_system_message is not None:
|
||||
opts["system_message"] = opts_system_message
|
||||
|
||||
@staticmethod
|
||||
def _prepare_attachments_for_copilot(messages: Sequence[Message]) -> list[Attachment] | None:
|
||||
"""Convert inline binary message content into Copilot SDK attachments.
|
||||
|
||||
Scans the outgoing messages for ``data`` content (binary payloads such as
|
||||
images or documents carried as base64 data URIs) and maps each one to an
|
||||
inline ``blob`` attachment understood by the Copilot SDK.
|
||||
|
||||
Only base64 ``data:`` content is forwarded as an attachment. Other content
|
||||
is not turned into an attachment: text content is already carried in the
|
||||
prompt, while remote URIs (for example ``https://`` links) and malformed or
|
||||
non-base64 ``data:`` URIs are skipped -- they are neither attached nor added
|
||||
to the prompt.
|
||||
|
||||
Args:
|
||||
messages: The messages being sent to the Copilot session.
|
||||
|
||||
Returns:
|
||||
A list of Copilot ``Attachment`` objects, or ``None`` when the messages
|
||||
contain no attachable binary content.
|
||||
"""
|
||||
attachments: list[Attachment] = []
|
||||
for message in messages:
|
||||
for content in message.contents:
|
||||
if content.type != "data":
|
||||
continue
|
||||
try:
|
||||
data_str = _get_data_bytes_as_str(content)
|
||||
except ContentError:
|
||||
logger.warning(
|
||||
"Skipping GitHub Copilot attachment with an unsupported data URI; "
|
||||
"only base64-encoded 'data:' URIs can be forwarded as attachments."
|
||||
)
|
||||
continue
|
||||
if not data_str:
|
||||
continue
|
||||
if not content.media_type:
|
||||
logger.warning(
|
||||
"Dropping GitHub Copilot attachment with no media type; the Copilot SDK "
|
||||
"requires a MIME type for inline binary content."
|
||||
)
|
||||
continue
|
||||
blob: BlobAttachment = {
|
||||
"type": "blob",
|
||||
"data": data_str,
|
||||
"mimeType": content.media_type,
|
||||
}
|
||||
attachments.append(blob)
|
||||
return attachments or None
|
||||
|
||||
def _prepare_tools(
|
||||
self,
|
||||
tools: Sequence[ToolTypes | CopilotTool],
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0rc4"
|
||||
version = "1.0.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# ruff: noqa: E402
|
||||
|
||||
import base64
|
||||
import inspect
|
||||
import os
|
||||
import unittest.mock
|
||||
@@ -37,7 +38,7 @@ from copilot.session_events import (
|
||||
)
|
||||
from copilot.tools import ToolInvocation, ToolResult
|
||||
|
||||
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
|
||||
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions, RawGitHubCopilotAgent
|
||||
|
||||
|
||||
def copilot_options(options: GitHubCopilotOptions) -> GitHubCopilotOptions:
|
||||
@@ -3351,10 +3352,131 @@ class TestGitHubCopilotAgentContextProviders:
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
|
||||
class TestGitHubCopilotAttachments:
|
||||
"""Tests for forwarding inline binary message content as Copilot attachments."""
|
||||
|
||||
async def test_data_content_forwarded_as_blob_attachment(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_message_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Non-streaming: DataContent is sent to the SDK as an inline blob attachment."""
|
||||
mock_session.send_and_wait.return_value = assistant_message_event
|
||||
image_bytes = b"\x89PNG\r\n\x1a\n-fake-image"
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_text("Describe this image"),
|
||||
Content.from_data(data=image_bytes, media_type="image/png"),
|
||||
],
|
||||
)
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
await agent.run(message)
|
||||
|
||||
attachments = mock_session.send_and_wait.call_args.kwargs["attachments"]
|
||||
assert attachments is not None
|
||||
assert len(attachments) == 1
|
||||
assert attachments[0]["type"] == "blob"
|
||||
assert attachments[0]["mimeType"] == "image/png"
|
||||
assert base64.b64decode(attachments[0]["data"]) == image_bytes
|
||||
|
||||
async def test_data_content_forwarded_as_blob_attachment_streaming(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Streaming: DataContent is sent to the SDK as an inline blob attachment."""
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
image_bytes = b"\x89PNG\r\n\x1a\n-fake-image"
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_text("Describe this image"),
|
||||
Content.from_data(data=image_bytes, media_type="image/png"),
|
||||
],
|
||||
)
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
async for _ in agent.run(message, stream=True):
|
||||
pass
|
||||
|
||||
attachments = mock_session.send.call_args.kwargs["attachments"]
|
||||
assert attachments is not None
|
||||
assert len(attachments) == 1
|
||||
assert attachments[0]["type"] == "blob"
|
||||
assert attachments[0]["mimeType"] == "image/png"
|
||||
assert base64.b64decode(attachments[0]["data"]) == image_bytes
|
||||
|
||||
async def test_text_only_message_sends_no_attachments(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_message_event: SessionEvent,
|
||||
) -> None:
|
||||
"""A text-only message results in no attachments being forwarded."""
|
||||
mock_session.send_and_wait.return_value = assistant_message_event
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
await agent.run("Just text, no attachments")
|
||||
|
||||
assert mock_session.send_and_wait.call_args.kwargs["attachments"] is None
|
||||
|
||||
def test_prepare_attachments_skips_data_without_media_type(self) -> None:
|
||||
"""Data content lacking a media type is dropped rather than sent without a MIME type."""
|
||||
content = Content.from_data(data=b"payload", media_type="application/octet-stream")
|
||||
content.media_type = None
|
||||
message = Message(role="user", contents=[content])
|
||||
|
||||
attachments = GitHubCopilotAgent._prepare_attachments_for_copilot([message])
|
||||
|
||||
assert attachments is None
|
||||
|
||||
async def test_non_base64_data_uri_is_skipped_not_raised(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_message_event: SessionEvent,
|
||||
) -> None:
|
||||
"""A non-base64 ``data:`` URI is skipped by the send path instead of failing the request.
|
||||
|
||||
Uses ``RawGitHubCopilotAgent`` (no telemetry layer) to isolate the provider's own
|
||||
attachment handling. The telemetry layer in ``GitHubCopilotAgent`` independently
|
||||
serializes message content and would trip a separate core limitation on this
|
||||
contrived input, which is unrelated to attachment forwarding.
|
||||
"""
|
||||
mock_session.send_and_wait.return_value = assistant_message_event
|
||||
# ``Content.from_uri`` classifies this as type="data" but it is not base64-encoded,
|
||||
# so extracting its bytes raises ContentError internally.
|
||||
non_base64 = Content.from_uri("data:text/plain,hello")
|
||||
assert non_base64.type == "data"
|
||||
message = Message(role="user", contents=[Content.from_text("hi"), non_base64])
|
||||
|
||||
agent = RawGitHubCopilotAgent(client=mock_client)
|
||||
# Should complete without raising.
|
||||
await agent.run(message)
|
||||
|
||||
assert mock_session.send_and_wait.call_args.kwargs["attachments"] is None
|
||||
|
||||
def test_prepare_attachments_skips_non_base64_data_uri(self) -> None:
|
||||
"""The helper drops a non-base64 ``data:`` URI rather than raising ContentError."""
|
||||
message = Message(role="user", contents=[Content.from_uri("data:text/plain,hello")])
|
||||
|
||||
attachments = GitHubCopilotAgent._prepare_attachments_for_copilot([message])
|
||||
|
||||
assert attachments is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — require COPILOT_GITHUB_TOKEN env var
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
skip_if_copilot_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("COPILOT_GITHUB_TOKEN", "") == "",
|
||||
reason="No COPILOT_GITHUB_TOKEN provided; skipping integration tests.",
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
# A2A Hosting Helpers (`agent-framework-hosting-a2a`)
|
||||
|
||||
Side-effect-free conversion helpers for hosting Agent Framework agents through
|
||||
the native A2A SDK.
|
||||
Conversion and native card-generation helpers for hosting Agent Framework
|
||||
agents and workflows through an application-owned A2A server.
|
||||
|
||||
## Public API
|
||||
|
||||
- `a2a_to_run(message, *, stream=False)` converts an A2A `Message` to
|
||||
`AgentRunArgs`.
|
||||
`AgentRunArgs`; pass `input_modes` for optional advertised-mode validation.
|
||||
- `a2a_from_run(result)` converts an Agent Framework response, message, or
|
||||
streaming update to A2A `Part` values.
|
||||
streaming update to A2A `Part` values; pass `output_modes` for optional
|
||||
advertised-mode validation.
|
||||
- `await AgentA2AAdapter(target, ...).get_card()` creates a native `AgentCard`
|
||||
from an agent or `AgentState`, target metadata, and explicit A2A discovery
|
||||
policy. The adapter also re-exposes `a2a_to_run(...)` and
|
||||
`a2a_from_run(...)`, validating against configured card modes by default.
|
||||
- `a2a_to_workflow_run(message, workflow)` validates one text, raw, or data
|
||||
part against the workflow's single start-executor input type.
|
||||
- `a2a_from_workflow_run(result)` converts completed public workflow outputs
|
||||
to native A2A parts and rejects pending external-input requests.
|
||||
- `await WorkflowA2AAdapter(target, ...).get_card()` creates a native `AgentCard`
|
||||
from a workflow or `WorkflowState` and infers defensible modes from declared
|
||||
workflow types. The adapter also re-exposes the workflow conversion helpers
|
||||
as `await a2a_to_run(...)` and `a2a_from_run(...)`, validating against
|
||||
effective card modes by default. Inferred workflow output modes are resolved
|
||||
by `get_card()` before validated output conversion.
|
||||
|
||||
## Boundary
|
||||
|
||||
@@ -20,3 +35,30 @@ constructs.
|
||||
`a2a_from_run(...)` intentionally returns a flat part list. It preserves
|
||||
content-level metadata, while applications own A2A message and artifact
|
||||
boundaries plus message-level metadata.
|
||||
|
||||
Card builders return native A2A protobuf values; do not create a parallel card
|
||||
model or subclass. `AgentA2AAdapter` infers built-in Agent Framework skills from
|
||||
the resolved agent's `SkillsProvider` instances by default; `infer_skills=False`
|
||||
disables this. The `skills` argument accepts both Agent Framework `Skill`
|
||||
values and native A2A `AgentSkill` values. Do not infer A2A skills from function
|
||||
tools. Capabilities such as streaming and push notifications remain explicit
|
||||
because they describe the application server. Skill discovery runs with a
|
||||
`SkillsSourceContext` containing the resolved agent and no session.
|
||||
|
||||
`supported_interfaces` contains one native `AgentInterface` per public
|
||||
protocol endpoint. The URL is where the application mounted the corresponding
|
||||
A2A routes, and the binding must match the protocol actually served there
|
||||
(commonly `JSONRPC`, `HTTP+JSON`, or `GRPC`).
|
||||
|
||||
A2A mode strings are extensible, but automatic parsing is intentionally
|
||||
limited to `text`, `application/json`, `application/octet-stream`, and
|
||||
pass-through concrete media types (with wildcard validation such as
|
||||
`image/*`). JSON-only output parses Agent Framework JSON text into native A2A
|
||||
data parts; structured workflow output becomes a data part, or JSON text when
|
||||
only `text` is advertised. Custom modes are accepted when a native part already
|
||||
carries that media type, and otherwise raise instead of guessing a serializer.
|
||||
|
||||
Workflow mode inference is conservative: string schemas map to `text`, binary
|
||||
strings to `application/octet-stream`, and JSON-compatible schemas to
|
||||
`application/json`. Unknown application-specific representations require
|
||||
explicit card modes and custom application conversion.
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
# agent-framework-hosting-a2a
|
||||
|
||||
A2A conversion helpers for app-owned Agent Framework hosting.
|
||||
Helpers for composing Agent Framework agents and workflows with an
|
||||
application-owned native A2A server.
|
||||
|
||||
The package deliberately does not choose a web framework or wrap the A2A SDK
|
||||
server lifecycle. It provides two conversion functions:
|
||||
The package converts protocol values and can generate the common discovery
|
||||
fields for a native `AgentCard`. It does not provide an `AgentExecutor`, task
|
||||
lifecycle, event queue, task store, routes, session policy, authentication, or
|
||||
deployment.
|
||||
|
||||
- `a2a_to_run(...)` converts a native A2A `Message` into Agent Framework run
|
||||
arguments.
|
||||
- `a2a_from_run(...)` converts an `AgentResponse`, `Message`, or streaming
|
||||
`AgentResponseUpdate` into native A2A `Part` values.
|
||||
## Choose the level of help
|
||||
|
||||
Application code keeps ownership of the A2A SDK's `AgentExecutor`,
|
||||
`RequestContext`, `TaskUpdater`, event queue, task store, routes, task state,
|
||||
artifact IDs, authentication, and deployment.
|
||||
| API | Adds |
|
||||
| --- | --- |
|
||||
| `a2a_to_run`, `a2a_from_run` | Native A2A-to-agent value conversion |
|
||||
| `AgentA2AAdapter` | Agent card generation plus the agent conversion helpers |
|
||||
| `a2a_to_workflow_run`, `a2a_from_workflow_run` | Typed workflow input and output conversion |
|
||||
| `WorkflowA2AAdapter` | Workflow card generation plus the workflow conversion helpers |
|
||||
|
||||
`a2a_from_run(...)` preserves content-level metadata on each returned part and
|
||||
flattens completed responses in message order. The application decides how to
|
||||
group those parts into A2A messages or artifacts and owns their message-level
|
||||
metadata and boundaries.
|
||||
Each level is optional. Applications keep using native A2A SDK objects and can
|
||||
construct an `AgentCard` directly when they need discovery fields beyond the
|
||||
common generated surface.
|
||||
|
||||
## Agent conversions
|
||||
|
||||
The core helpers work with any native A2A `AgentExecutor`:
|
||||
|
||||
```python
|
||||
run = a2a_to_run(context.message)
|
||||
run = a2a_to_run(context.message, stream=False)
|
||||
session_id = f"a2a:{context.tenant}:{context.context_id}"
|
||||
session = await state.get_or_create_session(session_id)
|
||||
result = await agent.run(
|
||||
run["messages"],
|
||||
session=session,
|
||||
options=run["options"],
|
||||
stream=run["stream"],
|
||||
)
|
||||
await state.set_session(session_id, session)
|
||||
parts = a2a_from_run(result)
|
||||
@@ -34,5 +41,145 @@ parts = a2a_from_run(result)
|
||||
# Native A2A SDK application code publishes `parts` with TaskUpdater.
|
||||
```
|
||||
|
||||
The surrounding A2A application may use Starlette, FastAPI, another ASGI
|
||||
framework, or the SDK's own application builders. These helpers do not care.
|
||||
`a2a_from_run(...)` returns a flat part list and preserves content-level
|
||||
metadata. The application decides how to group those parts into A2A messages
|
||||
or artifacts and owns their message-level metadata and boundaries.
|
||||
|
||||
Standalone conversions are permissive by default. Pass `input_modes` or
|
||||
`output_modes` to validate the converted parts against an advertised contract:
|
||||
|
||||
```python
|
||||
run = a2a_to_run(message, input_modes=["text", "image/*"])
|
||||
parts = a2a_from_run(result, output_modes=["text"])
|
||||
```
|
||||
|
||||
### Mode parsing
|
||||
|
||||
A2A mode strings are extensible; there is no exhaustive protocol-wide list.
|
||||
The helpers have an exhaustive set of built-in parsing behaviors:
|
||||
|
||||
| Mode | Automatic behavior |
|
||||
| --- | --- |
|
||||
| `text` | Uses A2A text parts and Agent Framework text content |
|
||||
| `application/json` | Parses JSON text into an A2A data part and parses A2A data into typed workflow input |
|
||||
| `application/octet-stream` | Uses raw byte parts |
|
||||
| Concrete media types such as `image/png` or `audio/wav` | Preserves matching raw or URL parts |
|
||||
| Wildcards such as `image/*` | Validates matching concrete media types |
|
||||
|
||||
Custom mode strings may still be advertised. They pass validation when the
|
||||
native part already carries that exact media type, but conversion raises when
|
||||
it would need to synthesize that representation without a built-in parser.
|
||||
Configured mode values must be non-empty strings.
|
||||
|
||||
## Supported interfaces
|
||||
|
||||
`supported_interfaces` tells an A2A client where and how it can call the
|
||||
application. Add one `AgentInterface` for each protocol binding the server
|
||||
actually exposes:
|
||||
|
||||
```python
|
||||
supported_interfaces = [
|
||||
AgentInterface(
|
||||
url="https://example.com/a2a",
|
||||
protocol_binding="JSONRPC",
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
The `url` is the public base URL where the matching A2A routes are mounted;
|
||||
include a path such as `/a2a` when the application mounts them below the
|
||||
domain root. `protocol_binding` identifies the wire protocol implemented at
|
||||
that URL, commonly `JSONRPC`, `HTTP+JSON`, or `GRPC`. Advertise only bindings
|
||||
that the application has configured. `protocol_version` and `tenant` are
|
||||
optional native A2A interface fields for deployments that use them.
|
||||
|
||||
## Generate an agent card
|
||||
|
||||
`AgentA2AAdapter` infers the public name and description from the agent and uses
|
||||
conservative text input/output modes. Pass either an agent or an existing
|
||||
`AgentState`; `get_card()` is async so factory-backed states can resolve their
|
||||
target.
|
||||
|
||||
By default, the card also discovers Agent Framework `Skill` values from
|
||||
`SkillsProvider` instances on the agent. The guaranteed skill frontmatter
|
||||
name and description become a native A2A `AgentSkill`, using the card's input
|
||||
and output modes. Set `infer_skills=False` to disable discovery. The `skills`
|
||||
parameter also accepts explicit Agent Framework `Skill` values or fully
|
||||
specified native A2A `AgentSkill` values when tags, examples, security, or
|
||||
skill-specific modes need to be controlled directly. Card discovery happens
|
||||
outside an agent run, so context-aware skill sources receive no session; use
|
||||
explicit skills or disable inference when the advertised list is
|
||||
session-specific.
|
||||
|
||||
Server capabilities stay explicit because they describe the public
|
||||
application contract, not the agent's `run` method.
|
||||
|
||||
The adapter re-exposes `a2a_to_run(...)` and `a2a_from_run(...)`, so a native
|
||||
executor can use the same object for card setup and request conversion without
|
||||
importing the standalone helpers. Adapter conversions validate against the
|
||||
configured card modes by default; pass `validate_modes=False` to opt out:
|
||||
|
||||
```python
|
||||
run = adapter.a2a_to_run(context.message, stream=True)
|
||||
parts = adapter.a2a_from_run(result)
|
||||
```
|
||||
|
||||
```python
|
||||
from a2a.types import AgentCapabilities, AgentInterface
|
||||
from agent_framework_hosting_a2a import AgentA2AAdapter
|
||||
|
||||
card = await AgentA2AAdapter(
|
||||
state,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[
|
||||
AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")
|
||||
],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
).get_card()
|
||||
```
|
||||
|
||||
## Host a workflow
|
||||
|
||||
Workflow input conversion follows the single start-executor input type:
|
||||
|
||||
- strings use one A2A text part;
|
||||
- bytes use one raw part;
|
||||
- structured and scalar JSON values use one data part.
|
||||
|
||||
The output helper converts public workflow outputs to native A2A parts.
|
||||
Pending human-input requests raise so the application can implement its own
|
||||
continuation policy.
|
||||
|
||||
```python
|
||||
workflow_input = a2a_to_workflow_run(context.message, workflow)
|
||||
result = await workflow.run(workflow_input, stream=False)
|
||||
parts = a2a_from_workflow_run(result)
|
||||
```
|
||||
|
||||
`WorkflowA2AAdapter` infers modes from the workflow's declared input and output
|
||||
types. It accepts a workflow or `WorkflowState`. Supply explicit modes for an
|
||||
application-specific representation:
|
||||
|
||||
```python
|
||||
card = await WorkflowA2AAdapter(
|
||||
workflow_state,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[
|
||||
AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")
|
||||
],
|
||||
skills=[workflow_skill],
|
||||
).get_card()
|
||||
```
|
||||
|
||||
It also exposes `await adapter.a2a_to_run(message)` and
|
||||
`adapter.a2a_from_run(result)` for workflow conversion. These methods validate
|
||||
against the effective card modes by default. When workflow output modes are
|
||||
inferred, call `get_card()` before converting output so the adapter has
|
||||
resolved the advertised contract.
|
||||
|
||||
Streaming workflow progress, artifacts, task status, checkpoints, and
|
||||
human-in-the-loop continuation remain part of the native executor and
|
||||
application contract.
|
||||
|
||||
The surrounding application may use Starlette, FastAPI, another ASGI
|
||||
framework, or the A2A SDK's application builders.
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._conversion import a2a_from_run, a2a_to_run
|
||||
from ._adapters import AgentA2AAdapter, WorkflowA2AAdapter
|
||||
from ._conversion import a2a_from_run, a2a_from_workflow_run, a2a_to_run, a2a_to_workflow_run
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -12,7 +13,11 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"AgentA2AAdapter",
|
||||
"WorkflowA2AAdapter",
|
||||
"__version__",
|
||||
"a2a_from_run",
|
||||
"a2a_from_workflow_run",
|
||||
"a2a_to_run",
|
||||
"a2a_to_workflow_run",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Native A2A card adapters for Agent Framework targets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Generic, TypeVar, cast
|
||||
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Part
|
||||
from a2a.types import Message as A2AMessage
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
Message,
|
||||
Skill,
|
||||
SkillsProvider,
|
||||
SkillsSourceContext,
|
||||
SupportsAgentRun,
|
||||
Workflow,
|
||||
WorkflowRunResult,
|
||||
)
|
||||
from agent_framework_hosting import AgentRunArgs, AgentState, WorkflowState
|
||||
|
||||
from ._conversion import (
|
||||
_normalized_modes, # pyright: ignore[reportPrivateUsage]
|
||||
_workflow_input_type, # pyright: ignore[reportPrivateUsage]
|
||||
_workflow_type_modes, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from ._conversion import (
|
||||
a2a_from_run as _a2a_from_run,
|
||||
)
|
||||
from ._conversion import (
|
||||
a2a_from_workflow_run as _a2a_from_workflow_run,
|
||||
)
|
||||
from ._conversion import (
|
||||
a2a_to_run as _a2a_to_run,
|
||||
)
|
||||
from ._conversion import (
|
||||
a2a_to_workflow_run as _a2a_to_workflow_run,
|
||||
)
|
||||
|
||||
AgentT = TypeVar("AgentT", bound=SupportsAgentRun)
|
||||
WorkflowT = TypeVar("WorkflowT", bound=Workflow)
|
||||
|
||||
|
||||
def _to_a2a_skill(skill: AgentSkill | Skill, input_modes: Sequence[str], output_modes: Sequence[str]) -> AgentSkill:
|
||||
if isinstance(skill, AgentSkill):
|
||||
return skill
|
||||
|
||||
frontmatter = skill.frontmatter
|
||||
return AgentSkill(
|
||||
id=frontmatter.name,
|
||||
name=frontmatter.name,
|
||||
description=frontmatter.description,
|
||||
tags=[frontmatter.name],
|
||||
examples=[],
|
||||
input_modes=input_modes,
|
||||
output_modes=output_modes,
|
||||
)
|
||||
|
||||
|
||||
class AgentA2AAdapter(Generic[AgentT]):
|
||||
"""Resolve a native A2A card from Agent Framework agent metadata."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: AgentT | AgentState[AgentT],
|
||||
*,
|
||||
version: str,
|
||||
supported_interfaces: Sequence[AgentInterface],
|
||||
skills: Sequence[AgentSkill | Skill] = (),
|
||||
infer_skills: bool = True,
|
||||
capabilities: AgentCapabilities | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
default_input_modes: Sequence[str] = ("text",),
|
||||
default_output_modes: Sequence[str] = ("text",),
|
||||
) -> None:
|
||||
"""Create an agent-backed A2A card adapter.
|
||||
|
||||
Args:
|
||||
target: Agent or existing ``AgentState`` whose public metadata
|
||||
should seed the card.
|
||||
|
||||
Keyword Args:
|
||||
version: Application-defined version advertised by the A2A server.
|
||||
supported_interfaces: Public A2A endpoints exposed by the application,
|
||||
with one native interface for each available protocol binding. For
|
||||
example, ``from a2a.types import AgentInterface`` and then
|
||||
``[AgentInterface(url="https://example.com/a2a",
|
||||
protocol_binding="JSONRPC")]``.
|
||||
skills: Explicit native A2A or Agent Framework skills.
|
||||
infer_skills: Whether to discover Agent Framework skills from
|
||||
``SkillsProvider`` instances on the resolved agent. Defaults to ``True``.
|
||||
capabilities: Native server capabilities. Defaults to no optional capabilities.
|
||||
name: Public card name override. Defaults to the resolved target name.
|
||||
description: Public card description override. Defaults to the resolved target description.
|
||||
default_input_modes: Input modes advertised by the card. Defaults to
|
||||
``("text",)``. Other A2A mode strings may include
|
||||
``"application/json"``, ``"application/octet-stream"``, or media
|
||||
types such as ``"image/png"``; these examples are not exhaustive.
|
||||
default_output_modes: Output modes advertised by the card. Defaults
|
||||
to ``("text",)`` and accepts the same A2A mode strings as
|
||||
``default_input_modes``.
|
||||
|
||||
Raises:
|
||||
ValueError: If required application-owned card values are missing.
|
||||
"""
|
||||
if not version:
|
||||
raise ValueError("An A2A agent card requires a version.")
|
||||
if not supported_interfaces:
|
||||
raise ValueError("An A2A agent card requires at least one supported interface.")
|
||||
if not default_input_modes or not default_output_modes:
|
||||
raise ValueError("An A2A agent card requires at least one input and output mode.")
|
||||
_normalized_modes(default_input_modes)
|
||||
_normalized_modes(default_output_modes)
|
||||
|
||||
self.state = target if isinstance(target, AgentState) else AgentState(target)
|
||||
self.version = version
|
||||
self.supported_interfaces = tuple(supported_interfaces)
|
||||
self.skills = tuple(skills)
|
||||
self.infer_skills = infer_skills
|
||||
self.capabilities = capabilities
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.default_input_modes = tuple(default_input_modes)
|
||||
self.default_output_modes = tuple(default_output_modes)
|
||||
|
||||
async def get_card(self) -> AgentCard:
|
||||
"""Return the native A2A card for the resolved agent.
|
||||
|
||||
Returns:
|
||||
A native A2A ``AgentCard``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the resolved target does not provide required public metadata.
|
||||
"""
|
||||
target = await self.state.get_target()
|
||||
card_name = self.name if self.name is not None else target.name
|
||||
card_description = self.description if self.description is not None else target.description
|
||||
if not card_name:
|
||||
raise ValueError("An A2A agent card requires a name.")
|
||||
if not card_description:
|
||||
raise ValueError("An A2A agent card requires a description.")
|
||||
|
||||
skills: list[AgentSkill | Skill] = list(self.skills)
|
||||
if self.infer_skills:
|
||||
providers = cast("Sequence[object]", getattr(target, "context_providers", ()))
|
||||
source_context = SkillsSourceContext(agent=target)
|
||||
for provider in providers:
|
||||
if isinstance(provider, SkillsProvider):
|
||||
discovered = await provider._source.get_skills( # pyright: ignore[reportPrivateUsage]
|
||||
source_context
|
||||
)
|
||||
skills.extend(discovered)
|
||||
|
||||
card_skills: list[AgentSkill] = []
|
||||
skill_ids: set[str] = set()
|
||||
for skill in skills:
|
||||
a2a_skill = _to_a2a_skill(skill, self.default_input_modes, self.default_output_modes)
|
||||
if a2a_skill.id not in skill_ids:
|
||||
skill_ids.add(a2a_skill.id)
|
||||
card_skills.append(a2a_skill)
|
||||
|
||||
return AgentCard(
|
||||
name=card_name,
|
||||
description=card_description,
|
||||
version=self.version,
|
||||
default_input_modes=self.default_input_modes,
|
||||
default_output_modes=self.default_output_modes,
|
||||
capabilities=self.capabilities if self.capabilities is not None else AgentCapabilities(),
|
||||
supported_interfaces=self.supported_interfaces,
|
||||
skills=card_skills,
|
||||
)
|
||||
|
||||
def a2a_to_run(
|
||||
self,
|
||||
message: A2AMessage,
|
||||
*,
|
||||
stream: bool = False,
|
||||
validate_modes: bool = True,
|
||||
) -> AgentRunArgs:
|
||||
"""Convert a native A2A message into Agent Framework run arguments.
|
||||
|
||||
Args:
|
||||
message: Native A2A message to convert.
|
||||
|
||||
Keyword Args:
|
||||
stream: Whether the caller intends to run the agent in streaming mode.
|
||||
validate_modes: Whether to validate parts against the card's
|
||||
``default_input_modes``. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
Arguments corresponding to ``Agent.run(...)``.
|
||||
|
||||
Raises:
|
||||
ValueError: If mode validation fails.
|
||||
"""
|
||||
return _a2a_to_run(
|
||||
message,
|
||||
stream=stream,
|
||||
input_modes=self.default_input_modes if validate_modes else None,
|
||||
)
|
||||
|
||||
def a2a_from_run(
|
||||
self,
|
||||
result: AgentResponse[Any] | Message | AgentResponseUpdate,
|
||||
*,
|
||||
validate_modes: bool = True,
|
||||
) -> list[Part]:
|
||||
"""Convert Agent Framework output into native A2A parts.
|
||||
|
||||
Args:
|
||||
result: Completed response, response message, or streaming update.
|
||||
|
||||
Keyword Args:
|
||||
validate_modes: Whether to validate parts against the card's
|
||||
``default_output_modes``. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
Native A2A parts ready for an A2A SDK message or artifact.
|
||||
|
||||
Raises:
|
||||
ValueError: If mode validation fails.
|
||||
"""
|
||||
return _a2a_from_run(
|
||||
result,
|
||||
output_modes=self.default_output_modes if validate_modes else None,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowA2AAdapter(Generic[WorkflowT]):
|
||||
"""Resolve a native A2A card from Agent Framework workflow metadata."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: WorkflowT | WorkflowState[WorkflowT],
|
||||
*,
|
||||
version: str,
|
||||
supported_interfaces: Sequence[AgentInterface],
|
||||
skills: Sequence[AgentSkill | Skill] = (),
|
||||
capabilities: AgentCapabilities | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
default_input_modes: Sequence[str] | None = None,
|
||||
default_output_modes: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Create a workflow-backed A2A card adapter.
|
||||
|
||||
Args:
|
||||
target: Workflow or existing ``WorkflowState`` whose metadata and
|
||||
declared types should seed the card.
|
||||
|
||||
Keyword Args:
|
||||
version: Application-defined version advertised by the A2A server.
|
||||
supported_interfaces: Public A2A endpoints exposed by the application,
|
||||
with one native interface for each available protocol binding. For
|
||||
example, ``from a2a.types import AgentInterface`` and then
|
||||
``[AgentInterface(url="https://example.com/a2a",
|
||||
protocol_binding="JSONRPC")]``.
|
||||
skills: Explicit native A2A or Agent Framework skills.
|
||||
capabilities: Native server capabilities. Defaults to no optional capabilities.
|
||||
name: Public card name override. Defaults to the resolved workflow name.
|
||||
description: Public card description override. Defaults to the resolved workflow description.
|
||||
default_input_modes: Input mode override. ``None`` or an empty sequence
|
||||
infers modes from the start executor. Explicit alternatives include
|
||||
``"text"``, ``"application/json"``, ``"application/octet-stream"``,
|
||||
and media types such as ``"image/png"``; A2A mode strings are
|
||||
extensible, so these examples are not exhaustive.
|
||||
default_output_modes: Output mode override. ``None`` or an empty sequence
|
||||
infers modes from declared workflow outputs and accepts the same
|
||||
explicit A2A mode strings as ``default_input_modes``.
|
||||
|
||||
Raises:
|
||||
ValueError: If required application-owned card values are missing.
|
||||
"""
|
||||
if not version:
|
||||
raise ValueError("An A2A workflow card requires a version.")
|
||||
if not supported_interfaces:
|
||||
raise ValueError("An A2A workflow card requires at least one supported interface.")
|
||||
if default_input_modes:
|
||||
_normalized_modes(default_input_modes)
|
||||
if default_output_modes:
|
||||
_normalized_modes(default_output_modes)
|
||||
self.state = target if isinstance(target, WorkflowState) else WorkflowState(target)
|
||||
self.version = version
|
||||
self.supported_interfaces = tuple(supported_interfaces)
|
||||
self.skills = tuple(skills)
|
||||
self.capabilities = capabilities
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.default_input_modes = tuple(default_input_modes) if default_input_modes else None
|
||||
self.default_output_modes = tuple(default_output_modes) if default_output_modes else None
|
||||
self._resolved_output_modes: tuple[str, ...] | None = self.default_output_modes
|
||||
|
||||
async def get_card(self) -> AgentCard:
|
||||
"""Return the native A2A card for the resolved workflow.
|
||||
|
||||
Returns:
|
||||
A native A2A ``AgentCard``.
|
||||
|
||||
Raises:
|
||||
ValueError: If required metadata or modes cannot be determined.
|
||||
"""
|
||||
target = await self.state.get_target()
|
||||
card_name = self.name if self.name is not None else target.name
|
||||
card_description = self.description if self.description is not None else target.description
|
||||
if not card_name:
|
||||
raise ValueError("An A2A workflow card requires a name.")
|
||||
if not card_description:
|
||||
raise ValueError("An A2A workflow card requires a description.")
|
||||
|
||||
input_modes = (
|
||||
list(self.default_input_modes)
|
||||
if self.default_input_modes is not None
|
||||
else _workflow_type_modes(_workflow_input_type(target))
|
||||
)
|
||||
if self.default_output_modes is not None:
|
||||
output_modes = list(self.default_output_modes)
|
||||
elif target.output_types:
|
||||
inferred_modes = {mode for output_type in target.output_types for mode in _workflow_type_modes(output_type)}
|
||||
output_modes = [
|
||||
mode for mode in ("text", "application/octet-stream", "application/json") if mode in inferred_modes
|
||||
]
|
||||
else:
|
||||
raise ValueError("Cannot infer A2A output modes because the workflow declares no output types.")
|
||||
self._resolved_output_modes = tuple(output_modes)
|
||||
|
||||
return AgentCard(
|
||||
name=card_name,
|
||||
description=card_description,
|
||||
version=self.version,
|
||||
default_input_modes=input_modes,
|
||||
default_output_modes=output_modes,
|
||||
capabilities=self.capabilities if self.capabilities is not None else AgentCapabilities(),
|
||||
supported_interfaces=self.supported_interfaces,
|
||||
skills=[_to_a2a_skill(skill, input_modes, output_modes) for skill in self.skills],
|
||||
)
|
||||
|
||||
async def a2a_to_run(self, message: A2AMessage, *, validate_modes: bool = True) -> Any:
|
||||
"""Convert a native A2A message into validated workflow input.
|
||||
|
||||
Args:
|
||||
message: Native A2A message containing the workflow input.
|
||||
|
||||
Keyword Args:
|
||||
validate_modes: Whether to validate parts against the card's
|
||||
effective input modes. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
A value validated for ``Workflow.run(...)``.
|
||||
|
||||
Raises:
|
||||
ValueError: If workflow input or mode validation fails.
|
||||
"""
|
||||
target = await self.state.get_target()
|
||||
input_modes = (
|
||||
list(self.default_input_modes)
|
||||
if self.default_input_modes is not None
|
||||
else _workflow_type_modes(_workflow_input_type(target))
|
||||
)
|
||||
return _a2a_to_workflow_run(
|
||||
message,
|
||||
target,
|
||||
input_modes=input_modes if validate_modes else None,
|
||||
)
|
||||
|
||||
def a2a_from_run(self, result: WorkflowRunResult, *, validate_modes: bool = True) -> list[Part]:
|
||||
"""Convert completed workflow outputs into native A2A parts.
|
||||
|
||||
Args:
|
||||
result: Completed non-streaming workflow result.
|
||||
|
||||
Keyword Args:
|
||||
validate_modes: Whether to validate parts against the card's
|
||||
effective output modes. Defaults to ``True``. When output modes
|
||||
are inferred, call :meth:`get_card` before enabling validation.
|
||||
|
||||
Returns:
|
||||
Native A2A parts for the workflow's public outputs.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If validation requires inferred output modes and
|
||||
:meth:`get_card` has not resolved them.
|
||||
ValueError: If workflow output or mode validation fails.
|
||||
"""
|
||||
if validate_modes and self._resolved_output_modes is None:
|
||||
raise RuntimeError("Call `await adapter.get_card()` before validating inferred workflow output modes.")
|
||||
return _a2a_from_workflow_run(
|
||||
result,
|
||||
output_modes=self._resolved_output_modes if validate_modes else None,
|
||||
)
|
||||
@@ -7,19 +7,167 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Collection, Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from a2a.types import Message as A2AMessage
|
||||
from a2a.types import Part
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, ChatOptions, Content, Message
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
ChatOptions,
|
||||
Content,
|
||||
Message,
|
||||
Workflow,
|
||||
WorkflowRunResult,
|
||||
)
|
||||
from agent_framework_hosting import AgentRunArgs
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from google.protobuf.json_format import MessageToDict, ParseDict
|
||||
from google.protobuf.struct_pb2 import Value
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic.errors import PydanticSchemaGenerationError
|
||||
|
||||
logger = logging.getLogger("agent_framework.hosting.a2a")
|
||||
|
||||
_BINARY_MODE = "application/octet-stream"
|
||||
_JSON_MODE = "application/json"
|
||||
_TEXT_MODE = "text"
|
||||
_MODE_ORDER = (_TEXT_MODE, _BINARY_MODE, _JSON_MODE)
|
||||
_JSON_SCHEMA_TYPES = {"array", "boolean", "integer", "null", "number", "object"}
|
||||
|
||||
def a2a_to_run(message: A2AMessage, *, stream: bool = False) -> AgentRunArgs:
|
||||
|
||||
def _normalized_modes(allowed_modes: Collection[str]) -> set[str]:
|
||||
normalized_modes: set[str] = set()
|
||||
for mode in allowed_modes:
|
||||
if not isinstance(mode, str) or not mode.strip():
|
||||
raise ValueError("A2A modes must be non-empty strings.")
|
||||
normalized_modes.add(mode.strip().lower())
|
||||
return normalized_modes
|
||||
|
||||
|
||||
def _mode_allowed(mode: str, normalized_modes: Collection[str]) -> bool:
|
||||
normalized_mode = mode.lower()
|
||||
if normalized_mode in normalized_modes:
|
||||
return True
|
||||
return any(
|
||||
allowed_mode.endswith("/*") and normalized_mode.startswith(allowed_mode[:-1])
|
||||
for allowed_mode in normalized_modes
|
||||
)
|
||||
|
||||
|
||||
def _part_mode(part: Part) -> str | None:
|
||||
match part.WhichOneof("content"):
|
||||
case "text":
|
||||
return _TEXT_MODE
|
||||
case "data":
|
||||
return _JSON_MODE
|
||||
case "raw":
|
||||
return part.media_type or _BINARY_MODE
|
||||
case "url":
|
||||
return part.media_type or None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_part_modes(parts: Sequence[Part], allowed_modes: Collection[str], direction: str) -> None:
|
||||
normalized_modes = _normalized_modes(allowed_modes)
|
||||
for part in parts:
|
||||
mode = _part_mode(part)
|
||||
if mode is None:
|
||||
continue
|
||||
if _mode_allowed(mode, normalized_modes):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"A2A {direction} part mode '{mode}' is not included in the advertised modes: {sorted(allowed_modes)}."
|
||||
)
|
||||
|
||||
|
||||
def _part_from_text(
|
||||
text: str,
|
||||
metadata: Mapping[str, Any],
|
||||
output_modes: Collection[str] | None,
|
||||
) -> Part:
|
||||
if output_modes is None:
|
||||
return Part(text=text, metadata=metadata)
|
||||
|
||||
normalized_modes = _normalized_modes(output_modes)
|
||||
if _mode_allowed(_TEXT_MODE, normalized_modes):
|
||||
return Part(text=text, metadata=metadata)
|
||||
if _mode_allowed(_JSON_MODE, normalized_modes):
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Agent Framework text output is not valid JSON for A2A application/json mode.") from exc
|
||||
value = Value()
|
||||
ParseDict(data, value)
|
||||
return Part(data=value, metadata=metadata)
|
||||
raise ValueError(
|
||||
f"Agent Framework text output cannot be converted to the advertised A2A modes: {sorted(output_modes)}."
|
||||
)
|
||||
|
||||
|
||||
def _modes_for_schema(schema: Mapping[str, Any]) -> list[str]:
|
||||
modes: set[str] = set()
|
||||
variants = schema.get("anyOf") or schema.get("oneOf")
|
||||
if isinstance(variants, list):
|
||||
for variant in cast("list[object]", variants):
|
||||
if isinstance(variant, dict):
|
||||
modes.update(_modes_for_schema(cast("dict[str, Any]", variant)))
|
||||
|
||||
schema_types = schema.get("type")
|
||||
if isinstance(schema_types, str):
|
||||
schema_types_list: list[object] = [schema_types]
|
||||
elif isinstance(schema_types, list):
|
||||
schema_types_list = cast("list[object]", schema_types)
|
||||
else:
|
||||
schema_types_list = []
|
||||
for schema_type in schema_types_list:
|
||||
if isinstance(schema_type, str):
|
||||
if schema_type == "string":
|
||||
modes.add(_BINARY_MODE if schema.get("format") == "binary" else _TEXT_MODE)
|
||||
elif schema_type in _JSON_SCHEMA_TYPES:
|
||||
modes.add(_JSON_MODE)
|
||||
|
||||
return [mode for mode in _MODE_ORDER if mode in modes]
|
||||
|
||||
|
||||
def _workflow_input_type(workflow: Workflow) -> Any:
|
||||
input_types = workflow.input_types
|
||||
if len(input_types) != 1:
|
||||
raise ValueError(
|
||||
f"A2A workflow helpers require exactly one start-executor input type; found {len(input_types)}."
|
||||
)
|
||||
return input_types[0]
|
||||
|
||||
|
||||
def _workflow_type_adapter(value_type: Any) -> TypeAdapter[Any]:
|
||||
try:
|
||||
return TypeAdapter(value_type)
|
||||
except PydanticSchemaGenerationError as exc:
|
||||
raise ValueError(f"Cannot convert A2A values for workflow type {value_type!r}.") from exc
|
||||
|
||||
|
||||
def _workflow_input_adapter(workflow: Workflow) -> TypeAdapter[Any]:
|
||||
return _workflow_type_adapter(_workflow_input_type(workflow))
|
||||
|
||||
|
||||
def _workflow_type_modes(value_type: Any) -> list[str]: # pyright: ignore[reportUnusedFunction]
|
||||
try:
|
||||
schema = _workflow_type_adapter(value_type).json_schema()
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Cannot infer an A2A mode for workflow type {value_type!r}.") from exc
|
||||
modes = _modes_for_schema(schema)
|
||||
if not modes:
|
||||
raise ValueError(f"Cannot infer an A2A mode for workflow type {value_type!r}.")
|
||||
return modes
|
||||
|
||||
|
||||
def a2a_to_run(
|
||||
message: A2AMessage,
|
||||
*,
|
||||
stream: bool = False,
|
||||
input_modes: Collection[str] | None = None,
|
||||
) -> AgentRunArgs:
|
||||
"""Convert an A2A message into Agent Framework run arguments.
|
||||
|
||||
A2A text, URL, raw-byte, and structured-data parts become Agent Framework
|
||||
@@ -31,13 +179,19 @@ def a2a_to_run(message: A2AMessage, *, stream: bool = False) -> AgentRunArgs:
|
||||
|
||||
Keyword Args:
|
||||
stream: Whether the caller intends to run the agent in streaming mode.
|
||||
input_modes: Advertised A2A input modes to validate against. ``None``
|
||||
disables mode validation.
|
||||
|
||||
Returns:
|
||||
Arguments corresponding to ``Agent.run(...)``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the message has no supported content parts.
|
||||
ValueError: If the message has no supported content parts or contains
|
||||
a part outside ``input_modes``.
|
||||
"""
|
||||
if input_modes is not None:
|
||||
_validate_part_modes(message.parts, input_modes, "input")
|
||||
|
||||
contents: list[Content] = []
|
||||
for part in message.parts:
|
||||
metadata = MessageToDict(part.metadata) if part.metadata else None
|
||||
@@ -97,7 +251,11 @@ def a2a_to_run(message: A2AMessage, *, stream: bool = False) -> AgentRunArgs:
|
||||
)
|
||||
|
||||
|
||||
def a2a_from_run(result: AgentResponse[Any] | Message | AgentResponseUpdate) -> list[Part]:
|
||||
def a2a_from_run(
|
||||
result: AgentResponse[Any] | Message | AgentResponseUpdate,
|
||||
*,
|
||||
output_modes: Collection[str] | None = None,
|
||||
) -> list[Part]:
|
||||
"""Convert Agent Framework output into native A2A parts.
|
||||
|
||||
``AgentResponse`` values are flattened in message order. User-role
|
||||
@@ -110,11 +268,16 @@ def a2a_from_run(result: AgentResponse[Any] | Message | AgentResponseUpdate) ->
|
||||
Args:
|
||||
result: A completed response, response message, or streaming update.
|
||||
|
||||
Keyword Args:
|
||||
output_modes: Advertised A2A output modes to validate against. ``None``
|
||||
disables mode validation.
|
||||
|
||||
Returns:
|
||||
Native A2A parts ready for an A2A SDK message or artifact.
|
||||
|
||||
Raises:
|
||||
ValueError: If Agent Framework data content contains an invalid data URI.
|
||||
ValueError: If Agent Framework data content contains an invalid data URI
|
||||
or produces a part outside ``output_modes``.
|
||||
"""
|
||||
items: Sequence[Message | AgentResponseUpdate] = result.messages if isinstance(result, AgentResponse) else [result]
|
||||
|
||||
@@ -126,7 +289,7 @@ def a2a_from_run(result: AgentResponse[Any] | Message | AgentResponseUpdate) ->
|
||||
metadata = content.additional_properties or {}
|
||||
match content.type:
|
||||
case "text" if content.text is not None:
|
||||
parts.append(Part(text=content.text, metadata=metadata))
|
||||
parts.append(_part_from_text(content.text, metadata, output_modes))
|
||||
case "uri" if content.uri is not None:
|
||||
parts.append(
|
||||
Part(
|
||||
@@ -155,4 +318,128 @@ def a2a_from_run(result: AgentResponse[Any] | Message | AgentResponseUpdate) ->
|
||||
"Agent Framework content type %s is not supported by A2A and was omitted.",
|
||||
content.type,
|
||||
)
|
||||
if output_modes is not None:
|
||||
_validate_part_modes(parts, output_modes, "output")
|
||||
return parts
|
||||
|
||||
|
||||
def a2a_to_workflow_run(
|
||||
message: A2AMessage,
|
||||
workflow: Workflow,
|
||||
*,
|
||||
input_modes: Collection[str] | None = None,
|
||||
) -> Any:
|
||||
"""Convert one native A2A message part into validated workflow input.
|
||||
|
||||
The workflow must declare exactly one start-executor input type. Text,
|
||||
raw-byte, and structured-data parts map to string, binary, and JSON
|
||||
workflow contracts respectively. Executor, task, session, and continuation
|
||||
behavior remain application-owned.
|
||||
|
||||
Args:
|
||||
message: Native A2A message containing the workflow input.
|
||||
workflow: Workflow whose start-executor input contract should be used.
|
||||
|
||||
Keyword Args:
|
||||
input_modes: Advertised A2A input modes to validate against. ``None``
|
||||
disables card-mode validation; workflow type validation still applies.
|
||||
|
||||
Returns:
|
||||
A value validated for ``Workflow.run(...)``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the workflow input type is unsupported, the message
|
||||
does not contain exactly one compatible part, or a part is outside
|
||||
``input_modes``.
|
||||
"""
|
||||
if input_modes is not None:
|
||||
_validate_part_modes(message.parts, input_modes, "input")
|
||||
|
||||
adapter = _workflow_input_adapter(workflow)
|
||||
modes = _modes_for_schema(adapter.json_schema())
|
||||
if not modes:
|
||||
raise ValueError(f"Cannot convert A2A input for workflow type {workflow.input_types[0]!r}.")
|
||||
|
||||
candidates: list[tuple[str, Part]] = []
|
||||
for part in message.parts:
|
||||
content_type = part.WhichOneof("content")
|
||||
if content_type == "text" and _TEXT_MODE in modes:
|
||||
candidates.append((_TEXT_MODE, part))
|
||||
elif content_type == "raw" and _BINARY_MODE in modes:
|
||||
candidates.append((_BINARY_MODE, part))
|
||||
elif content_type == "data" and _JSON_MODE in modes:
|
||||
candidates.append((_JSON_MODE, part))
|
||||
|
||||
if len(candidates) != 1:
|
||||
expected = ", ".join(modes)
|
||||
raise ValueError(
|
||||
f"A2A workflow input must contain exactly one compatible part for modes [{expected}]; "
|
||||
f"found {len(candidates)}."
|
||||
)
|
||||
|
||||
mode, part = candidates[0]
|
||||
if mode == _TEXT_MODE:
|
||||
value: Any = part.text
|
||||
elif mode == _BINARY_MODE:
|
||||
value = part.raw
|
||||
else:
|
||||
value = MessageToDict(part.data)
|
||||
return adapter.validate_python(value)
|
||||
|
||||
|
||||
def a2a_from_workflow_run(
|
||||
result: WorkflowRunResult,
|
||||
*,
|
||||
output_modes: Collection[str] | None = None,
|
||||
) -> list[Part]:
|
||||
"""Convert completed workflow outputs into native A2A parts.
|
||||
|
||||
Args:
|
||||
result: Completed non-streaming workflow result.
|
||||
|
||||
Keyword Args:
|
||||
output_modes: Advertised A2A output modes to validate against. ``None``
|
||||
disables mode validation.
|
||||
|
||||
Returns:
|
||||
Native A2A parts for the workflow's public outputs.
|
||||
|
||||
Raises:
|
||||
ValueError: If the workflow requires external input or produces a part
|
||||
outside ``output_modes``.
|
||||
"""
|
||||
if result.get_request_info_events():
|
||||
raise ValueError(
|
||||
"The workflow requires external input. A2A workflow conversion does not manage "
|
||||
"human-in-the-loop continuation; handle it in the application contract."
|
||||
)
|
||||
|
||||
parts: list[Part] = []
|
||||
for output in result.get_outputs():
|
||||
if isinstance(output, (AgentResponse, Message, AgentResponseUpdate)):
|
||||
parts.extend(
|
||||
a2a_from_run(
|
||||
cast("AgentResponse[Any] | Message | AgentResponseUpdate", output),
|
||||
output_modes=output_modes,
|
||||
)
|
||||
)
|
||||
elif isinstance(output, str):
|
||||
parts.append(_part_from_text(output, {}, output_modes))
|
||||
elif isinstance(output, bytes):
|
||||
parts.append(Part(raw=output, media_type=_BINARY_MODE))
|
||||
else:
|
||||
data = TypeAdapter(object).dump_python(output, mode="json", serialize_as_any=True)
|
||||
normalized_modes = _normalized_modes(output_modes) if output_modes is not None else None
|
||||
if (
|
||||
normalized_modes is not None
|
||||
and _mode_allowed(_TEXT_MODE, normalized_modes)
|
||||
and not _mode_allowed(_JSON_MODE, normalized_modes)
|
||||
):
|
||||
parts.append(Part(text=json.dumps(data, separators=(",", ":"), sort_keys=True)))
|
||||
else:
|
||||
value = Value()
|
||||
ParseDict(data, value)
|
||||
parts.append(Part(data=value))
|
||||
if output_modes is not None:
|
||||
_validate_part_modes(parts, output_modes, "output")
|
||||
return parts
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A conversion helpers for app-owned Agent Framework hosting."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260721"
|
||||
version = "1.0.0a260723"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from a2a.types import AgentCapabilities, AgentInterface, AgentSkill, Part, Role
|
||||
from a2a.types import Message as A2AMessage
|
||||
from agent_framework import (
|
||||
InlineSkill,
|
||||
Message,
|
||||
SkillFrontmatter,
|
||||
SkillsProvider,
|
||||
SupportsAgentRun,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowRunResult,
|
||||
executor,
|
||||
)
|
||||
from agent_framework_hosting import AgentState, WorkflowState
|
||||
from google.protobuf.json_format import ParseDict
|
||||
from pytest import raises
|
||||
|
||||
from agent_framework_hosting_a2a import AgentA2AAdapter, WorkflowA2AAdapter
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuredInput:
|
||||
value: str
|
||||
|
||||
|
||||
async def test_agent_card_infers_metadata_and_keeps_a2a_policy_explicit() -> None:
|
||||
target = cast("SupportsAgentRun", SimpleNamespace(name="Travel Agent", description="Plans trips."))
|
||||
interface = AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")
|
||||
skill = AgentSkill(
|
||||
id="plan_trip",
|
||||
name="Plan trip",
|
||||
description="Plan a trip.",
|
||||
tags=["travel"],
|
||||
examples=["Plan a weekend in Paris."],
|
||||
)
|
||||
|
||||
adapter = AgentA2AAdapter(
|
||||
AgentState(lambda: target),
|
||||
version="1.0.0",
|
||||
supported_interfaces=[interface],
|
||||
skills=[skill],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
)
|
||||
card = await adapter.get_card()
|
||||
|
||||
assert card.name == "Travel Agent"
|
||||
assert card.description == "Plans trips."
|
||||
assert card.default_input_modes == ["text"]
|
||||
assert card.default_output_modes == ["text"]
|
||||
assert card.capabilities.streaming is True
|
||||
assert card.supported_interfaces == [interface]
|
||||
assert card.skills == [skill]
|
||||
run = adapter.a2a_to_run(A2AMessage(message_id="message-1", role=Role.ROLE_USER, parts=[Part(text="hello")]))
|
||||
assert isinstance(run["messages"], list)
|
||||
assert isinstance(run["messages"][0], Message)
|
||||
assert run["messages"][0].text == "hello"
|
||||
assert adapter.a2a_from_run(Message("assistant", ["hello"]))[0].text == "hello"
|
||||
with raises(ValueError, match="audio/wav"):
|
||||
adapter.a2a_to_run(
|
||||
A2AMessage(
|
||||
message_id="message-2",
|
||||
role=Role.ROLE_USER,
|
||||
parts=[Part(raw=b"audio", media_type="audio/wav")],
|
||||
)
|
||||
)
|
||||
assert adapter.a2a_to_run(
|
||||
A2AMessage(
|
||||
message_id="message-2",
|
||||
role=Role.ROLE_USER,
|
||||
parts=[Part(raw=b"audio", media_type="audio/wav")],
|
||||
),
|
||||
validate_modes=False,
|
||||
)["messages"]
|
||||
|
||||
|
||||
async def test_agent_card_requires_public_metadata_and_interface() -> None:
|
||||
target = cast("SupportsAgentRun", SimpleNamespace(name=None, description=None))
|
||||
|
||||
with raises(ValueError, match="supported interface"):
|
||||
AgentA2AAdapter(target, version="1.0.0", supported_interfaces=[])
|
||||
|
||||
with raises(ValueError, match="non-empty strings"):
|
||||
AgentA2AAdapter(
|
||||
target,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")],
|
||||
default_input_modes=[""],
|
||||
)
|
||||
|
||||
with raises(ValueError, match="requires a name"):
|
||||
await AgentA2AAdapter(
|
||||
target,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")],
|
||||
).get_card()
|
||||
|
||||
|
||||
async def test_agent_card_infers_agent_framework_skills_and_can_disable_inference() -> None:
|
||||
skill = InlineSkill(
|
||||
frontmatter=SkillFrontmatter(name="plan-trip", description="Plan a trip."),
|
||||
instructions="Help the user plan a trip.",
|
||||
)
|
||||
target = cast(
|
||||
"SupportsAgentRun",
|
||||
SimpleNamespace(
|
||||
name="Travel Agent",
|
||||
description="Plans trips.",
|
||||
context_providers=[SkillsProvider([skill])],
|
||||
),
|
||||
)
|
||||
interface = AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")
|
||||
|
||||
inferred_card = await AgentA2AAdapter(
|
||||
target,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[interface],
|
||||
).get_card()
|
||||
explicit_card = await AgentA2AAdapter(
|
||||
target,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[interface],
|
||||
skills=[skill],
|
||||
infer_skills=False,
|
||||
).get_card()
|
||||
disabled_card = await AgentA2AAdapter(
|
||||
target,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[interface],
|
||||
infer_skills=False,
|
||||
).get_card()
|
||||
|
||||
assert inferred_card.skills[0].id == "plan-trip"
|
||||
assert inferred_card.skills[0].description == "Plan a trip."
|
||||
assert inferred_card.skills[0].input_modes == ["text"]
|
||||
assert explicit_card.skills[0] == inferred_card.skills[0]
|
||||
assert disabled_card.skills == []
|
||||
|
||||
|
||||
async def test_workflow_card_infers_json_input_and_text_output_modes() -> None:
|
||||
@executor(id="structured")
|
||||
async def structured(value: StructuredInput, ctx: WorkflowContext[object, str]) -> None:
|
||||
await ctx.yield_output(value.value)
|
||||
|
||||
workflow = WorkflowBuilder(
|
||||
start_executor=structured,
|
||||
name="Structured Workflow",
|
||||
description="Processes structured input.",
|
||||
output_from=[structured],
|
||||
).build()
|
||||
|
||||
adapter = WorkflowA2AAdapter(
|
||||
WorkflowState(lambda: workflow),
|
||||
version="1.0.0",
|
||||
supported_interfaces=[AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")],
|
||||
default_input_modes=[],
|
||||
default_output_modes=[],
|
||||
)
|
||||
output_result = WorkflowRunResult([WorkflowEvent("output", "hello", executor_id="structured")])
|
||||
with raises(RuntimeError, match="get_card"):
|
||||
adapter.a2a_from_run(output_result)
|
||||
assert adapter.a2a_from_run(output_result, validate_modes=False)[0].text == "hello"
|
||||
|
||||
card = await adapter.get_card()
|
||||
data_part = Part()
|
||||
ParseDict({"value": "hello"}, data_part.data)
|
||||
workflow_input = await adapter.a2a_to_run(
|
||||
A2AMessage(message_id="message-1", role=Role.ROLE_USER, parts=[data_part])
|
||||
)
|
||||
output_parts = adapter.a2a_from_run(output_result)
|
||||
|
||||
assert card.name == "Structured Workflow"
|
||||
assert card.description == "Processes structured input."
|
||||
assert card.default_input_modes == ["application/json"]
|
||||
assert card.default_output_modes == ["text"]
|
||||
assert workflow_input == StructuredInput(value="hello")
|
||||
assert output_parts[0].text == "hello"
|
||||
|
||||
|
||||
async def test_workflow_card_allows_explicit_modes_for_unsupported_types() -> None:
|
||||
@executor(id="custom")
|
||||
async def custom(value: object, ctx: WorkflowContext[object, object]) -> None:
|
||||
await ctx.yield_output(value)
|
||||
|
||||
workflow = WorkflowBuilder(
|
||||
start_executor=custom,
|
||||
name="Custom Workflow",
|
||||
description="Processes a custom protocol value.",
|
||||
output_from=[custom],
|
||||
).build()
|
||||
interface = AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")
|
||||
|
||||
with raises(ValueError, match="Cannot infer"):
|
||||
await WorkflowA2AAdapter(
|
||||
workflow,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[interface],
|
||||
).get_card()
|
||||
|
||||
card = await WorkflowA2AAdapter(
|
||||
workflow,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[interface],
|
||||
default_input_modes=["application/x-custom"],
|
||||
default_output_modes=["application/x-custom"],
|
||||
).get_card()
|
||||
|
||||
assert card.default_input_modes == ["application/x-custom"]
|
||||
assert card.default_output_modes == ["application/x-custom"]
|
||||
|
||||
|
||||
async def test_workflow_card_requires_one_input_type() -> None:
|
||||
from agent_framework import Executor, handler
|
||||
|
||||
class MultipleInputs(Executor):
|
||||
@handler
|
||||
async def handle_text(self, value: str, ctx: WorkflowContext[object, str]) -> None:
|
||||
await ctx.yield_output(value)
|
||||
|
||||
@handler
|
||||
async def handle_number(self, value: int, ctx: WorkflowContext[object, str]) -> None:
|
||||
await ctx.yield_output(str(value))
|
||||
|
||||
workflow = WorkflowBuilder(
|
||||
start_executor=MultipleInputs(id="multiple"),
|
||||
name="Multiple",
|
||||
description="Multiple inputs.",
|
||||
output_from="all",
|
||||
).build()
|
||||
|
||||
with raises(ValueError, match="exactly one"):
|
||||
await WorkflowA2AAdapter(
|
||||
workflow,
|
||||
version="1.0.0",
|
||||
supported_interfaces=[AgentInterface(url="https://example.com/a2a", protocol_binding="JSONRPC")],
|
||||
).get_card()
|
||||
@@ -1,12 +1,48 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from a2a.types import Message as A2AMessage
|
||||
from a2a.types import Part, Role
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowRunResult,
|
||||
executor,
|
||||
)
|
||||
from google.protobuf.json_format import MessageToDict, ParseDict
|
||||
from pytest import raises
|
||||
|
||||
from agent_framework_hosting_a2a import a2a_from_run, a2a_to_run
|
||||
from agent_framework_hosting_a2a import (
|
||||
a2a_from_run,
|
||||
a2a_from_workflow_run,
|
||||
a2a_to_run,
|
||||
a2a_to_workflow_run,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowInput:
|
||||
text: str
|
||||
repeat: int
|
||||
|
||||
|
||||
def create_workflow():
|
||||
@executor(id="repeat")
|
||||
async def repeat_text(value: WorkflowInput, ctx: WorkflowContext[object, str]) -> None:
|
||||
await ctx.yield_output(value.text * value.repeat)
|
||||
|
||||
return WorkflowBuilder(
|
||||
start_executor=repeat_text,
|
||||
name="Repeat Workflow",
|
||||
description="Repeat text a requested number of times.",
|
||||
output_from=[repeat_text],
|
||||
).build()
|
||||
|
||||
|
||||
def test_a2a_to_run_converts_supported_parts() -> None:
|
||||
@@ -52,7 +88,8 @@ def test_a2a_to_run_omits_unsupported_parts() -> None:
|
||||
message_id="message-1",
|
||||
role=Role.ROLE_USER,
|
||||
parts=[Part(), Part(text="hello")],
|
||||
)
|
||||
),
|
||||
input_modes=[" text "],
|
||||
)
|
||||
|
||||
messages = run["messages"]
|
||||
@@ -62,6 +99,19 @@ def test_a2a_to_run_omits_unsupported_parts() -> None:
|
||||
assert converted.text == "hello"
|
||||
|
||||
|
||||
def test_a2a_to_run_validates_advertised_input_modes() -> None:
|
||||
message = A2AMessage(
|
||||
message_id="message-1",
|
||||
role=Role.ROLE_USER,
|
||||
parts=[Part(raw=b"audio", media_type="audio/wav")],
|
||||
)
|
||||
|
||||
with raises(ValueError, match="audio/wav"):
|
||||
a2a_to_run(message, input_modes=["text"])
|
||||
|
||||
assert a2a_to_run(message, input_modes=[" audio/* "])["messages"]
|
||||
|
||||
|
||||
def test_a2a_from_run_converts_final_response() -> None:
|
||||
response = AgentResponse(
|
||||
messages=[
|
||||
@@ -128,6 +178,35 @@ def test_a2a_from_run_omits_user_messages() -> None:
|
||||
assert a2a_from_run(AgentResponse(messages=[Message("user", ["omit me"])])) == []
|
||||
|
||||
|
||||
def test_a2a_from_run_validates_advertised_output_modes() -> None:
|
||||
result = Message(
|
||||
"assistant",
|
||||
[Content.from_uri("https://example.com/image.png", media_type="image/png")],
|
||||
)
|
||||
|
||||
with raises(ValueError, match="image/png"):
|
||||
a2a_from_run(result, output_modes=["text"])
|
||||
|
||||
assert a2a_from_run(result, output_modes=["image/*"])[0].url == "https://example.com/image.png"
|
||||
|
||||
|
||||
def test_a2a_from_run_parses_json_text_for_json_output_mode() -> None:
|
||||
parts = a2a_from_run(
|
||||
Message("assistant", ['{"answer":42}']),
|
||||
output_modes=["application/json"],
|
||||
)
|
||||
|
||||
assert MessageToDict(parts[0].data) == {"answer": 42.0}
|
||||
|
||||
with raises(ValueError, match="not valid JSON"):
|
||||
a2a_from_run(Message("assistant", ["not json"]), output_modes=["application/json"])
|
||||
|
||||
|
||||
def test_a2a_from_run_rejects_text_for_incompatible_output_mode() -> None:
|
||||
with raises(ValueError, match="cannot be converted"):
|
||||
a2a_from_run(Message("assistant", ["hello"]), output_modes=["application/octet-stream"])
|
||||
|
||||
|
||||
def test_a2a_from_run_rejects_invalid_data_uri() -> None:
|
||||
content = Content("data", uri="not-a-data-uri", media_type="application/octet-stream")
|
||||
|
||||
@@ -144,3 +223,116 @@ def test_a2a_from_run_rejects_invalid_base64_data() -> None:
|
||||
|
||||
with raises(ValueError, match="invalid base64"):
|
||||
a2a_from_run(Message("assistant", [content]))
|
||||
|
||||
|
||||
def test_a2a_to_workflow_run_validates_structured_input() -> None:
|
||||
data_part = Part()
|
||||
ParseDict({"text": "go", "repeat": 2}, data_part.data)
|
||||
|
||||
value = a2a_to_workflow_run(
|
||||
A2AMessage(message_id="message-1", role=Role.ROLE_USER, parts=[Part(), data_part]),
|
||||
create_workflow(),
|
||||
input_modes=["application/json"],
|
||||
)
|
||||
|
||||
assert value == WorkflowInput(text="go", repeat=2)
|
||||
|
||||
|
||||
def test_a2a_to_workflow_run_rejects_invalid_structured_input() -> None:
|
||||
data_part = Part()
|
||||
ParseDict({"text": "go", "repeat": "not_a_number"}, data_part.data)
|
||||
|
||||
with raises(ValueError, match="repeat"):
|
||||
a2a_to_workflow_run(
|
||||
A2AMessage(message_id="message-1", role=Role.ROLE_USER, parts=[data_part]),
|
||||
create_workflow(),
|
||||
)
|
||||
|
||||
|
||||
def test_a2a_to_workflow_run_supports_text_and_binary_inputs() -> None:
|
||||
@executor(id="text")
|
||||
async def text_input(value: str, ctx: WorkflowContext[object, str]) -> None:
|
||||
await ctx.yield_output(value)
|
||||
|
||||
@executor(id="binary")
|
||||
async def binary_input(value: bytes, ctx: WorkflowContext[object, bytes]) -> None:
|
||||
await ctx.yield_output(value)
|
||||
|
||||
text_workflow = WorkflowBuilder(start_executor=text_input, output_from=[text_input]).build()
|
||||
binary_workflow = WorkflowBuilder(start_executor=binary_input, output_from=[binary_input]).build()
|
||||
|
||||
assert (
|
||||
a2a_to_workflow_run(
|
||||
A2AMessage(message_id="text", role=Role.ROLE_USER, parts=[Part(text="hello")]),
|
||||
text_workflow,
|
||||
)
|
||||
== "hello"
|
||||
)
|
||||
assert (
|
||||
a2a_to_workflow_run(
|
||||
A2AMessage(message_id="binary", role=Role.ROLE_USER, parts=[Part(raw=b"data")]),
|
||||
binary_workflow,
|
||||
)
|
||||
== b"data"
|
||||
)
|
||||
|
||||
|
||||
def test_a2a_to_workflow_run_requires_one_compatible_part() -> None:
|
||||
@executor(id="text")
|
||||
async def text_input(value: str, ctx: WorkflowContext[object, str]) -> None:
|
||||
await ctx.yield_output(value)
|
||||
|
||||
with raises(ValueError, match="exactly one compatible"):
|
||||
a2a_to_workflow_run(
|
||||
A2AMessage(
|
||||
message_id="message-1",
|
||||
role=Role.ROLE_USER,
|
||||
parts=[Part(text="one"), Part(text="two")],
|
||||
),
|
||||
WorkflowBuilder(start_executor=text_input, output_from=[text_input]).build(),
|
||||
)
|
||||
|
||||
|
||||
def test_a2a_from_workflow_run_converts_public_outputs() -> None:
|
||||
result = WorkflowRunResult([
|
||||
WorkflowEvent("output", "hello", executor_id="text"),
|
||||
WorkflowEvent("output", b"data", executor_id="binary"),
|
||||
WorkflowEvent("output", {"count": 2}, executor_id="structured"),
|
||||
WorkflowEvent(
|
||||
"output",
|
||||
AgentResponse(messages=[Message("assistant", ["from agent"])]),
|
||||
executor_id="agent",
|
||||
),
|
||||
])
|
||||
|
||||
parts = a2a_from_workflow_run(result)
|
||||
|
||||
assert parts[0].text == "hello"
|
||||
assert parts[1].raw == b"data"
|
||||
assert parts[1].media_type == "application/octet-stream"
|
||||
assert MessageToDict(parts[2].data) == {"count": 2.0}
|
||||
assert parts[3].text == "from agent"
|
||||
|
||||
|
||||
def test_a2a_from_workflow_run_serializes_structured_output_for_text_mode() -> None:
|
||||
result = WorkflowRunResult([WorkflowEvent("output", {"count": 2}, executor_id="structured")])
|
||||
|
||||
text_parts = a2a_from_workflow_run(result, output_modes=["text"])
|
||||
json_parts = a2a_from_workflow_run(result, output_modes=["application/json"])
|
||||
|
||||
assert text_parts[0].text == '{"count":2}'
|
||||
assert MessageToDict(json_parts[0].data) == {"count": 2.0}
|
||||
|
||||
|
||||
def test_a2a_from_workflow_run_rejects_pending_input() -> None:
|
||||
result = WorkflowRunResult([
|
||||
WorkflowEvent.request_info(
|
||||
request_id="approval",
|
||||
source_executor_id="review",
|
||||
request_data={"question": "Approve?"},
|
||||
response_type=bool,
|
||||
)
|
||||
])
|
||||
|
||||
with raises(ValueError, match="requires external input"):
|
||||
a2a_from_workflow_run(result)
|
||||
|
||||
@@ -43,6 +43,7 @@ from pytest import fixture
|
||||
|
||||
from agent_framework_hyperlight import AllowedDomain, FileMount, HyperlightCodeActProvider, HyperlightExecuteCodeTool
|
||||
from agent_framework_hyperlight import _execute_code_tool as execute_code_module
|
||||
from agent_framework_hyperlight import _instructions as instructions_module
|
||||
|
||||
|
||||
def _hyperlight_integration_static_skip_reason() -> str | None:
|
||||
@@ -1052,6 +1053,61 @@ def test_execute_code_tool_allowed_domains_use_structured_entries_and_replace_by
|
||||
]
|
||||
|
||||
|
||||
def test_execute_code_tool_normalizers_reject_invalid_inputs() -> None:
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
execute_code_module._normalize_domain(" ")
|
||||
with pytest.raises(ValueError, match="Could not normalize allowed domain entry"):
|
||||
execute_code_module._normalize_domain("https://")
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
execute_code_module._normalize_http_method(" ")
|
||||
assert execute_code_module._normalize_http_methods(None) is None
|
||||
with pytest.raises(ValueError, match="must not be empty when provided"):
|
||||
execute_code_module._normalize_http_methods([])
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
execute_code_module._normalize_mount_path(" ")
|
||||
with pytest.raises(ValueError, match="must stay within /input"):
|
||||
execute_code_module._normalize_mount_path("/input/../escape")
|
||||
with pytest.raises(ValueError, match="must point to a concrete path under /input"):
|
||||
execute_code_module._normalize_mount_path("/input")
|
||||
|
||||
|
||||
def test_execute_code_tool_shape_guards_validate_pairs() -> None:
|
||||
assert execute_code_module._is_file_mount_pair(("source.txt", "mount.txt")) is True
|
||||
assert execute_code_module._is_file_mount_pair(("source.txt", "mount.txt", "extra")) is False
|
||||
assert execute_code_module._is_file_mount_pair(("source.txt", 1)) is False
|
||||
|
||||
assert execute_code_module._is_allowed_domain_pair(("example.com", "get")) is True
|
||||
assert execute_code_module._is_allowed_domain_pair(("example.com", ["get", "post"])) is True
|
||||
assert execute_code_module._is_allowed_domain_pair((123, ["get"])) is False
|
||||
assert execute_code_module._is_allowed_domain_pair(("example.com", 123)) is False
|
||||
|
||||
|
||||
def test_instruction_builders_cover_mounted_paths_and_workspace_free_filesystem_state() -> None:
|
||||
description = instructions_module.build_execute_code_description(
|
||||
tools=[compute],
|
||||
filesystem_enabled=True,
|
||||
workspace_enabled=False,
|
||||
mounted_paths=["/input/data/report.txt"],
|
||||
allowed_domains=[],
|
||||
)
|
||||
instructions = instructions_module.build_codeact_instructions(
|
||||
tools=[compute],
|
||||
tools_visible_to_model=True,
|
||||
filesystem_enabled=True,
|
||||
)
|
||||
filesystem_text = instructions_module._format_filesystem_capabilities(
|
||||
filesystem_enabled=True,
|
||||
workspace_enabled=False,
|
||||
mounted_paths=[],
|
||||
)
|
||||
|
||||
assert "Additional mounted paths:" in description
|
||||
assert "/input/data/report.txt" in description
|
||||
assert "Some tools may also appear directly" in instructions
|
||||
assert "For larger artifacts, write them to `/output/<filename>` instead" in instructions
|
||||
assert "No workspace root or explicit file mounts are currently configured." in filesystem_text
|
||||
|
||||
|
||||
def test_execute_code_tool_description_contains_call_tool_guidance(tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
@@ -1253,6 +1309,32 @@ async def test_provider_injects_run_scoped_execute_code_tool() -> None:
|
||||
assert [tool_obj.name for tool_obj in run_tool.get_tools()] == ["compute"]
|
||||
|
||||
|
||||
def test_provider_delegates_file_mounts_and_allowed_domains_to_internal_tool(tmp_path: Path) -> None:
|
||||
provider = HyperlightCodeActProvider()
|
||||
|
||||
provider.add_file_mounts((tmp_path, "reports/output.txt"))
|
||||
assert provider.get_file_mounts() == [FileMount(tmp_path.resolve(), "/input/reports/output.txt")]
|
||||
|
||||
provider.remove_file_mount("/input/reports/output.txt")
|
||||
assert provider.get_file_mounts() == []
|
||||
|
||||
provider.add_file_mounts((tmp_path, "reports/output.txt"))
|
||||
provider.clear_file_mounts()
|
||||
assert provider.get_file_mounts() == []
|
||||
|
||||
provider.add_allowed_domains([("api.example.com", "get"), "github.com"])
|
||||
assert provider.get_allowed_domains() == [
|
||||
AllowedDomain("api.example.com", ("GET",)),
|
||||
AllowedDomain("github.com", None),
|
||||
]
|
||||
|
||||
provider.remove_allowed_domain("github.com")
|
||||
assert provider.get_allowed_domains() == [AllowedDomain("api.example.com", ("GET",))]
|
||||
|
||||
provider.clear_allowed_domains()
|
||||
assert provider.get_allowed_domains() == []
|
||||
|
||||
|
||||
async def test_agent_runs_hyperlight_codeact_end_to_end_with_fake_sandbox(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_FakeSandbox.instances.clear()
|
||||
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox)
|
||||
|
||||
@@ -24,6 +24,7 @@ from agent_framework._sessions import SessionContext
|
||||
|
||||
from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool
|
||||
from agent_framework_monty import _execute_code_tool as execute_code_module
|
||||
from agent_framework_monty import _instructions as instructions_module
|
||||
from agent_framework_monty import _monty_bridge as bridge_module
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -184,6 +185,14 @@ def dangerous_tool(payload: Annotated[str, "Anything"]) -> str:
|
||||
return payload
|
||||
|
||||
|
||||
def _decode_content_bytes(item: Content) -> bytes:
|
||||
import base64
|
||||
|
||||
assert item.uri is not None
|
||||
_, _, encoded = item.uri.partition("base64,")
|
||||
return base64.b64decode(encoded)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MontyExecuteCodeTool tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -335,6 +344,22 @@ def test_dynamic_description_default_mentions_no_filesystem() -> None:
|
||||
assert "Filesystem access is unavailable" in description
|
||||
|
||||
|
||||
def test_instruction_builders_describe_write_caps_and_visible_tools(tmp_path: Path) -> None:
|
||||
from agent_framework_monty import FileMount
|
||||
|
||||
mount = FileMount(host_path=tmp_path, mount_path="/work", mode="read-write", write_bytes_limit=128)
|
||||
description = instructions_module.build_execute_code_description(tools=[add_tool], mounts=[mount])
|
||||
instructions = instructions_module.build_codeact_instructions(
|
||||
tools=[add_tool],
|
||||
tools_visible_to_model=True,
|
||||
mounts=[mount],
|
||||
)
|
||||
|
||||
assert "write cap 128 bytes" in description
|
||||
assert "Files written to `/work` are returned" in description
|
||||
assert "Some tools may also appear directly" in instructions
|
||||
|
||||
|
||||
def test_resource_limits_round_trip() -> None:
|
||||
monty_tool = MontyExecuteCodeTool(resource_limits={"max_duration_secs": 5.0})
|
||||
assert monty_tool.resource_limits == {"max_duration_secs": 5.0}
|
||||
@@ -360,6 +385,61 @@ def test_execute_code_filtered_out_when_added_as_tool() -> None:
|
||||
assert [t.name for t in monty_tool.get_tools()] == ["add_tool"]
|
||||
|
||||
|
||||
def test_mount_helpers_validate_inputs_and_convert_mounts(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from agent_framework_monty import FileMount
|
||||
|
||||
host_dir = tmp_path / "host"
|
||||
host_dir.mkdir()
|
||||
file_path = tmp_path / "file.txt"
|
||||
file_path.write_text("x", encoding="utf-8")
|
||||
|
||||
assert execute_code_module._is_file_mount_pair((host_dir, "/work")) is True
|
||||
assert execute_code_module._is_file_mount_pair(FileMount(host_path=host_dir, mount_path="/work")) is False
|
||||
assert execute_code_module._is_file_mount_pair((host_dir, "/work", "extra")) is False
|
||||
assert execute_code_module._is_file_mount_pair((host_dir, 1)) is False
|
||||
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
execute_code_module._normalize_mount_path(" ")
|
||||
with pytest.raises(ValueError, match="must not contain '..' segments"):
|
||||
execute_code_module._normalize_mount_path("/work/../escape")
|
||||
with pytest.raises(ValueError, match="must point to a concrete absolute path"):
|
||||
execute_code_module._normalize_mount_path("/")
|
||||
with pytest.raises(ValueError, match="existing directory"):
|
||||
execute_code_module._resolve_existing_directory(file_path)
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class _FakeMountDir:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(bridge_module, "load_monty", lambda: types.SimpleNamespace(MountDir=_FakeMountDir))
|
||||
execute_code_module._to_monty_mount(
|
||||
FileMount(host_path=host_dir, mount_path="/work", mode="read-write", write_bytes_limit=12)
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"virtual_path": "/work",
|
||||
"host_path": str(host_dir),
|
||||
"mode": "read-write",
|
||||
"write_bytes_limit": 12,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_to_dict_materializes_dynamic_description(tmp_path: Path) -> None:
|
||||
monty_tool = MontyExecuteCodeTool(tools=[add_tool], workspace_root=tmp_path)
|
||||
serialized = monty_tool.to_dict()
|
||||
|
||||
assert monty_tool.workspace_root == tmp_path.resolve()
|
||||
assert "description" in serialized
|
||||
assert "add_tool" in serialized["description"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_code behavior with the fake Monty runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -507,6 +587,69 @@ async def test_run_code_returns_error_content_on_runtime_failure(monkeypatch: py
|
||||
assert "boom" in (result[0].error_details or "")
|
||||
|
||||
|
||||
def test_build_execution_contents_handles_truncation_and_non_json_output() -> None:
|
||||
truncated = execute_code_module._build_execution_contents(
|
||||
result={"stdout": "hello", "truncated": True, "output": complex(1, 2)}
|
||||
)
|
||||
assert [item.text for item in truncated] == ["hello\n\n[stdout truncated]", "(1+2j)"]
|
||||
|
||||
truncated_only = execute_code_module._build_execution_contents(
|
||||
result={"stdout": "", "truncated": True, "output": None}
|
||||
)
|
||||
assert [item.text for item in truncated_only] == ["[stdout truncated]"]
|
||||
|
||||
|
||||
def test_capture_written_files_returns_new_files_and_omits_large_ones(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from agent_framework_monty import FileMount
|
||||
|
||||
writable = tmp_path / "writable"
|
||||
writable.mkdir()
|
||||
readonly = tmp_path / "readonly"
|
||||
readonly.mkdir()
|
||||
nested = writable / "nested"
|
||||
nested.mkdir()
|
||||
|
||||
existing = writable / "existing.txt"
|
||||
existing.write_text("before", encoding="utf-8")
|
||||
(nested / "report.txt").write_text("old", encoding="utf-8")
|
||||
(readonly / "ignored.txt").write_text("unchanged", encoding="utf-8")
|
||||
|
||||
mounts = [
|
||||
FileMount(host_path=writable, mount_path="/work", mode="read-write"),
|
||||
FileMount(host_path=readonly, mount_path="/readonly", mode="read-only"),
|
||||
]
|
||||
pre_state = execute_code_module._snapshot_writable_mounts(mounts)
|
||||
|
||||
existing.write_text("after", encoding="utf-8")
|
||||
(nested / "report.txt").write_text("updated", encoding="utf-8")
|
||||
(writable / "artifact.bin").write_bytes(b"\x00\x01")
|
||||
(writable / "large.txt").write_text("123456789", encoding="utf-8")
|
||||
monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILE_BYTES", 8)
|
||||
|
||||
captured = execute_code_module._capture_written_files(mounts, pre_state)
|
||||
data_items = [item for item in captured if item.type == "data"]
|
||||
text_items = [item for item in captured if item.type == "text"]
|
||||
|
||||
assert set(pre_state) == {"/work"}
|
||||
assert "existing.txt" in pre_state["/work"]
|
||||
assert "ignored.txt" not in pre_state["/work"]
|
||||
assert {item.additional_properties["path"] for item in data_items} == {
|
||||
"/work/artifact.bin",
|
||||
"/work/existing.txt",
|
||||
"/work/nested/report.txt",
|
||||
}
|
||||
assert any("large.txt" in (item.text or "") and "omitted" in (item.text or "") for item in text_items)
|
||||
assert any(
|
||||
_decode_content_bytes(item) == b"after"
|
||||
for item in data_items
|
||||
if item.additional_properties["path"] == "/work/existing.txt"
|
||||
)
|
||||
assert all(not item.additional_properties["path"].startswith("/readonly/") for item in data_items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MontyCodeActProvider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -539,6 +682,20 @@ def test_provider_delegates_tool_management_to_internal_tool() -> None:
|
||||
assert provider.get_tools() == []
|
||||
|
||||
|
||||
def test_provider_delegates_file_mount_management_to_internal_tool(tmp_path: Path) -> None:
|
||||
provider = MontyCodeActProvider()
|
||||
provider.add_file_mounts((tmp_path, "/work"))
|
||||
|
||||
assert [mount.mount_path for mount in provider.get_file_mounts()] == ["/work"]
|
||||
|
||||
provider.remove_file_mount("/work")
|
||||
assert provider.get_file_mounts() == []
|
||||
|
||||
provider.add_file_mounts((tmp_path, "/again"))
|
||||
provider.clear_file_mounts()
|
||||
assert provider.get_file_mounts() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_type_stubs - signature smoke test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.10.2"
|
||||
version = "1.11.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Additional edge-case coverage for ``RedisContextProvider``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework.exceptions import IntegrationInvalidRequestException
|
||||
from redisvl.utils.vectorize import BaseVectorizer
|
||||
|
||||
from agent_framework_redis._context_provider import RedisContextProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_index() -> AsyncMock:
|
||||
index = AsyncMock()
|
||||
index.create = AsyncMock()
|
||||
index.exists = AsyncMock(return_value=False)
|
||||
index.load = AsyncMock()
|
||||
index.query = AsyncMock(return_value=[])
|
||||
return index
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_index(mock_index: AsyncMock) -> Generator[MagicMock]:
|
||||
with patch("agent_framework_redis._context_provider.AsyncSearchIndex") as mock_cls:
|
||||
mock_cls.from_dict = MagicMock(return_value=mock_index)
|
||||
mock_cls.from_existing = AsyncMock()
|
||||
yield mock_cls
|
||||
|
||||
|
||||
def test_build_filter_from_dict_combines_multiple_tags(
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1")
|
||||
combined = provider._build_filter_from_dict({
|
||||
"application_id": "app-1",
|
||||
"agent_id": None,
|
||||
"user_id": "user-1",
|
||||
})
|
||||
|
||||
assert combined is not None
|
||||
assert str(combined) == "(@application_id:{app\\-1} @user_id:{user\\-1})"
|
||||
|
||||
|
||||
def test_schema_dict_includes_vector_configuration(
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
vectorizer = MagicMock(spec=BaseVectorizer)
|
||||
vectorizer.dims = 3
|
||||
vectorizer.dtype = "float16"
|
||||
|
||||
provider = RedisContextProvider(
|
||||
source_id="ctx",
|
||||
user_id="user-1",
|
||||
redis_vectorizer=vectorizer,
|
||||
vector_field_name="embedding",
|
||||
vector_algorithm="flat",
|
||||
vector_distance_metric="l2",
|
||||
)
|
||||
|
||||
vector_field = next(field for field in provider.schema_dict["fields"] if field["name"] == "embedding")
|
||||
|
||||
assert vector_field["type"] == "vector"
|
||||
assert vector_field["attrs"] == {
|
||||
"algorithm": "flat",
|
||||
"dims": 3,
|
||||
"distance_metric": "l2",
|
||||
"datatype": "float16",
|
||||
}
|
||||
|
||||
|
||||
async def test_ensure_index_short_circuits_after_first_initialization(
|
||||
mock_index: AsyncMock,
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1")
|
||||
provider._index_initialized = True
|
||||
|
||||
await provider._ensure_index()
|
||||
|
||||
mock_index.exists.assert_not_called()
|
||||
mock_index.create.assert_not_called()
|
||||
|
||||
|
||||
async def test_ensure_index_validates_existing_schema_before_create(
|
||||
mock_index: AsyncMock,
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
mock_index.exists.return_value = True
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1")
|
||||
|
||||
with patch.object(provider, "_validate_schema_compatibility", AsyncMock()) as validate_schema:
|
||||
await provider._ensure_index()
|
||||
|
||||
validate_schema.assert_awaited_once()
|
||||
mock_index.create.assert_awaited_once_with(overwrite=False, drop=False)
|
||||
assert provider._index_initialized is True
|
||||
|
||||
|
||||
async def test_validate_schema_compatibility_raises_for_significant_mismatch(
|
||||
patch_index: MagicMock,
|
||||
) -> None:
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1")
|
||||
existing_index = AsyncMock()
|
||||
existing_index.schema.to_dict = MagicMock(
|
||||
return_value={
|
||||
"index": {"name": "context", "prefix": "other", "key_separator": ":", "storage_type": "hash"},
|
||||
"fields": [{"name": "content", "type": "text"}],
|
||||
}
|
||||
)
|
||||
patch_index.from_existing = AsyncMock(return_value=existing_index)
|
||||
|
||||
with pytest.raises(ValueError, match="overwrite_index=True"):
|
||||
await provider._validate_schema_compatibility()
|
||||
|
||||
|
||||
async def test_add_requires_content_field(
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1")
|
||||
|
||||
with (
|
||||
patch.object(provider, "_ensure_index", AsyncMock()),
|
||||
pytest.raises(IntegrationInvalidRequestException, match="requires a 'content' field"),
|
||||
):
|
||||
await provider._add(data={"role": "user"}, session_id="session-1")
|
||||
|
||||
|
||||
async def test_add_vectorizes_documents_and_applies_defaults(
|
||||
mock_index: AsyncMock,
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
vectorizer = MagicMock(spec=BaseVectorizer)
|
||||
vectorizer.dims = 2
|
||||
vectorizer.dtype = "float32"
|
||||
vectorizer.aembed_many = AsyncMock(return_value=[[1.0, 2.0], [3.0, 4.0]])
|
||||
|
||||
provider = RedisContextProvider(
|
||||
source_id="ctx",
|
||||
application_id="app-1",
|
||||
agent_id="agent-1",
|
||||
user_id="user-1",
|
||||
redis_vectorizer=vectorizer,
|
||||
vector_field_name="embedding",
|
||||
)
|
||||
|
||||
with patch.object(provider, "_ensure_index", AsyncMock()):
|
||||
await provider._add(
|
||||
data=[
|
||||
{"content": "first"},
|
||||
{"content": "second", "conversation_id": "custom-conversation"},
|
||||
],
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
loaded_docs = mock_index.load.await_args.args[0]
|
||||
assert [doc["content"] for doc in loaded_docs] == ["first", "second"]
|
||||
assert loaded_docs[0]["application_id"] == "app-1"
|
||||
assert loaded_docs[0]["agent_id"] == "agent-1"
|
||||
assert loaded_docs[0]["user_id"] == "user-1"
|
||||
assert loaded_docs[0]["thread_id"] == "session-1"
|
||||
assert loaded_docs[0]["conversation_id"] == "session-1"
|
||||
assert isinstance(loaded_docs[0]["embedding"], bytes)
|
||||
assert isinstance(loaded_docs[1]["embedding"], bytes)
|
||||
vectorizer.aembed_many.assert_awaited_once_with(["first", "second"], batch_size=2)
|
||||
|
||||
|
||||
async def test_redis_search_requires_non_empty_text(
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1")
|
||||
|
||||
with (
|
||||
patch.object(provider, "_ensure_index", AsyncMock()),
|
||||
pytest.raises(IntegrationInvalidRequestException, match="non-empty text"),
|
||||
):
|
||||
await provider._redis_search(text=" ")
|
||||
|
||||
|
||||
async def test_redis_search_combines_explicit_filter_expression(
|
||||
mock_index: AsyncMock,
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1", application_id="app-1")
|
||||
base_filter = MagicMock()
|
||||
merged_filter = object()
|
||||
base_filter.__and__.return_value = merged_filter
|
||||
explicit_filter = object()
|
||||
|
||||
with (
|
||||
patch.object(provider, "_ensure_index", AsyncMock()),
|
||||
patch.object(provider, "_build_filter_from_dict", return_value=base_filter),
|
||||
patch("agent_framework_redis._context_provider.TextQuery") as text_query,
|
||||
):
|
||||
text_query.return_value = MagicMock()
|
||||
await provider._redis_search(
|
||||
text="hello redis",
|
||||
session_id="session-1",
|
||||
filter_expression=explicit_filter,
|
||||
return_fields=["content"],
|
||||
num_results=3,
|
||||
)
|
||||
|
||||
base_filter.__and__.assert_called_once_with(explicit_filter)
|
||||
assert text_query.call_args.kwargs["filter_expression"] is merged_filter
|
||||
assert text_query.call_args.kwargs["return_fields"] == ["content"]
|
||||
assert text_query.call_args.kwargs["num_results"] == 3
|
||||
mock_index.query.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_search_all_collects_paginated_batches(
|
||||
mock_index: AsyncMock,
|
||||
patch_index: MagicMock, # noqa: ARG001
|
||||
) -> None:
|
||||
provider = RedisContextProvider(source_id="ctx", user_id="user-1")
|
||||
|
||||
async def paginate(*args: Any, **kwargs: Any) -> AsyncIterator[list[dict[str, str]]]: # noqa: ARG001
|
||||
yield [{"content": "first"}]
|
||||
yield [{"content": "second"}, {"content": "third"}]
|
||||
|
||||
mock_index.paginate = MagicMock(return_value=paginate())
|
||||
|
||||
results = await provider.search_all(page_size=2)
|
||||
|
||||
assert results == [
|
||||
{"content": "first"},
|
||||
{"content": "second"},
|
||||
{"content": "third"},
|
||||
]
|
||||
@@ -10,14 +10,21 @@ available in CI / dev sandboxes).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from typing import TypeAlias
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_tools.shell import (
|
||||
DockerNotAvailableError,
|
||||
DockerShellTool,
|
||||
ShellCommandError,
|
||||
ShellExecutor,
|
||||
ShellResult,
|
||||
is_docker_available,
|
||||
)
|
||||
from agent_framework_tools.shell._docker import (
|
||||
@@ -25,6 +32,44 @@ from agent_framework_tools.shell._docker import (
|
||||
build_run_argv,
|
||||
)
|
||||
|
||||
_CommunicateOutcome: TypeAlias = tuple[bytes, bytes] | BaseException
|
||||
_WaitOutcome: TypeAlias = int | None | BaseException
|
||||
|
||||
|
||||
class _FakeProcess:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
pid: int = 1234,
|
||||
returncode: int | None = 0,
|
||||
communicate_results: Sequence[_CommunicateOutcome] | None = None,
|
||||
wait_results: Sequence[_WaitOutcome] | None = None,
|
||||
) -> None:
|
||||
self.pid = pid
|
||||
self.returncode = returncode
|
||||
self.stdout = object()
|
||||
self.stderr = object()
|
||||
self.killed = False
|
||||
self._communicate_results = list(communicate_results or [(b"", b"")])
|
||||
self._wait_results = list(wait_results or [returncode])
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
result = self._communicate_results.pop(0)
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
stdout, stderr = result
|
||||
return stdout, stderr
|
||||
|
||||
async def wait(self) -> int | None:
|
||||
result = self._wait_results.pop(0)
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
self.returncode = result
|
||||
return result
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
|
||||
def _docker_image_available(image: str) -> bool:
|
||||
if not is_docker_available():
|
||||
@@ -236,6 +281,233 @@ def test_as_function_carries_shell_kind():
|
||||
)
|
||||
|
||||
|
||||
async def test_start_and_close_are_noops_in_stateless_mode() -> None:
|
||||
tool = DockerShellTool(mode="stateless")
|
||||
|
||||
with (
|
||||
patch.object(tool, "_start_container", AsyncMock()) as start_container,
|
||||
patch.object(tool, "_stop_container", AsyncMock()) as stop_container,
|
||||
):
|
||||
await tool.start()
|
||||
await tool.close()
|
||||
|
||||
start_container.assert_not_called()
|
||||
stop_container.assert_not_called()
|
||||
|
||||
|
||||
async def test_start_creates_and_reuses_persistent_session() -> None:
|
||||
tool = DockerShellTool(docker_binary="podman", shell="sh")
|
||||
session = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(tool, "_start_container", AsyncMock()) as start_container,
|
||||
patch("agent_framework_tools.shell._docker.ShellSession", return_value=session) as shell_session,
|
||||
):
|
||||
await tool.start()
|
||||
await tool.start()
|
||||
|
||||
start_container.assert_awaited_once()
|
||||
shell_session.assert_called_once_with(
|
||||
["podman", "exec", "-i", tool._container_name, "sh"],
|
||||
workdir=None,
|
||||
env=None,
|
||||
max_output_bytes=tool._max_output_bytes,
|
||||
)
|
||||
assert session.start.await_count == 2
|
||||
|
||||
|
||||
async def test_close_terminates_session_and_container() -> None:
|
||||
tool = DockerShellTool()
|
||||
tool._container_started = True
|
||||
session = AsyncMock()
|
||||
tool._session = session
|
||||
|
||||
with patch.object(tool, "_stop_container", AsyncMock()) as stop_container:
|
||||
await tool.close()
|
||||
|
||||
session.close.assert_awaited_once()
|
||||
stop_container.assert_awaited_once()
|
||||
assert tool._session is None
|
||||
assert tool._container_started is False
|
||||
|
||||
|
||||
async def test_run_rejects_denied_commands() -> None:
|
||||
tool = DockerShellTool(
|
||||
policy=MagicMock(evaluate=MagicMock(return_value=MagicMock(decision="deny", reason="blocked")))
|
||||
)
|
||||
|
||||
with pytest.raises(ShellCommandError, match="blocked"):
|
||||
await tool.run("danger")
|
||||
|
||||
|
||||
async def test_run_logs_audit_hook_failures_and_executes_persistent_command(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
def broken_hook(command: str) -> None:
|
||||
raise RuntimeError(f"boom:{command}")
|
||||
|
||||
tool = DockerShellTool(on_command=broken_hook)
|
||||
tool._session = AsyncMock(run=AsyncMock(return_value=ShellResult("", "", 0, 1)))
|
||||
|
||||
result = await tool.run("echo hi")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "on_command hook raised" in caplog.text
|
||||
tool._session.run.assert_awaited_once_with("echo hi", timeout=30.0)
|
||||
|
||||
|
||||
async def test_run_raises_if_start_did_not_create_persistent_session() -> None:
|
||||
tool = DockerShellTool()
|
||||
|
||||
with patch.object(tool, "start", AsyncMock()), pytest.raises(RuntimeError, match="session failed to start"):
|
||||
await tool.run("echo hi")
|
||||
|
||||
|
||||
async def test_run_dispatches_to_private_stateless_runner() -> None:
|
||||
tool = DockerShellTool(mode="stateless")
|
||||
expected = ShellResult(stdout="ok", stderr="", exit_code=0, duration_ms=1)
|
||||
|
||||
with patch.object(tool, "_run_stateless", AsyncMock(return_value=expected)) as run_stateless:
|
||||
result = await tool.run("echo hi", timeout=9.0)
|
||||
|
||||
assert result is expected
|
||||
run_stateless.assert_awaited_once_with("echo hi", timeout=9.0)
|
||||
|
||||
|
||||
async def test_run_stateless_builds_expected_argv() -> None:
|
||||
tool = DockerShellTool(
|
||||
mode="stateless",
|
||||
docker_binary="podman",
|
||||
image="alpine:3",
|
||||
shell="sh",
|
||||
host_workdir="/repo",
|
||||
workdir="/workspace",
|
||||
mount_readonly=False,
|
||||
env={"AF_TEST": "1"},
|
||||
)
|
||||
proc = _FakeProcess(returncode=3, communicate_results=[(b"hello\n", b"warning\n")])
|
||||
|
||||
with patch(
|
||||
"agent_framework_tools.shell._docker.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=proc),
|
||||
) as create_proc:
|
||||
result = await tool._run_stateless("echo hi", timeout=12.0)
|
||||
|
||||
assert create_proc.await_args is not None
|
||||
argv = create_proc.await_args.args
|
||||
assert argv[:4] == ("podman", "run", "--rm", "-i")
|
||||
assert "-v" in argv
|
||||
assert "/repo:/workspace:rw" in argv
|
||||
assert "AF_TEST=1" in argv
|
||||
assert argv[-4:] == ("alpine:3", "sh", "-c", "echo hi")
|
||||
assert result.stdout == "hello\n"
|
||||
assert result.stderr == "warning\n"
|
||||
assert result.exit_code == 3
|
||||
assert result.timed_out is False
|
||||
|
||||
|
||||
async def test_run_stateless_timeout_reaps_container_when_kill_fails() -> None:
|
||||
tool = DockerShellTool(mode="stateless")
|
||||
command_proc = _FakeProcess(
|
||||
returncode=137,
|
||||
communicate_results=[asyncio.TimeoutError(), (b"after-timeout", b"stderr")],
|
||||
)
|
||||
killer = _FakeProcess(returncode=1)
|
||||
reaper = _FakeProcess(returncode=9, communicate_results=[(b"", b"rm failed")])
|
||||
|
||||
with patch(
|
||||
"agent_framework_tools.shell._docker.asyncio.create_subprocess_exec",
|
||||
AsyncMock(side_effect=[command_proc, killer, reaper]),
|
||||
):
|
||||
result = await tool._run_stateless("sleep 5", timeout=0.01)
|
||||
|
||||
assert result.timed_out is True
|
||||
assert result.exit_code == 137
|
||||
assert result.stdout == "after-timeout"
|
||||
assert result.stderr == "stderr"
|
||||
|
||||
|
||||
async def test_run_stateless_timeout_handles_kill_and_reaper_timeouts() -> None:
|
||||
tool = DockerShellTool(mode="stateless")
|
||||
command_proc = _FakeProcess(
|
||||
returncode=None,
|
||||
communicate_results=[asyncio.TimeoutError(), RuntimeError("drain failed")],
|
||||
)
|
||||
killer = _FakeProcess(returncode=None, wait_results=[asyncio.TimeoutError()])
|
||||
reaper = _FakeProcess(returncode=None, communicate_results=[asyncio.TimeoutError()])
|
||||
|
||||
with patch(
|
||||
"agent_framework_tools.shell._docker.asyncio.create_subprocess_exec",
|
||||
AsyncMock(side_effect=[command_proc, killer, reaper]),
|
||||
):
|
||||
result = await tool._run_stateless("sleep 5", timeout=0.01)
|
||||
|
||||
assert killer.killed is True
|
||||
assert reaper.killed is True
|
||||
assert result.timed_out is True
|
||||
assert result.exit_code == -1
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
async def test_start_container_success_logs_container_id(caplog: pytest.LogCaptureFixture) -> None:
|
||||
tool = DockerShellTool()
|
||||
proc = _FakeProcess(returncode=0, communicate_results=[(b"abcdef1234567890\n", b"")])
|
||||
|
||||
with (
|
||||
caplog.at_level("INFO", logger="agent_framework_tools.shell._docker"),
|
||||
patch("agent_framework_tools.shell._docker.asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
|
||||
):
|
||||
await tool._start_container()
|
||||
|
||||
assert f"started docker container {tool._container_name}" in caplog.text
|
||||
|
||||
|
||||
async def test_start_container_raises_when_runtime_fails() -> None:
|
||||
tool = DockerShellTool()
|
||||
proc = _FakeProcess(returncode=7, communicate_results=[(b"", b"daemon unavailable")])
|
||||
|
||||
with (
|
||||
patch("agent_framework_tools.shell._docker.asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
|
||||
pytest.raises(DockerNotAvailableError, match="daemon unavailable"),
|
||||
):
|
||||
await tool._start_container()
|
||||
|
||||
|
||||
async def test_stop_container_returns_after_first_success() -> None:
|
||||
tool = DockerShellTool()
|
||||
proc = _FakeProcess(returncode=0, communicate_results=[(b"", b"")])
|
||||
|
||||
with patch("agent_framework_tools.shell._docker.asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
|
||||
await tool._stop_container()
|
||||
|
||||
|
||||
async def test_stop_container_retries_when_first_attempt_fails() -> None:
|
||||
tool = DockerShellTool()
|
||||
first = _FakeProcess(returncode=1, communicate_results=[(b"", b"still running")])
|
||||
second = _FakeProcess(returncode=2, communicate_results=[(b"", b"still running")])
|
||||
|
||||
with patch(
|
||||
"agent_framework_tools.shell._docker.asyncio.create_subprocess_exec",
|
||||
AsyncMock(side_effect=[first, second]),
|
||||
) as create_proc:
|
||||
await tool._stop_container()
|
||||
|
||||
assert create_proc.await_count == 2
|
||||
|
||||
|
||||
async def test_as_function_surfaces_command_errors() -> None:
|
||||
tool = DockerShellTool(mode="persistent")
|
||||
|
||||
with patch.object(tool, "run", AsyncMock(side_effect=ShellCommandError("blocked"))):
|
||||
function = tool.as_function()
|
||||
assert function.func is not None
|
||||
result = await function.func("pwd")
|
||||
|
||||
assert result == "blocked"
|
||||
assert "persistent session" in function.description
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- integration
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_tools.shell import LocalShellTool, ShellCommandError, ShellPolicy
|
||||
from agent_framework_tools.shell._executor import _popen_kwargs_for_group, run_stateless
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
class _FakeExecProcess:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
returncode: int | None = 0,
|
||||
communicate_results: list[tuple[bytes, bytes] | BaseException] | None = None,
|
||||
) -> None:
|
||||
self.returncode = returncode
|
||||
self.stdout = object()
|
||||
self.stderr = object()
|
||||
self._communicate_results = list(communicate_results or [(b"", b"")])
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
result = self._communicate_results.pop(0)
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
stdout, stderr = result
|
||||
return stdout, stderr
|
||||
|
||||
|
||||
async def test_stateless_echo() -> None:
|
||||
@@ -71,6 +92,123 @@ async def test_audit_hook_fires_for_allowed_commands() -> None:
|
||||
assert seen == [cmd]
|
||||
|
||||
|
||||
def test_local_shell_tool_handles_mode_and_environment_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
with pytest.raises(ValueError, match="mode must be"):
|
||||
LocalShellTool(mode="bogus") # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
|
||||
|
||||
monkeypatch.setenv("INHERITED", "yes")
|
||||
inherited = LocalShellTool(
|
||||
mode="stateless",
|
||||
approval_mode="never_require",
|
||||
acknowledge_unsafe=True,
|
||||
env={"EXTRA": "1"},
|
||||
)
|
||||
clean = LocalShellTool(
|
||||
mode="stateless",
|
||||
approval_mode="never_require",
|
||||
acknowledge_unsafe=True,
|
||||
env={"ONLY": "2"},
|
||||
clean_env=True,
|
||||
)
|
||||
|
||||
assert inherited._env is not None
|
||||
assert inherited._env["INHERITED"] == "yes"
|
||||
assert inherited._env["EXTRA"] == "1"
|
||||
assert clean._env == {"ONLY": "2"}
|
||||
|
||||
|
||||
async def test_local_shell_tool_stateless_start_is_noop() -> None:
|
||||
tool = LocalShellTool(mode="stateless", approval_mode="never_require", acknowledge_unsafe=True)
|
||||
await tool.start()
|
||||
await tool.close()
|
||||
|
||||
|
||||
async def test_local_shell_tool_raises_if_start_did_not_create_session() -> None:
|
||||
tool = LocalShellTool(mode="persistent", approval_mode="never_require", acknowledge_unsafe=True)
|
||||
|
||||
with patch.object(tool, "start", AsyncMock()), pytest.raises(RuntimeError, match="session failed to start"):
|
||||
await tool.run("echo hi")
|
||||
|
||||
|
||||
async def test_local_shell_tool_as_function_returns_policy_errors() -> None:
|
||||
tool = LocalShellTool(mode="persistent", approval_mode="never_require", acknowledge_unsafe=True)
|
||||
|
||||
with patch.object(tool, "run", AsyncMock(side_effect=ShellCommandError("blocked"))):
|
||||
function = tool.as_function(description="custom shell")
|
||||
assert function.func is not None
|
||||
result = await function.func("pwd")
|
||||
|
||||
assert result == "blocked"
|
||||
assert function.description == "custom shell"
|
||||
|
||||
|
||||
def test_local_shell_tool_reanchors_powershell_paths() -> None:
|
||||
tool = LocalShellTool(
|
||||
mode="persistent",
|
||||
shell="pwsh",
|
||||
workdir="C:\\repo",
|
||||
approval_mode="never_require",
|
||||
acknowledge_unsafe=True,
|
||||
)
|
||||
|
||||
assert tool._maybe_reanchor("Get-ChildItem").startswith("Set-Location -LiteralPath 'C:\\repo'")
|
||||
|
||||
|
||||
def test_popen_kwargs_for_group_covers_windows_branch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import agent_framework_tools.shell._executor as executor_module
|
||||
|
||||
monkeypatch.setattr(executor_module.sys, "platform", "win32")
|
||||
monkeypatch.setattr(executor_module.subprocess, "CREATE_NEW_PROCESS_GROUP", 77, raising=False)
|
||||
|
||||
assert _popen_kwargs_for_group() == {"creationflags": 77}
|
||||
|
||||
|
||||
async def test_run_stateless_adds_powershell_encoding_preamble() -> None:
|
||||
proc = _FakeExecProcess(returncode=0, communicate_results=[(b"ok", b"")])
|
||||
|
||||
with (
|
||||
patch("agent_framework_tools.shell._executor.is_powershell", return_value=True),
|
||||
patch(
|
||||
"agent_framework_tools.shell._executor.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=proc),
|
||||
) as create_proc,
|
||||
):
|
||||
result = await run_stateless(
|
||||
["pwsh", "-Command"],
|
||||
"Write-Output hi",
|
||||
workdir=None,
|
||||
env=None,
|
||||
timeout=1.0,
|
||||
max_output_bytes=1024,
|
||||
)
|
||||
|
||||
assert result.stdout == "ok"
|
||||
assert create_proc.await_args is not None
|
||||
assert create_proc.await_args.args[-1].startswith("$OutputEncoding = [Console]::OutputEncoding")
|
||||
|
||||
|
||||
async def test_run_stateless_timeout_returns_empty_output_if_drain_fails() -> None:
|
||||
proc = _FakeExecProcess(returncode=None, communicate_results=[asyncio.TimeoutError(), RuntimeError("drain failed")])
|
||||
|
||||
with (
|
||||
patch("agent_framework_tools.shell._executor.asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
|
||||
patch("agent_framework_tools.shell._executor.kill_process_tree", AsyncMock()) as kill_tree,
|
||||
):
|
||||
result = await run_stateless(
|
||||
["/bin/sh", "-c"],
|
||||
"sleep 5",
|
||||
workdir=None,
|
||||
env=None,
|
||||
timeout=0.01,
|
||||
max_output_bytes=1024,
|
||||
)
|
||||
|
||||
kill_tree.assert_awaited_once_with(proc)
|
||||
assert result.timed_out is True
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="persistent-mode sentinel on POSIX")
|
||||
async def test_persistent_preserves_cwd_and_exports_across_calls(tmp_path: os.PathLike[str]) -> None:
|
||||
async with LocalShellTool(
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from agent_framework_tools.shell._killtree import (
|
||||
_kill_via_psutil,
|
||||
_kill_via_stdlib,
|
||||
_resolve_taskkill,
|
||||
kill_process_tree,
|
||||
)
|
||||
|
||||
|
||||
class _FakeAsyncProcess:
|
||||
def __init__(self, *, pid: int = 101, returncode: int | None = None) -> None:
|
||||
self.pid = pid
|
||||
self.returncode = returncode
|
||||
self.killed = False
|
||||
|
||||
async def wait(self) -> int | None:
|
||||
return self.returncode
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
|
||||
class _FakeExecProcess(_FakeAsyncProcess):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
returncode: int | None = 0,
|
||||
communicate_results: list[tuple[bytes, bytes] | BaseException] | None = None,
|
||||
) -> None:
|
||||
super().__init__(returncode=returncode)
|
||||
self.stdout = object()
|
||||
self.stderr = object()
|
||||
self._communicate_results = list(communicate_results or [(b"", b"")])
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
result = self._communicate_results.pop(0)
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
stdout, stderr = result
|
||||
return stdout, stderr
|
||||
|
||||
|
||||
def test_resolve_taskkill_uses_systemroot_and_caches(monkeypatch) -> None:
|
||||
import agent_framework_tools.shell._killtree as killtree_module
|
||||
|
||||
monkeypatch.setattr(killtree_module, "_taskkill_path", None)
|
||||
monkeypatch.setenv("SystemRoot", "C:\\Windows")
|
||||
monkeypatch.setattr(killtree_module.os.path, "isfile", lambda path: path.endswith("taskkill.exe"))
|
||||
|
||||
expected_path = os.path.join("C:\\Windows", "System32", "taskkill.exe")
|
||||
assert _resolve_taskkill() == expected_path
|
||||
assert _resolve_taskkill() == expected_path
|
||||
|
||||
|
||||
async def test_kill_process_tree_short_circuits_or_delegates() -> None:
|
||||
proc = cast(asyncio.subprocess.Process, _FakeAsyncProcess(returncode=0))
|
||||
await kill_process_tree(proc)
|
||||
|
||||
live = cast(asyncio.subprocess.Process, _FakeAsyncProcess(returncode=None))
|
||||
with (
|
||||
patch("agent_framework_tools.shell._killtree._kill_via_psutil", AsyncMock()) as via_psutil,
|
||||
patch("agent_framework_tools.shell._killtree._has_psutil", True),
|
||||
):
|
||||
await kill_process_tree(live)
|
||||
|
||||
via_psutil.assert_awaited_once_with(live, grace=2.0)
|
||||
|
||||
|
||||
async def test_kill_via_psutil_terminates_parent_and_children() -> None:
|
||||
import agent_framework_tools.shell._killtree as killtree_module
|
||||
|
||||
no_such_process = type("NoSuchProcess", (Exception,), {})
|
||||
access_denied = type("AccessDenied", (Exception,), {})
|
||||
child = MagicMock(is_running=MagicMock(return_value=True))
|
||||
parent = MagicMock(children=MagicMock(return_value=[child]), is_running=MagicMock(return_value=True))
|
||||
fake_psutil = MagicMock(
|
||||
Process=MagicMock(return_value=parent),
|
||||
NoSuchProcess=no_such_process,
|
||||
AccessDenied=access_denied,
|
||||
)
|
||||
proc = cast(asyncio.subprocess.Process, _FakeAsyncProcess(pid=4321, returncode=None))
|
||||
|
||||
with patch.object(killtree_module, "psutil", fake_psutil):
|
||||
await _kill_via_psutil(proc, grace=0.01)
|
||||
|
||||
parent.terminate.assert_called_once()
|
||||
child.terminate.assert_called_once()
|
||||
parent.kill.assert_called_once()
|
||||
child.kill.assert_called_once()
|
||||
|
||||
|
||||
async def test_kill_via_psutil_handles_missing_parent_process() -> None:
|
||||
import agent_framework_tools.shell._killtree as killtree_module
|
||||
|
||||
no_such_process = type("NoSuchProcess", (Exception,), {})
|
||||
fake_psutil = MagicMock(Process=MagicMock(side_effect=no_such_process()), NoSuchProcess=no_such_process)
|
||||
proc = cast(asyncio.subprocess.Process, _FakeAsyncProcess(pid=9999, returncode=None))
|
||||
|
||||
with patch.object(killtree_module, "psutil", fake_psutil):
|
||||
await _kill_via_psutil(proc, grace=0.01)
|
||||
|
||||
|
||||
async def test_kill_via_stdlib_windows_uses_taskkill_and_proc_kill(monkeypatch) -> None:
|
||||
import agent_framework_tools.shell._killtree as killtree_module
|
||||
|
||||
monkeypatch.setattr(killtree_module.sys, "platform", "win32")
|
||||
monkeypatch.setattr(killtree_module, "_resolve_taskkill", lambda: "C:\\Windows\\System32\\taskkill.exe")
|
||||
killer = _FakeExecProcess(returncode=None)
|
||||
raw_proc = _FakeAsyncProcess(pid=55, returncode=None)
|
||||
proc = cast(asyncio.subprocess.Process, raw_proc)
|
||||
|
||||
with patch("agent_framework_tools.shell._killtree.asyncio.create_subprocess_exec", AsyncMock(return_value=killer)):
|
||||
await _kill_via_stdlib(proc, grace=0.01)
|
||||
|
||||
assert killer.killed is True
|
||||
assert raw_proc.killed is True
|
||||
|
||||
|
||||
async def test_kill_via_stdlib_posix_escalates_to_sigkill(monkeypatch) -> None:
|
||||
import agent_framework_tools.shell._killtree as killtree_module
|
||||
|
||||
monkeypatch.setattr(killtree_module.sys, "platform", "darwin")
|
||||
killpg = MagicMock()
|
||||
monkeypatch.setattr(killtree_module.os, "getpgid", lambda pid: 99, raising=False)
|
||||
monkeypatch.setattr(killtree_module.os, "killpg", killpg, raising=False)
|
||||
monkeypatch.setattr(killtree_module.signal, "SIGKILL", 9, raising=False)
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
async def fake_wait_for(awaitable: Any, timeout: float) -> None:
|
||||
del timeout
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
awaitable.close()
|
||||
raise asyncio.TimeoutError
|
||||
await awaitable
|
||||
|
||||
proc = cast(asyncio.subprocess.Process, _FakeAsyncProcess(pid=12, returncode=None))
|
||||
|
||||
with patch("agent_framework_tools.shell._killtree.asyncio.wait_for", side_effect=fake_wait_for):
|
||||
await _kill_via_stdlib(proc, grace=0.01)
|
||||
|
||||
assert killpg.call_args_list[0].args == (99, killtree_module.signal.SIGTERM)
|
||||
assert killpg.call_args_list[1].args == (99, killtree_module.signal.SIGKILL)
|
||||
@@ -3,7 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework_tools.shell import ShellExecutionError
|
||||
from agent_framework_tools.shell._resolve import resolve_shell
|
||||
from agent_framework_tools.shell._resolve import _ensure_command_flag, is_powershell, resolve_shell
|
||||
|
||||
|
||||
def test_empty_string_shell_override_rejected() -> None:
|
||||
@@ -21,6 +21,52 @@ def test_empty_sequence_shell_override_rejected() -> None:
|
||||
resolve_shell([], interactive=True)
|
||||
|
||||
|
||||
def test_resolve_shell_prefers_environment_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AGENT_FRAMEWORK_SHELL", "/custom/pwsh -NoProfile")
|
||||
|
||||
assert resolve_shell(None, interactive=False) == ["/custom/pwsh", "-NoProfile", "-Command"]
|
||||
|
||||
|
||||
def test_resolve_shell_windows_defaults_and_missing_binary(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import agent_framework_tools.shell._resolve as resolve_module
|
||||
|
||||
monkeypatch.setattr(resolve_module.sys, "platform", "win32")
|
||||
monkeypatch.setattr(resolve_module.shutil, "which", lambda name: "C:/pwsh.exe" if name == "pwsh" else None)
|
||||
assert resolve_shell(None, interactive=True) == [
|
||||
"C:/pwsh.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"-",
|
||||
]
|
||||
|
||||
monkeypatch.setattr(resolve_module.shutil, "which", lambda name: None)
|
||||
with pytest.raises(ShellExecutionError, match="Neither 'pwsh' nor 'powershell'"):
|
||||
resolve_shell(None, interactive=False)
|
||||
|
||||
|
||||
def test_resolve_shell_posix_fallbacks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import agent_framework_tools.shell._resolve as resolve_module
|
||||
|
||||
monkeypatch.setattr(resolve_module.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(resolve_module.os.path, "exists", lambda candidate: candidate == "/bin/sh")
|
||||
assert resolve_shell(None, interactive=False) == ["/bin/sh", "-c"]
|
||||
|
||||
monkeypatch.setattr(resolve_module.os.path, "exists", lambda candidate: False)
|
||||
monkeypatch.setattr(resolve_module.shutil, "which", lambda name: "/usr/local/bin/sh" if name == "sh" else None)
|
||||
assert resolve_shell(None, interactive=True) == ["/usr/local/bin/sh"]
|
||||
|
||||
monkeypatch.setattr(resolve_module.shutil, "which", lambda name: None)
|
||||
with pytest.raises(ShellExecutionError, match="No POSIX shell found"):
|
||||
resolve_shell(None, interactive=False)
|
||||
|
||||
|
||||
def test_is_powershell_and_command_flag_helpers() -> None:
|
||||
assert is_powershell([]) is False
|
||||
assert _ensure_command_flag([]) == []
|
||||
|
||||
|
||||
def test_stateless_appends_dash_c_for_posix_shell_without_flag() -> None:
|
||||
argv = resolve_shell("/bin/bash", interactive=False)
|
||||
assert argv == ["/bin/bash", "-c"]
|
||||
|
||||
+25
-3
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.12.0"
|
||||
version = "1.12.1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core[all]==1.12.0",
|
||||
"agent-framework-core[all]==1.12.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -395,10 +395,32 @@ args = [
|
||||
]
|
||||
|
||||
[tool.poe.tasks.validate-dependency-bounds-test]
|
||||
help = "Run workspace dependency-bound validation in test mode, optionally scoped with -P/--package short names such as `core`."
|
||||
help = "Run the exhaustive workspace dependency-bound test+typing matrix, optionally scoped with -P/--package short names such as `core`."
|
||||
shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test --package \"$project\""
|
||||
args = [{ name = "project", default = "*", options = ["-P", "--package"] }]
|
||||
|
||||
[tool.poe.tasks.validate-python-release]
|
||||
help = "Refresh uv.lock, then run lower/upper import probes for changed package metadata on each package closure's minimum Python."
|
||||
executor = "simple"
|
||||
shell = """
|
||||
command=(
|
||||
python -m scripts.dependencies.validate_dependency_bounds
|
||||
--mode release
|
||||
--base-ref "${base_ref}"
|
||||
--release-timeout-seconds "${timeout}"
|
||||
)
|
||||
if [ -n "${python}" ]; then
|
||||
command+=(--python "${python}")
|
||||
fi
|
||||
"${command[@]}"
|
||||
"""
|
||||
interpreter = "bash"
|
||||
args = [
|
||||
{ name = "base_ref", options = ["-B", "--base-ref"] },
|
||||
{ name = "python", default = "", options = ["--python"] },
|
||||
{ name = "timeout", default = "300", options = ["--timeout-seconds"] },
|
||||
]
|
||||
|
||||
[tool.poe.tasks.validate-dependency-bounds-project]
|
||||
help = "Validate lower and upper dependency bounds for a -P/--package workspace package, optionally narrowed with -M/--mode and -D/--dependency."
|
||||
shell = """
|
||||
|
||||
@@ -10,7 +10,7 @@ This directory contains examples demonstrating how to use the `GitHubCopilotAgen
|
||||
2. **GitHub Copilot Subscription**: An active GitHub Copilot subscription
|
||||
3. **Install the package**:
|
||||
```bash
|
||||
pip install agent-framework-github-copilot --pre
|
||||
pip install agent-framework-github-copilot
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -14,7 +14,7 @@ in application code. The helper package does not choose a web framework.
|
||||
| Run this file | To... |
|
||||
|---------------|-------|
|
||||
| **[`a2a_server.py`](a2a_server.py)** | Host an Agent Framework agent as an A2A-compliant server (multi-agent). |
|
||||
| **[`agent_framework_to_a2a.py`](agent_framework_to_a2a.py)** | Minimal example: expose a single agent as an A2A server. |
|
||||
| **[`agent_framework_to_a2a.py`](agent_framework_to_a2a.py)** | Expose a single agent with conversion helpers and a native `AgentCard` inferred from agent and skill metadata; the A2A server remains application-owned. |
|
||||
|
||||
## Supporting Modules
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@hostLogistics = http://localhost:5002
|
||||
|
||||
### Query agent card for the invoice agent
|
||||
GET {{hostInvoice}}/.well-known/agent.json
|
||||
GET {{hostInvoice}}/.well-known/agent-card.json
|
||||
|
||||
### Send a message to the invoice agent
|
||||
POST {{hostInvoice}}
|
||||
@@ -30,7 +30,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Query agent card for the policy agent
|
||||
GET {{hostPolicy}}/.well-known/agent.json
|
||||
GET {{hostPolicy}}/.well-known/agent-card.json
|
||||
|
||||
### Send a message to the policy agent
|
||||
POST {{hostPolicy}}
|
||||
@@ -56,7 +56,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Query agent card for the logistics agent
|
||||
GET {{hostLogistics}}/.well-known/agent.json
|
||||
GET {{hostLogistics}}/.well-known/agent-card.json
|
||||
|
||||
### Send a message to the logistics agent
|
||||
POST {{hostLogistics}}
|
||||
|
||||
@@ -36,7 +36,7 @@ A2A Server Sample — Host an Agent Framework agent as an A2A endpoint
|
||||
|
||||
This sample creates a Python-based A2A-compliant server that wraps an Agent
|
||||
Framework agent. The server uses the a2a-sdk's Starlette application to handle
|
||||
JSON-RPC requests and serves the AgentCard at /.well-known/agent.json.
|
||||
JSON-RPC requests and serves the AgentCard at /.well-known/agent-card.json.
|
||||
|
||||
Three agent types are available:
|
||||
- invoice — Answers invoice queries using mock data and function tools.
|
||||
@@ -194,7 +194,7 @@ def main() -> None:
|
||||
print(f"Starting A2A server: {agent_card.name}")
|
||||
print(f" Agent type : {args.agent_type}")
|
||||
print(f" Listening : {url}")
|
||||
print(f" Agent card : {url}.well-known/agent.json")
|
||||
print(f" Agent card : {url}.well-known/agent-card.json")
|
||||
print()
|
||||
|
||||
uvicorn.run(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from asyncio import CancelledError
|
||||
from typing import Generic, TypeVar
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from a2a.helpers import new_task_from_user_message
|
||||
@@ -14,16 +14,14 @@ from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
|
||||
from a2a.types import (
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
Part,
|
||||
TaskState,
|
||||
)
|
||||
from agent_framework import Agent, SupportsAgentRun
|
||||
from agent_framework import Agent, InlineSkill, SkillFrontmatter, SkillsProvider
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentState
|
||||
from agent_framework_hosting_a2a import a2a_from_run, a2a_to_run
|
||||
from agent_framework_hosting_a2a import AgentA2AAdapter
|
||||
from dotenv import load_dotenv
|
||||
from starlette.applications import Starlette
|
||||
|
||||
@@ -31,14 +29,12 @@ load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AgentT = TypeVar("AgentT", bound=SupportsAgentRun)
|
||||
|
||||
|
||||
class AppAgentExecutor(AgentExecutor, Generic[AgentT]):
|
||||
class AppAgentExecutor(AgentExecutor):
|
||||
"""Native A2A SDK executor composed with Agent Framework conversion helpers."""
|
||||
|
||||
def __init__(self, state: AgentState[AgentT]) -> None:
|
||||
self.state = state
|
||||
def __init__(self, adapter: AgentA2AAdapter[Any]) -> None:
|
||||
self.adapter = adapter
|
||||
|
||||
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
if context.context_id is None:
|
||||
@@ -59,11 +55,11 @@ class AppAgentExecutor(AgentExecutor, Generic[AgentT]):
|
||||
await updater.submit()
|
||||
try:
|
||||
await updater.start_work()
|
||||
run = a2a_to_run(context.message, stream=True)
|
||||
agent = await self.state.get_target()
|
||||
run = self.adapter.a2a_to_run(context.message, stream=True)
|
||||
agent = await self.adapter.state.get_target()
|
||||
# Demo-only key: the outer server must authenticate and authorize these protocol IDs for multi-user use.
|
||||
session_id = f"a2a:{context.tenant}:{context.context_id}"
|
||||
session = await self.state.get_or_create_session(session_id)
|
||||
session = await self.adapter.state.get_or_create_session(session_id)
|
||||
if not run["stream"]:
|
||||
raise RuntimeError("This executor requires streaming run arguments.")
|
||||
stream = agent.run( # pyright: ignore[reportCallIssue]
|
||||
@@ -75,7 +71,7 @@ class AppAgentExecutor(AgentExecutor, Generic[AgentT]):
|
||||
default_artifact_id = uuid.uuid4().hex
|
||||
streamed_artifact_ids: set[str] = set()
|
||||
async for update in stream:
|
||||
parts = a2a_from_run(update)
|
||||
parts = self.adapter.a2a_from_run(update)
|
||||
if parts:
|
||||
artifact_id = update.message_id or default_artifact_id
|
||||
await updater.add_artifact(
|
||||
@@ -86,15 +82,15 @@ class AppAgentExecutor(AgentExecutor, Generic[AgentT]):
|
||||
streamed_artifact_ids.add(artifact_id)
|
||||
final_response = await stream.get_final_response()
|
||||
if not streamed_artifact_ids:
|
||||
parts = a2a_from_run(final_response)
|
||||
parts = self.adapter.a2a_from_run(final_response)
|
||||
if parts:
|
||||
await updater.update_status(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=updater.new_agent_message(parts),
|
||||
)
|
||||
await self.state.set_session(session_id, session)
|
||||
await self.adapter.state.set_session(session_id, session)
|
||||
await updater.complete()
|
||||
except CancelledError:
|
||||
except asyncio.CancelledError:
|
||||
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
|
||||
except Exception:
|
||||
logger.exception("A2A agent execution failed.")
|
||||
@@ -105,51 +101,38 @@ class AppAgentExecutor(AgentExecutor, Generic[AgentT]):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# --8<-- [start:AgentSkill]
|
||||
flight_skill = AgentSkill(
|
||||
id="Flight_Booking",
|
||||
name="Flight Booking",
|
||||
description="Search and book flights across Europe.",
|
||||
tags=["flights", "travel", "europe"],
|
||||
examples=[],
|
||||
)
|
||||
hotel_skill = AgentSkill(
|
||||
id="Hotel_Booking",
|
||||
name="Hotel Booking",
|
||||
description="Search and book hotels across Europe.",
|
||||
tags=["hotels", "travel", "accommodation"],
|
||||
examples=[],
|
||||
)
|
||||
# --8<-- [end:AgentSkill]
|
||||
|
||||
# --8<-- [start:AgentCard]
|
||||
# This will be the public-facing agent card
|
||||
public_agent_card = AgentCard(
|
||||
name="Europe Travel Agent",
|
||||
description=(
|
||||
"A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe."
|
||||
flight_skill = InlineSkill(
|
||||
frontmatter=SkillFrontmatter(
|
||||
name="flight-booking",
|
||||
description="Search and book flights across Europe.",
|
||||
),
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
supported_interfaces=[AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC")],
|
||||
skills=[flight_skill, hotel_skill],
|
||||
instructions="Help users search and book flights across Europe.",
|
||||
)
|
||||
hotel_skill = InlineSkill(
|
||||
frontmatter=SkillFrontmatter(
|
||||
name="hotel-booking",
|
||||
description="Search and book hotels across Europe.",
|
||||
),
|
||||
instructions="Help users search and book hotels across Europe.",
|
||||
)
|
||||
# --8<-- [end:AgentCard]
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Europe Travel Agent",
|
||||
instructions=(
|
||||
"You are a helpful Europe Travel Agent. "
|
||||
"You can help users search and book flights and hotels across Europe."
|
||||
),
|
||||
description="Helps users search and book flights and hotels across Europe.",
|
||||
instructions="You are a helpful Europe Travel Agent.",
|
||||
context_providers=[SkillsProvider([flight_skill, hotel_skill])],
|
||||
)
|
||||
state = AgentState(agent)
|
||||
|
||||
state = AgentState(agent)
|
||||
adapter = AgentA2AAdapter(
|
||||
state,
|
||||
version="1.0.0",
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
supported_interfaces=[AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC")],
|
||||
)
|
||||
public_agent_card = asyncio.run(adapter.get_card())
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=AppAgentExecutor(state),
|
||||
agent_executor=AppAgentExecutor(adapter),
|
||||
task_store=InMemoryTaskStore(),
|
||||
agent_card=public_agent_card,
|
||||
)
|
||||
|
||||
@@ -12,10 +12,19 @@ Run the commands below from the `python/` directory.
|
||||
|
||||
- `validate_dependency_bounds.py`
|
||||
- Main entrypoint for dependency-bound workflows.
|
||||
- Supports `test`, `lower`, `upper`, and `both` modes.
|
||||
- `test` runs workspace-wide smoke validation at the lower and upper ends of the currently allowed ranges.
|
||||
- Supports `release`, `test`, `lower`, `upper`, and `both` modes.
|
||||
- `release` refreshes `uv.lock`, then runs changed packages through fast lock-independent lower/upper import probes.
|
||||
- `test` runs the exhaustive workspace test+typing compatibility matrix.
|
||||
- `lower`, `upper`, and `both` dispatch to the lower/upper optimizer implementations for one package.
|
||||
|
||||
- `_dependency_bounds_release_impl.py`
|
||||
- Discovers package metadata changed from the selected release base.
|
||||
- Resolves published runtime dependencies and non-development extras independently of `uv.lock` with both
|
||||
`lowest-direct` and `highest` strategies.
|
||||
- Derives the minimum supported Python minor from each changed package's internal editable dependency closure.
|
||||
- Imports each changed package and records resolved dependency versions in a JSON report.
|
||||
- Runs probes concurrently under one five-minute deadline.
|
||||
|
||||
- `upgrade_dev_dependencies.py`
|
||||
- Refreshes exact dev dependency pins across the root `pyproject.toml` and package `pyproject.toml` files.
|
||||
- Reuses the same version-selection logic as the upper-bound tooling so direct dev-tooling refreshes and dependency-range expansion stay consistent.
|
||||
@@ -45,6 +54,7 @@ These are the normal user-facing entrypoints:
|
||||
```bash
|
||||
uv run poe upgrade-dev-dependency-pins
|
||||
uv run poe upgrade-dev-dependencies
|
||||
uv run poe validate-python-release --base-ref upstream/main
|
||||
uv run poe validate-dependency-bounds-test
|
||||
uv run poe validate-dependency-bounds-test --package core
|
||||
uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"
|
||||
@@ -52,7 +62,10 @@ uv run poe validate-dependency-bounds-project --mode both --package core --depen
|
||||
|
||||
- `upgrade-dev-dependency-pins` only refreshes exact dev pins in `pyproject.toml` files.
|
||||
- `upgrade-dev-dependencies` refreshes dev pins (using task above), runs `uv lock --upgrade`, reinstalls from the frozen lockfile, then runs `check`, `typing`, and `test`.
|
||||
- `validate-dependency-bounds-test` runs the repo-wide lower/upper smoke gate.
|
||||
- `validate-python-release` is the bounded release gate: it refreshes `uv.lock`, finds changed package metadata,
|
||||
and probes both dependency-bound extremes without reusing the lockfile.
|
||||
- `validate-dependency-bounds-test` runs the exhaustive package test+typing matrix and is intentionally not part of
|
||||
the routine release path.
|
||||
- `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--package` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs.
|
||||
|
||||
### GitHub Actions workflows
|
||||
@@ -76,6 +89,7 @@ These are useful for debugging or targeted manual runs:
|
||||
|
||||
```bash
|
||||
python -m scripts.dependencies.upgrade_dev_dependencies --dry-run --version-source lock
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode release --base-ref upstream/main --dry-run
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode test --package core --dry-run
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode both --package core --dependencies openai --dry-run
|
||||
python -m scripts.dependencies._dependency_bounds_lower_impl --packages core --dependencies openai --dry-run
|
||||
@@ -89,6 +103,7 @@ Use the direct lower/upper implementation modules mainly for debugging or develo
|
||||
The validators write JSON reports into this folder:
|
||||
|
||||
- `dependency-bounds-test-results.json`
|
||||
- `dependency-bounds-release-results.json`
|
||||
- `dependency-lower-bound-results.json`
|
||||
- `dependency-range-results.json`
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user