diff --git a/README.md b/README.md index 80beb639..5e1e56ff 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agen ## Why use it -- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, Haystack, Smolagents, and MCP servers — drop in, no SDK rewrites required. +- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, Haystack, Smolagents, Agno, and MCP servers — drop in, no SDK rewrites required. - **Pre-execution policy enforcement** via the `FrameworkAdapter` ABC — block disallowed tool calls before they hit the LLM. - **Audit trail** — every tool call, prompt, and policy decision is emitted to the gateway with full agent lineage (parent / root / team). - **Native PyO3 fast path** (optional) — drop into a Rust runtime client when you need sub-millisecond policy checks. @@ -41,6 +41,7 @@ are expected to work but are not continuously tested. | MCP | `mcp` | `>=1.0.0` | `1.27.x` | | OpenAI Agents | `agents` | `>=0.1.0` | `0.17.x` | | Smolagents | `smolagents` | `>=1.0.0,<2.0.0` | `1.26.x` | +| Agno | `agno` | `>=2.0.0` | `2.6.x` | Python framework compatibility is documented **authoritatively in the SDK docs** — [Framework compatibility](./docs/compatibility/frameworks.md) — because the Python diff --git a/agent_assembly/adapters/agno/__init__.py b/agent_assembly/adapters/agno/__init__.py new file mode 100644 index 00000000..e9930eb0 --- /dev/null +++ b/agent_assembly/adapters/agno/__init__.py @@ -0,0 +1,6 @@ +"""Agno adapter package.""" + +from agent_assembly.adapters.agno.adapter import AgnoAdapter +from agent_assembly.adapters.agno.patch import AgnoPatch + +__all__ = ["AgnoAdapter", "AgnoPatch"] diff --git a/agent_assembly/adapters/agno/adapter.py b/agent_assembly/adapters/agno/adapter.py new file mode 100644 index 00000000..b44ec3ab --- /dev/null +++ b/agent_assembly/adapters/agno/adapter.py @@ -0,0 +1,33 @@ +"""Agno (formerly Phidata) framework adapter. + +Governs Agno agents at the SDK layer by patching the framework's single +function-tool execution chokepoint (``FunctionCall.execute`` / ``aexecute``). +See ``agent_assembly.adapters.agno.patch`` for the hook-point rationale. +""" + +from __future__ import annotations + +from agent_assembly.adapters.agno.patch import AgnoPatch +from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor + + +class AgnoAdapter(FrameworkAdapter): + """Adapter for Agno (pip ``agno``) framework governance hook installation.""" + + def __init__(self) -> None: + self._patch: AgnoPatch | None = None + + def get_framework_name(self) -> str: + return "agno" + + def get_supported_versions(self) -> list[str]: + return [">=2.0.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + self._patch = AgnoPatch(interceptor) + self._patch.apply() + + def unregister_hooks(self) -> None: + if self._patch is not None: + self._patch.revert() + self._patch = None diff --git a/agent_assembly/adapters/agno/patch.py b/agent_assembly/adapters/agno/patch.py new file mode 100644 index 00000000..3e8cda20 --- /dev/null +++ b/agent_assembly/adapters/agno/patch.py @@ -0,0 +1,249 @@ +"""Agno (formerly Phidata) patch module. + +Agno (pip ``agno``) runs **every** function-tool body through a single +chokepoint: ``agno.tools.function.FunctionCall.execute`` (sync) and +``FunctionCall.aexecute`` (async). An ``Agent`` builds a ``FunctionCall`` from the +model's tool-call request and invokes ``execute()``/``aexecute()``, which calls +the user's tool entrypoint and wraps the return in a ``FunctionExecutionResult``. + +Patching that one method is therefore the SDK-layer interception point for Agno: +it sits *before* the tool body runs, so a governance ``deny`` short-circuits the +body entirely (the tool never executes) and an ``allow`` runs it and records the +result. This is the same pre-execution allow/deny contract the CrewAI / Pydantic +AI adapters implement on ``BaseTool.run`` / ``Tool._run``, reusing the shared +decision-normalization and approval helpers from +``agent_assembly.adapters.crewai.patch`` so the fail-closed-under-enforce posture +(AAASM-3107) is identical across adapters. +""" + +from __future__ import annotations + +import importlib as importlib +import inspect +from dataclasses import dataclass +from functools import wraps +from typing import Any + +from agent_assembly.adapters.crewai.patch import ( + _format_approval_rejected_message as _format_approval_rejected, +) +from agent_assembly.adapters.crewai.patch import ( + _format_blocked_message as _format_blocked, +) +from agent_assembly.adapters.crewai.patch import ( + _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, +) +from agent_assembly.adapters.crewai.patch import ( + _interceptor_enforces, +) +from agent_assembly.adapters.crewai.patch import ( + _invoke_sync_tool_check as _invoke_tool_check, +) +from agent_assembly.adapters.crewai.patch import ( + _normalize_decision as _normalize_governance_decision, +) +from agent_assembly.adapters.crewai.patch import ( + _record_sync_tool_result as _record_tool_result, +) +from agent_assembly.adapters.crewai.patch import ( + _wait_for_sync_tool_approval as _wait_for_tool_approval, +) + +_TOOLS_PATCHED_FLAG = "_agent_assembly_agno_functioncall_patched" +_ORIGINAL_EXECUTE = "_agent_assembly_original_agno_functioncall_execute" +_ORIGINAL_AEXECUTE = "_agent_assembly_original_agno_functioncall_aexecute" + + +@dataclass(slots=True) +class AgnoPatch: + """Applies Agno runtime monkey-patching hooks on ``FunctionCall.execute``.""" + + callback_handler: Any + + def apply(self) -> bool: + """Patch ``FunctionCall.execute``/``aexecute`` and report availability. + + Returns: + ``True`` when the Agno ``FunctionCall`` class was found and the + governance hook installed; ``False`` (a no-op) when ``agno`` is not + importable or the expected hook point is absent — never raises + ``AttributeError`` on a missing framework. + """ + function_call_cls = _load_agno_functioncall_class() + if function_call_cls is None: + return False + _apply_execute_patch(function_call_cls, self.callback_handler) + return True + + def revert(self) -> None: + """Revert the Agno ``FunctionCall`` patches when present.""" + function_call_cls = _load_agno_functioncall_class() + if function_call_cls is not None: + _revert_execute_patch(function_call_cls) + return None + + +def _load_agno_functioncall_class() -> type[Any] | None: + try: + module = importlib.import_module("agno.tools.function") + except ImportError: + return None + + function_call_cls = getattr(module, "FunctionCall", None) + if isinstance(function_call_cls, type): + return function_call_cls + return None + + +def _load_agno_failure_result_factory() -> Any | None: + """Return a callable building a failure ``FunctionExecutionResult``, or None. + + A governance ``deny`` must short-circuit a tool with a result object that + Agno's calling code understands. ``FunctionExecutionResult(status="failure", + error=...)`` is exactly what ``execute()`` returns on a failed tool, so the + denied message flows back to the model as a tool error rather than a tool + result — the body never runs. + """ + try: + module = importlib.import_module("agno.tools.function") + except ImportError: + return None + return getattr(module, "FunctionExecutionResult", None) + + +def _build_denied_result(message: str) -> Any: + """Wrap a governance block message in an Agno failure result. + + Falls back to returning the raw string when the result type cannot be + constructed (e.g. a future Agno that renames it), so a deny still blocks the + body even if the wrapper shape changed. + """ + factory = _load_agno_failure_result_factory() + if factory is None: + return message + try: + return factory(status="failure", error=message) + except Exception: + return message + + +def _resolve_tool_name(function_call: Any) -> str: + function = getattr(function_call, "function", None) + name = getattr(function, "name", None) + if isinstance(name, str) and name: + return name + return str(function_call.__class__.__name__) + + +def _resolve_tool_args(function_call: Any) -> dict[str, Any]: + arguments = getattr(function_call, "arguments", None) + if isinstance(arguments, dict): + return dict(arguments) + return {} + + +def _evaluate_decision( + callback_handler: Any, + *, + tool_name: str, + tool_args: dict[str, Any], + enforce: bool, +) -> tuple[str, str | None, bool]: + """Run the pre-execution governance check and resolve a final verdict. + + Returns ``(status, reason, is_pending_flow)`` where ``status`` is one of + ``allow`` / ``deny`` (``pending`` is resolved here by waiting for approval). + """ + decision = _invoke_tool_check( + callback_handler, + tool_name=tool_name, + tool_args=tool_args, + agent_id=None, + ) + status, reason = _normalize_governance_decision(decision, enforce=enforce) + is_pending_flow = False + if status == "pending": + is_pending_flow = True + timeout_seconds = _resolve_pending_timeout_seconds(callback_handler) + final_decision = _wait_for_tool_approval( + callback_handler, + tool_name=tool_name, + timeout_seconds=timeout_seconds, + tool_args=tool_args, + agent_id=None, + ) + status, reason = _normalize_governance_decision(final_decision, enforce=enforce) + return status, reason, is_pending_flow + + +def _apply_execute_patch(function_call_cls: type[Any], callback_handler: Any) -> None: + if getattr(function_call_cls, _TOOLS_PATCHED_FLAG, False): + return + + original_execute = function_call_cls.execute + original_aexecute = getattr(function_call_cls, "aexecute", None) + enforce = _interceptor_enforces(callback_handler) + + @wraps(original_execute) + def patched_execute(self: Any, *args: Any, **kwargs: Any) -> Any: + tool_name = _resolve_tool_name(self) + tool_args = _resolve_tool_args(self) + status, reason, is_pending_flow = _evaluate_decision( + callback_handler, + tool_name=tool_name, + tool_args=tool_args, + enforce=enforce, + ) + if status == "deny": + message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason) + return _build_denied_result(message) + + result = original_execute(self, *args, **kwargs) + _record_tool_result(callback_handler, tool_name=tool_name, result=result) + return result + + setattr(function_call_cls, _ORIGINAL_EXECUTE, original_execute) + function_call_cls.execute = patched_execute + + if inspect.iscoroutinefunction(original_aexecute): + + @wraps(original_aexecute) + async def patched_aexecute(self: Any, *args: Any, **kwargs: Any) -> Any: + tool_name = _resolve_tool_name(self) + tool_args = _resolve_tool_args(self) + status, reason, is_pending_flow = _evaluate_decision( + callback_handler, + tool_name=tool_name, + tool_args=tool_args, + enforce=enforce, + ) + if status == "deny": + message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason) + return _build_denied_result(message) + + result = await original_aexecute(self, *args, **kwargs) + _record_tool_result(callback_handler, tool_name=tool_name, result=result) + return result + + setattr(function_call_cls, _ORIGINAL_AEXECUTE, original_aexecute) + function_call_cls.aexecute = patched_aexecute + + setattr(function_call_cls, _TOOLS_PATCHED_FLAG, True) + + +def _revert_execute_patch(function_call_cls: type[Any]) -> None: + if not getattr(function_call_cls, _TOOLS_PATCHED_FLAG, False): + return None + + original_execute = getattr(function_call_cls, _ORIGINAL_EXECUTE, None) + if callable(original_execute): + function_call_cls.execute = original_execute + + original_aexecute = getattr(function_call_cls, _ORIGINAL_AEXECUTE, None) + if callable(original_aexecute): + function_call_cls.aexecute = original_aexecute + + for attr in (_ORIGINAL_EXECUTE, _ORIGINAL_AEXECUTE, _TOOLS_PATCHED_FLAG): + if hasattr(function_call_cls, attr): + delattr(function_call_cls, attr) + return None diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 86629732..bc17db21 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -5,6 +5,7 @@ from threading import Lock from typing import Callable, Literal +from agent_assembly.adapters.agno.adapter import AgnoAdapter from agent_assembly.adapters.base import FrameworkAdapter from agent_assembly.adapters.crewai.adapter import CrewAIAdapter from agent_assembly.adapters.google_adk.adapter import GoogleADKAdapter @@ -28,6 +29,7 @@ "google_adk": 5, "haystack": 6, "smolagents": 6, + "agno": 6, "mcp": 99, } @@ -72,6 +74,7 @@ def __init__(self) -> None: GoogleADKAdapter(), HaystackAdapter(), SmolagentsAdapter(), + AgnoAdapter(), MCPAdapter(), ] for adapter in builtin_adapters: diff --git a/docs/compatibility/frameworks.md b/docs/compatibility/frameworks.md index 29bf537c..ca35d469 100644 --- a/docs/compatibility/frameworks.md +++ b/docs/compatibility/frameworks.md @@ -52,6 +52,7 @@ to work but are not continuously tested. | MCP | `mcp` | `agent_assembly.adapters.mcp` | `>=1.0.0` | `1.27.x` | | OpenAI Agents | `agents` | `agent_assembly.adapters.openai_agents` | `>=0.1.0` | `0.17.x` | | Smolagents | `smolagents` | `agent_assembly.adapters.smolagents` | `>=1.0.0,<2.0.0` | `1.26.x` | +| Agno | `agno` | `agent_assembly.adapters.agno` | `>=2.0.0` (patches `FunctionCall.execute` / `aexecute`) | `2.6.x` | !!! note "Adapter present vs. example present" Every framework above has an adapter that is implemented and registered — @@ -78,6 +79,21 @@ work through the `Tool._run` hook but are not exercised in CI. > predated the `>=0.3.0` `AbstractToolset.call_tool` support and is no longer accurate; > the example and that pin now track the tested `>=0.3.0` line. +### Agno (formerly Phidata) + +Agno (pip `agno`; the package was previously published as `phidata`) routes **every** +function-tool body through a single chokepoint: `agno.tools.function.FunctionCall.execute` +(sync) and `FunctionCall.aexecute` (async). An `Agent` builds a `FunctionCall` from the +model's tool-call request and invokes that method, which runs the user's tool entrypoint +and wraps the return in a `FunctionExecutionResult`. + +The Agno adapter patches that one method, so the pre-execution governance check runs +*before* the tool body. A `deny` short-circuits the body entirely — the tool never runs — +and returns a `FunctionExecutionResult(status="failure", ...)` carrying the block message, +which Agno surfaces to the model as a tool error. An `allow` runs the body and records the +result. The **tested line is `agno>=2.0.0`** (currently `2.6.x`), installed by the SDK's +dev/test dependency group and exercised by the `importorskip`-guarded integration test. + ## Declaring frameworks in your own project The frameworks above are **not** runtime dependencies of `agent-assembly` — installing diff --git a/pyproject.toml b/pyproject.toml index b996955a..043ba872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,13 @@ test = [ # tests drive a real `Tool` subclass, so a regression to a fail-open no-op # patch is caught (deny must block `forward`) instead of silently skipped. "smolagents>=1.0.0,<2.0.0", + # AAASM-3537: dev/test-only (NOT a runtime dependency). Agno (formerly + # Phidata) runs every function-tool body through + # `agno.tools.function.FunctionCall.execute` / `aexecute`. Installing it lets + # the `importorskip`-guarded integration test drive a real `FunctionCall`, so + # the negative-control deny test fails if the patch ever regresses to a no-op + # instead of being silently skipped. + "agno>=2.0.0", ] pre-commit-ci = [ "pre-commit>=3.5.0,<5", diff --git a/test/integration/test_agno_interception_integration.py b/test/integration/test_agno_interception_integration.py new file mode 100644 index 00000000..cc000d1a --- /dev/null +++ b/test/integration/test_agno_interception_integration.py @@ -0,0 +1,141 @@ +"""Real-framework governance proof for the Agno adapter. + +Unlike ``test/unit/adapters/agno/test_patch.py`` (which drives a *fake* +``agno.tools.function`` module), this test installs the patch over the **real** +``agno.tools.function.FunctionCall`` class and drives an actual tool call the +way Agno does internally — ``Function.from_callable(tool)`` → +``FunctionCall(...).execute()``. + +This is the negative control demanded by AAASM-3528: if the patch were a no-op +(the failure mode where an adapter is registered but does not actually gate +tool execution), ``test_real_agno_deny_blocks_tool_body`` would FAIL because the +tool body would run and record its side effect. The test only passes when the +deny genuinely short-circuits the real tool body. + +The test is ``importorskip``-guarded on ``agno`` so it runs whenever the +framework is installed (the dev/test dependency group installs it) and is +skipped — not silently passed — when it is not. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("agno", reason="agno not installed") + +from agno.tools.function import Function, FunctionCall # noqa: E402 + +from agent_assembly.adapters.agno import AgnoAdapter # noqa: E402 + + +@pytest.fixture +def revert_agno_patches() -> object: + """Ensure the real FunctionCall class is restored after each test.""" + adapters: list[AgnoAdapter] = [] + yield adapters + for adapter in adapters: + adapter.unregister_hooks() + + +@pytest.mark.integration +def test_real_agno_deny_blocks_tool_body(revert_agno_patches: list[AgnoAdapter]) -> None: + executed: list[int] = [] + + def transfer_funds(amount: int) -> str: + executed.append(amount) + return f"transferred {amount}" + + class DenyInterceptor: + _enforce = True + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "policy forbids money movement"} + + adapter = AgnoAdapter() + revert_agno_patches.append(adapter) + adapter.register(DenyInterceptor()) + + function = Function.from_callable(transfer_funds) + result = FunctionCall(function=function, arguments={"amount": 1000}).execute() + + # Negative control: a no-op patch would let the body run -> executed == [1000]. + assert executed == [] + assert result.status == "failure" + assert "[BLOCKED by governance policy]" in str(result.error) + assert "policy forbids money movement" in str(result.error) + + +@pytest.mark.integration +def test_real_agno_allow_runs_tool_and_records(revert_agno_patches: list[AgnoAdapter]) -> None: + executed: list[str] = [] + recorded: list[object] = [] + + def lookup_weather(city: str) -> str: + executed.append(city) + return f"weather in {city} is sunny" + + class AllowRecording: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + def record_result(self, **kwargs: object) -> None: + recorded.append(kwargs["result"]) + + adapter = AgnoAdapter() + revert_agno_patches.append(adapter) + adapter.register(AllowRecording()) + + function = Function.from_callable(lookup_weather) + result = FunctionCall(function=function, arguments={"city": "Tokyo"}).execute() + + assert executed == ["Tokyo"] + assert result.status == "success" + assert result.result == "weather in Tokyo is sunny" + assert len(recorded) == 1 + + +@pytest.mark.integration +def test_real_agno_revert_restores_unguarded_execution(revert_agno_patches: list[AgnoAdapter]) -> None: + executed: list[int] = [] + + def charge(amount: int) -> str: + executed.append(amount) + return "charged" + + class DenyInterceptor: + _enforce = True + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "denied"} + + adapter = AgnoAdapter() + adapter.register(DenyInterceptor()) + function = Function.from_callable(charge) + + blocked = FunctionCall(function=function, arguments={"amount": 5}).execute() + assert blocked.status == "failure" + assert executed == [] + + # After revert, the real FunctionCall must run the body unguarded again. + adapter.unregister_hooks() + allowed = FunctionCall(function=function, arguments={"amount": 9}).execute() + assert allowed.status == "success" + assert executed == [9] + + +@pytest.mark.integration +def test_real_agno_adapter_detected_and_active() -> None: + """The registry auto-detects the installed agno adapter and reports it active.""" + from agent_assembly.adapters.registry import AdapterRegistry + + registry = AdapterRegistry() + try: + activated = registry.auto_detect() + assert "agno" in activated + active_names = {info.name for info in registry.list_active()} + assert "agno" in active_names + finally: + registry.unregister("agno") diff --git a/test/unit/adapters/agno/__init__.py b/test/unit/adapters/agno/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/adapters/agno/test_patch.py b/test/unit/adapters/agno/test_patch.py new file mode 100644 index 00000000..9333b804 --- /dev/null +++ b/test/unit/adapters/agno/test_patch.py @@ -0,0 +1,299 @@ +"""Unit tests for the Agno adapter patch. + +These tests drive a *fake* ``agno.tools.function`` module so they run fast and +deterministically without importing the real framework, exercising the +allow / deny / pending / record branches and the idempotent apply+revert +contract. The real-framework governance proof (the negative control that fails +if the patch is a no-op) lives in +``test/integration/test_agno_interception_integration.py``. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.agno import patch as agno_patch + + +class _FakeFunctionExecutionResult: + """Stand-in for ``agno.tools.function.FunctionExecutionResult``.""" + + def __init__(self, *, status: str, result: Any = None, error: Any = None) -> None: + self.status = status + self.result = result + self.error = error + + +def _recording_body(sink: list[Any], return_value: Any) -> Any: + """Return a tool body that appends its args to ``sink`` then returns ``return_value``.""" + + def body(args: Any) -> Any: + sink.append(args) + return return_value + + return body + + +def _make_fake_function_call_cls(body: Any) -> type[Any]: + """Build a fake ``FunctionCall`` whose ``execute``/``aexecute`` run ``body``.""" + + class FakeFunctionCall: + def __init__(self, name: str, arguments: dict[str, Any]) -> None: + self.function = SimpleNamespace(name=name) + self.arguments = arguments + + def execute(self) -> Any: + return _FakeFunctionExecutionResult(status="success", result=body(self.arguments)) + + async def aexecute(self) -> Any: + return _FakeFunctionExecutionResult(status="success", result=body(self.arguments)) + + return FakeFunctionCall + + +def _install_fake_agno(monkeypatch: pytest.MonkeyPatch, function_call_cls: type[Any]) -> None: + fake_module = SimpleNamespace( + FunctionCall=function_call_cls, + FunctionExecutionResult=_FakeFunctionExecutionResult, + ) + + def fake_import_module(module_name: str) -> object: + if module_name == "agno.tools.function": + return fake_module + raise ImportError(module_name) + + monkeypatch.setattr(agno_patch.importlib, "import_module", fake_import_module) + + +class _AllowInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + +def test_apply_is_idempotent_and_reverts(monkeypatch: pytest.MonkeyPatch) -> None: + cls = _make_fake_function_call_cls(lambda args: args) + _install_fake_agno(monkeypatch, cls) + original_execute = cls.execute + original_aexecute = cls.aexecute + + patcher = agno_patch.AgnoPatch(_AllowInterceptor()) + assert patcher.apply() is True + assert cls.execute is not original_execute + assert cls.aexecute is not original_aexecute + first_ref = cls.execute + + # Second apply is a no-op (does not double-wrap). + assert patcher.apply() is True + assert cls.execute is first_ref + + patcher.revert() + assert cls.execute is original_execute + assert cls.aexecute is original_aexecute + assert getattr(cls, agno_patch._TOOLS_PATCHED_FLAG, False) is False + + +def test_apply_false_without_agno(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_import_error(module_name: str) -> object: + raise ImportError(module_name) + + monkeypatch.setattr(agno_patch.importlib, "import_module", raise_import_error) + assert agno_patch._load_agno_functioncall_class() is None + assert agno_patch.AgnoPatch(_AllowInterceptor()).apply() is False + + +def test_load_returns_none_for_non_type(monkeypatch: pytest.MonkeyPatch) -> None: + fake_module = SimpleNamespace(FunctionCall=object()) + + def fake_import_module(module_name: str) -> object: + if module_name == "agno.tools.function": + return fake_module + raise ImportError(module_name) + + monkeypatch.setattr(agno_patch.importlib, "import_module", fake_import_module) + assert agno_patch._load_agno_functioncall_class() is None + + +def test_deny_blocks_body_and_returns_failure_result(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[dict[str, Any]] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ran")) + _install_fake_agno(monkeypatch, cls) + + class DenyInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "blocked for safety"} + + patcher = agno_patch.AgnoPatch(DenyInterceptor()) + assert patcher.apply() is True + + result = cls("transfer", {"amount": 100}).execute() + + # The tool body must NOT have run, and the returned result is a failure. + assert ran == [] + assert result.status == "failure" + assert "[BLOCKED by governance policy]" in result.error + assert "blocked for safety" in result.error + + +def test_allow_runs_body_and_records_result(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[dict[str, Any]] = [] + recorded: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ok")) + _install_fake_agno(monkeypatch, cls) + + class AllowRecording: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + def record_result(self, **kwargs: object) -> None: + recorded.append(kwargs["result"]) + + patcher = agno_patch.AgnoPatch(AllowRecording()) + assert patcher.apply() is True + + result = cls("lookup", {"q": "x"}).execute() + + assert ran == [{"q": "x"}] + assert result.status == "success" + assert result.result == "ok" + assert len(recorded) == 1 + + +def test_unknown_verdict_blocks_under_enforce(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ran")) + _install_fake_agno(monkeypatch, cls) + + class EnforcingUnknown: + _enforce = True + + def check_tool_start(self, **kwargs: object) -> object: + del kwargs + return None + + patcher = agno_patch.AgnoPatch(EnforcingUnknown()) + assert patcher.apply() is True + + result = cls("anything", {}).execute() + + assert ran == [] + assert result.status == "failure" + assert "[BLOCKED by governance policy]" in result.error + + +def test_pending_then_approved_runs_body(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ran")) + _install_fake_agno(monkeypatch, cls) + + class PendingThenApprove: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs approval"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + patcher = agno_patch.AgnoPatch(PendingThenApprove()) + assert patcher.apply() is True + + result = cls("deploy", {}).execute() + + assert ran == [{}] + assert result.status == "success" + + +def test_pending_then_rejected_returns_approval_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ran")) + _install_fake_agno(monkeypatch, cls) + + class PendingThenReject: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs approval"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "approval timeout"} + + patcher = agno_patch.AgnoPatch(PendingThenReject()) + assert patcher.apply() is True + + result = cls("deploy", {}).execute() + + assert ran == [] + assert result.status == "failure" + assert result.error.startswith("[APPROVAL REJECTED]") + assert "approval timeout" in result.error + + +def test_async_deny_blocks_body(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ran")) + _install_fake_agno(monkeypatch, cls) + + class DenyInterceptor: + _enforce = True + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "no net"} + + patcher = agno_patch.AgnoPatch(DenyInterceptor()) + assert patcher.apply() is True + + result = asyncio.run(cls("fetch", {"url": "http://x"}).aexecute()) + + assert ran == [] + assert result.status == "failure" + assert "[BLOCKED by governance policy]" in result.error + + +def test_async_allow_runs_body(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ok")) + _install_fake_agno(monkeypatch, cls) + + patcher = agno_patch.AgnoPatch(_AllowInterceptor()) + assert patcher.apply() is True + + result = asyncio.run(cls("fetch", {"url": "http://x"}).aexecute()) + + assert ran == [{"url": "http://x"}] + assert result.status == "success" + assert result.result == "ok" + + +def test_denied_result_falls_back_to_string_without_result_type(monkeypatch: pytest.MonkeyPatch) -> None: + # When the failure-result factory is unavailable, a deny still blocks the + # body by returning the raw block message string. + fake_module = SimpleNamespace(FunctionExecutionResult=None) + + def fake_import_module(module_name: str) -> object: + if module_name == "agno.tools.function": + return fake_module + raise ImportError(module_name) + + monkeypatch.setattr(agno_patch.importlib, "import_module", fake_import_module) + assert agno_patch._build_denied_result("boom") == "boom" + + +def test_resolve_tool_name_falls_back_to_class_name() -> None: + fc = SimpleNamespace(function=SimpleNamespace(name=None)) + assert agno_patch._resolve_tool_name(fc) == "SimpleNamespace" + + fc_named = SimpleNamespace(function=SimpleNamespace(name="my_tool")) + assert agno_patch._resolve_tool_name(fc_named) == "my_tool" + + +def test_resolve_tool_args_handles_non_dict() -> None: + assert agno_patch._resolve_tool_args(SimpleNamespace(arguments=None)) == {} + assert agno_patch._resolve_tool_args(SimpleNamespace(arguments={"a": 1})) == {"a": 1} diff --git a/uv.lock b/uv.lock index 2379fd6e..2d626b43 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "agno" }, { name = "coverage" }, { name = "grpcio-tools" }, { name = "mypy" }, @@ -63,6 +64,7 @@ pre-commit-ci = [ { name = "ruff" }, ] test = [ + { name = "agno" }, { name = "openai-agents" }, { name = "pydantic-ai" }, { name = "pytest" }, @@ -86,6 +88,7 @@ provides-extras = ["runtime", "all"] [package.metadata.requires-dev] dev = [ + { name = "agno", specifier = ">=2.0.0" }, { name = "coverage", specifier = "~=7.10" }, { name = "grpcio-tools", specifier = ">=1.66,<2" }, { name = "mypy", specifier = ">=1.11,<3" }, @@ -121,6 +124,7 @@ pre-commit-ci = [ { name = "ruff", specifier = ">=0.1.0" }, ] test = [ + { name = "agno", specifier = ">=2.0.0" }, { name = "openai-agents", specifier = ">=0.1.0" }, { name = "pydantic-ai", specifier = ">=0.3.0" }, { name = "pytest", specifier = ">=8.1.1,<10" }, @@ -131,6 +135,30 @@ test = [ { name = "smolagents", specifier = ">=1.0.0,<2.0.0" }, ] +[[package]] +name = "agno" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "gitpython" }, + { name = "h11" }, + { name = "httpx", extra = ["http2"] }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/82/442e6377753bfc8f532107d20776aebcfd6f4d077b9d7f67782e0adb3cf4/agno-2.6.18.tar.gz", hash = "sha256:2a10f3aec66afe7ed4fcab655a35da3f190bc1e638dcaf41d425878e5cde1946", size = 2155365, upload-time = "2026-06-18T20:37:36.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e4/d1e895cb85b8e675f68bda54e52f419acaf8a2fff935f118f54b9c1fd774/agno-2.6.18-py3-none-any.whl", hash = "sha256:4e4ac7d5f74afefda51c998121d3fa7b8cc591e96ba7ebd4f54a0328a9072c06", size = 2543984, upload-time = "2026-06-18T20:37:34.136Z" }, +] + [[package]] name = "aiofile" version = "3.11.1" @@ -1303,6 +1331,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "hf-xet" version = "1.4.3" @@ -1335,6 +1376,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1376,6 +1426,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "httpx-sse" version = "0.4.3" @@ -1421,6 +1476,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/03/40a05316cb6616e5b7efd7773656441ab04b4b022c2199e79bb4622a92a3/huggingface_hub-1.18.0-py3-none-any.whl", hash = "sha256:729be4a976fb706dcc02d176bcda8a3f32bdf21a294e8f4b3dda6fbcbc9c1ab1", size = 684411, upload-time = "2026-06-05T09:26:31.48Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.14"