Compare commits

..

27 Commits

Author SHA1 Message Date
Evan Mattson cd5d282827 Merge branch 'main' into feature/python-subworkflow-checkpoint 2026-07-24 12:25:13 +09:00
Tao Chen 535690cd1d Add warning 2026-07-23 15:25:58 -07:00
Evan Mattson e90b6de5a7 Python: Improve python package management operations (#7274)
* improve package mgmt timings

* Address Python release validation review feedback
2026-07-23 22:14:10 +00:00
Atharva Vichare d98ac29115 Python: Fix duplicate function call on approval round-trip (#7267) (#7271)
* Python: Fix duplicate function call on approval round-trip (#7267)

`_replace_approval_contents_with_results` deduped restored function calls
against only the message currently being scanned. On an approval round-trip
the hosting layer replays the stored `function_call` item and its
`mcp_approval_request` item as two separate assistant messages, so the
per-message check never fired and the approval request restored a second
copy of the call.

Only one copy received the function result; the orphaned copy was left
unanswered, which the Responses API rejects with
"No tool output found for function call call_<id>".

Collect existing call ids across all messages instead, and add a restored
call to that set so two approval requests for the same call cannot both
expand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Refactor approval placeholder result handling

Refactor approval handling logic to improve clarity and maintainability.

* Refactor test to support reused call IDs after completion

Updated the test to allow reused call IDs after completion, ensuring that a completed call does not suppress later approval requests with the same ID. Adjusted assertions to reflect the new behavior.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:27:26 +00:00
Giles Odigwe 040e2705aa Promote agent-framework-github-copilot to 1.0.0 (released) (#7302)
Promote the GitHub Copilot package from release candidate (1.0.0rc4) to released (1.0.0): bump the version, switch the classifier to Production/Stable, update PACKAGE_STATUS.md, and drop the --pre install flag from the package and sample READMEs. Add a github-copilot-1.0.0 CHANGELOG section covering the promotion and the input-attachment forwarding shipped in this release. No core/root bump: this is a standalone package promotion and the core[all] extra references the package without a version pin.

Copilot-Session: f523064c-60b4-4d18-bf95-c16c5fda9126
2026-07-23 21:06:31 +00:00
Giles Odigwe 8c057507f4 Python: Forward GitHub Copilot input attachments as inline blobs (#7300)
* Python: Forward GitHub Copilot input attachments as inline blobs

The Python GitHubCopilotAgent built the prompt from message text only, so
DataContent (images/documents) passed on input was silently dropped. The .NET
provider already forwards these as attachments.

Map input data content to the Copilot SDK's inline BlobAttachment (base64,
no temp files) in both the streaming and non-streaming send paths. Data content
without a media type is dropped with a warning instead of silently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7

* Python: Handle non-base64 data URIs and fix attachment docstring

Address PR review feedback:
- Guard _get_data_bytes_as_str against ContentError so a non-base64 data:
  URI (which _validate_uri still classifies as type="data") is skipped with a
  warning instead of failing the entire Copilot request.
- Correct the docstring: remote URIs and non-base64 data URIs are neither
  attached nor added to the prompt (the prompt is built from text content only).
- Add tests for the non-base64 data URI path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7

* Python: Fix flaky attachment test under telemetry

The end-to-end non-base64 data URI test failed in CI because GitHubCopilotAgent's
telemetry layer serializes message content (observability._to_otel_part ->
_get_data_bytes_as_str), which raises ContentError on a non-base64 data: URI
before the attachment code runs. That is an unrelated core-observability
limitation, not attachment behavior.

Use RawGitHubCopilotAgent (no telemetry layer) for that test so it isolates the
provider's send path. The direct helper test still covers the ContentError guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ac85c429-4115-42ef-a18a-f576e3cf03f7
2026-07-23 21:05:52 +00:00
Peter Ibekwe 0d5c0f8fa0 .NET: Add language and prompt customization to Magentic orchestration (#7263)
* Add language and prompt customization to Magentic orchestration

* Update default prompts formatting
2026-07-23 18:19:12 +00:00
Chinedum Echeta 217912a2c0 Python: Support async credentials in FoundryToolbox (#7208)
* Python: Support async credentials in `FoundryToolbox`

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refactor: Use AzureCredentialTypes for credential type annotations in Toolbox classes

* Remove auth_flow method from _ToolboxAuth class

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 17:57:31 +00:00
pratik wayase 0841116330 Python: fix(foundry_hosting): preserve auth credentials across FoundryToolbox reconnections (#7202)
* fix(foundry_hosting): preserve auth credentials across FoundryToolbox reconnections

* Address copilot comments

* fix syntax check

* Fix tests

* Fix formatting

* Fix formatting

---------

Co-authored-by: Tao Chen <taochen@microsoft.com>
2026-07-23 17:15:19 +00:00
Evan Mattson cd6345e91e Python: Align AG-UI workflow cache scoping (#7277)
* snapshot scope resolver isolation

* Fix AG-UI scope resolver test typing
2026-07-23 17:13:27 +00:00
Amit Dhawan 59b979213a Python: Fix stale agent.json references in A2A sample (#7281)
Co-authored-by: Amit Dhawan <amit.dhawan@barco.com>
2026-07-23 15:59:01 +00:00
westey ad26cfe8c7 .NET: Switch to using new community toolkit VectorData packages (#5694)
* Switch to using new community toolkit VectorData packages

* Fix formatting.

* Update dotnet/Directory.Packages.props

Co-authored-by: Adam Sitnik <adam.sitnik@gmail.com>

* Fix build error.

* Upgrade MEAI

* Upgrade additional dependencies

* Address rename after package upgrade.

* Revert some packages versions due to version mismatches

---------

Co-authored-by: Adam Sitnik <adam.sitnik@gmail.com>
2026-07-23 15:02:24 +00:00
dependabot[bot] 0c8bf5b6c0 Bump brace-expansion in /python/packages/devui/frontend (#7232)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.16.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.16)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 11:57:16 +00:00
Eduard van Valkenburg cb2914fa7e Bump agent-framework-hosting-a2a to 1.0.0a260723 (#7282)
Prepare the focused alpha release for the progressive A2A adapters from #7258. No other package versions or dependency bounds change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 003e02dd-dba0-40a5-9ebf-083901aefb57
2026-07-23 10:56:15 +00:00
Eduard van Valkenburg 0796af0c26 Python: add progressive A2A hosting adapters (#7258)
* Python: add progressive A2A hosting adapters

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: address A2A adapter review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

---------

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607
2026-07-23 07:43:30 +00:00
Evan Mattson 711d6f24ae Bump Python package versions for 1.12.1 release (#7273)
Bump root and core to 1.12.1, OpenAI to 1.11.0 for new public prompt-cache options, Foundry to 1.10.3, and Gemini and Foundry Hosting to beta 260722 based on CHANGELOG entries. Promote AG-UI from 1.0.0rc9 to stable 1.0.0. No beta cohort bump was applied, and core floors remain unchanged under the strict affected-dependency policy because the connectors do not require a new core API.
2026-07-23 13:29:20 +09:00
Evan Mattson bd17a64697 Restore dedicated DevFlow Copilot authentication (#7276)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d9fa4e9c-c32d-42fb-8ee4-4772473e6479
2026-07-23 13:23:39 +09:00
Eduard van Valkenburg 5147579992 Python: Enforce package coverage by lifecycle (#7261)
* Enforce Python coverage by package lifecycle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0

* Fix Python CI and deprecation usage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0

* Make POSIX kill-tree test portable

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ae5ad8e-6b66-41b3-a862-4e2a3fae1cd0
2026-07-23 02:38:28 +00:00
Tao Chen 85fde62a76 Fix syntax check 2026-07-20 14:14:24 -07:00
Tao Chen 85eb53d412 Address comments 2026-07-20 13:27:35 -07:00
Tao Chen 2d7c8da6b0 Merge branch 'main' into feature/python-subworkflow-checkpoint 2026-07-20 11:16:36 -07:00
Tao Chen e0b0b79d9e Do not allow checkpoint storage in sub workflow 2026-07-14 14:30:46 -07:00
Tao Chen a4f6c26990 Clean up 2026-07-14 11:28:48 -07:00
Tao Chen 1389f304f2 Drop redundant decode in WorkflowExecutor.on_checkpoint_restore
The storage backend already materializes the full checkpoint on load (FileCheckpointStorage decodes recursively; InMemoryCheckpointStorage deep-copies), so the embedded sub_workflow_checkpoint (and legacy execution_contexts) arrive already decoded - like every other executor's on_checkpoint_restore state. Remove the no-op decode_checkpoint_value calls and the now-unused import.
2026-07-14 11:11:49 -07:00
Tao Chen 93719f4a34 Remove per-execution bookkeeping from WorkflowExecutor
The sub-workflow is a single shared instance, so per-execution ExecutionContext/request routing never provided real isolation. Delegate request/response tracking to the sub-workflow itself: can_handle accepts targeted propagated responses, _handle_response validates against the sub-workflow's pending requests and forwards responses immediately, and on_checkpoint_save embeds only the sub-workflow checkpoint (on_checkpoint_restore keeps a legacy reader for older checkpoints). Also emit the fresh-message/checkpoint-while-pending warning from FunctionalWorkflow.run to match Workflow.run.
2026-07-14 10:46:14 -07:00
Tao Chen a486374fd8 Move checkpoint-object construction into the runner context
Add RunnerContext.create_checkpoint_object alongside create_checkpoint (create_checkpoint now delegates to it and persists), so Runner.capture_checkpoint_object builds the snapshot via the context instead of a one-off get_messages peek primitive. In-flight messages are captured non-destructively (per-source lists copied). The checkpoint-less capturing contexts (azurefunctions, durabletask) raise NotImplementedError to match create_checkpoint.
2026-07-13 15:57:37 -07:00
Tao Chen e78604103d Fix sub-workflow checkpoint restore to preserve sub-workflow state
Add Runner.capture_checkpoint_object/restore_from_checkpoint_object (quiescent-only nested checkpoint) and embed a sub_workflow_checkpoint in WorkflowExecutor.on_checkpoint_save/on_checkpoint_restore so a resumed parent restores each sub-workflow's mid-progress state instead of only replaying pending request-info events. Keeps a backward-compat fallback when sub_workflow_checkpoint is absent.
2026-07-13 15:18:53 -07:00
104 changed files with 7162 additions and 1064 deletions
+212
View File
@@ -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())
+134
View File
@@ -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()
+1 -2
View File
@@ -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 }}
+10 -1
View File
@@ -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
+1 -2
View File
@@ -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 }}
-382
View File
@@ -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())
+5 -2
View File
@@ -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:
+9 -9
View File
@@ -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" />
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
</ItemGroup>
<ItemGroup>
@@ -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";
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
</ItemGroup>
<ItemGroup>
@@ -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.");
@@ -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";
@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" />
<PackageReference Include="CommunityToolkit.VectorData.Qdrant" />
</ItemGroup>
<ItemGroup>
@@ -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;
}
@@ -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>
@@ -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,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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:
+2 -2
View File
@@ -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` |
+4 -1
View File
@@ -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", "")
+2 -2
View File
@@ -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
+289 -22
View File
@@ -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(
+32 -11
View File
@@ -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)
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.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
View File
@@ -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": {
+6 -6
View File
@@ -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})
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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.",
+46 -4
View File
@@ -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.
+164 -17
View File
@@ -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
+1 -1
View File
@@ -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
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -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
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.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
+1 -1
View File
@@ -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}}
+2 -2
View File
@@ -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,
)
+18 -3
View File
@@ -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