From c4d9001c1150d1588ead845e6eee385668e5485d Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:16:16 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20Agno=20patc?= =?UTF-8?q?h=20hooking=20FunctionCall.execute/aexecute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agno (pip agno, formerly Phidata) runs every function-tool body through FunctionCall.execute (sync) / aexecute (async). Patch that chokepoint with a pre-execution allow/deny: deny short-circuits the body and returns a failure FunctionExecutionResult; allow runs it and records the result. Reuses the shared fail-closed decision/approval helpers from the crewai adapter. Refs AAASM-3537 Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_assembly/adapters/agno/patch.py | 249 ++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 agent_assembly/adapters/agno/patch.py diff --git a/agent_assembly/adapters/agno/patch.py b/agent_assembly/adapters/agno/patch.py new file mode 100644 index 00000000..ec9f8410 --- /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 + return _apply_execute_patch(function_call_cls, self.callback_handler) + + 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) -> bool: + if getattr(function_call_cls, _TOOLS_PATCHED_FLAG, False): + return True + + 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) + return 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 From aaeb2692bb615a83f9c405365b950fab55211398 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:16:31 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20AgnoAdapter?= =?UTF-8?q?=20implementing=20the=20FrameworkAdapter=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgnoAdapter advertises framework name "agno" and supported range >=2.0.0, and installs/reverts the AgnoPatch governance hooks. Refs AAASM-3537 Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_assembly/adapters/agno/__init__.py | 6 +++++ agent_assembly/adapters/agno/adapter.py | 33 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 agent_assembly/adapters/agno/__init__.py create mode 100644 agent_assembly/adapters/agno/adapter.py 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 From c627373baf2861baf829c0198a070f9783221712 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:16:51 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Register=20AgnoAd?= =?UTF-8?q?apter=20as=20a=20built-in=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AgnoAdapter to the registry's built-in list and priority map so init_assembly() auto-detects and hooks Agno whenever the framework is installed. Refs AAASM-3537 Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_assembly/adapters/registry.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 6dd3d297..21b264d0 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 @@ -24,6 +25,7 @@ "pydantic_ai": 3, "openai": 4, "google_adk": 5, + "agno": 6, "mcp": 99, } @@ -66,6 +68,7 @@ def __init__(self) -> None: PydanticAIAdapter(), OpenAIAgentsAdapter(), GoogleADKAdapter(), + AgnoAdapter(), MCPAdapter(), ] for adapter in builtin_adapters: From 67f0e96276852fcc6afcea20804581551f9dd061 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:17:04 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=85=20(adapters):=20Add=20Agno=20patc?= =?UTF-8?q?h=20unit=20tests=20for=20allow/deny/pending=20and=20revert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive a fake agno.tools.function module to cover the allow/deny/pending/record branches, fail-closed-under-enforce on unknown verdicts, the async aexecute path, and idempotent apply+revert — without importing the real framework. Refs AAASM-3537 Co-Authored-By: Claude Opus 4.8 (1M context) --- test/unit/adapters/agno/__init__.py | 0 test/unit/adapters/agno/test_patch.py | 299 ++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 test/unit/adapters/agno/__init__.py create mode 100644 test/unit/adapters/agno/test_patch.py 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} From 1b7ac9bacb6ae59a2de0febe97cbe418a8bb7b3e Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:17:06 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=85=20(adapters):=20Add=20real-Agno?= =?UTF-8?q?=20governance=20integration=20test=20(negative=20control)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive a real agno FunctionCall through the patched execute() to prove a deny short-circuits the actual tool body (fails if the patch is ever a no-op), an allow runs and records it, revert restores unguarded execution, and the registry auto-detects the installed adapter. importorskip-guarded on agno. Refs AAASM-3537 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_agno_interception_integration.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 test/integration/test_agno_interception_integration.py 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") From ad7078c29dad658040dfcce43cde5b9b394ff58b Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:17:23 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=94=A7=20(deps):=20Add=20agno=20to=20?= =?UTF-8?q?the=20test=20dependency=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev/test-only (NOT a runtime dependency). Installing agno lets the importorskip-guarded integration test drive a real FunctionCall, so the negative-control deny test fails on a regression instead of being silently skipped. Refs AAASM-3537 Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 7 ++++++ uv.lock | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c6f7f997..bf379316 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,13 @@ test = [ # integration test drive a real tool call, so a regression to the old # fail-open no-op patch is actually caught instead of silently skipped. "openai-agents>=0.1.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/uv.lock b/uv.lock index 324218b9..416527cd 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" }, @@ -62,6 +63,7 @@ pre-commit-ci = [ { name = "ruff" }, ] test = [ + { name = "agno" }, { name = "openai-agents" }, { name = "pydantic-ai" }, { name = "pytest" }, @@ -84,6 +86,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" }, @@ -118,6 +121,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" }, @@ -127,6 +131,30 @@ test = [ { name = "pytest-rerunfailures", specifier = ">=14.0,<17" }, ] +[[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" @@ -1299,6 +1327,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" @@ -1331,6 +1372,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" @@ -1372,6 +1422,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" @@ -1417,6 +1472,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" From 06a3da3997232f7519015ac05a30171f955cd4da Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:17:23 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Document=20Agno=20?= =?UTF-8?q?framework=20support=20and=20hook=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Agno to the README and the authoritative Python framework-compatibility matrix, with a section explaining the FunctionCall.execute/aexecute hook point and the >=2.0.0 supported / 2.6.x tested lines. Refs AAASM-3537 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 ++- docs/compatibility/frameworks.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 55f0186c..036ee607 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, and MCP servers — drop in, no SDK rewrites required. +- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, 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. @@ -39,6 +39,7 @@ are expected to work but are not continuously tested. | Google ADK | `google.adk` | `>=1.0.0,<2.0` | `1.x` line | | MCP | `mcp` | `>=1.0.0` | `1.27.x` | | OpenAI Agents | `agents` | `>=0.1.0` | `0.17.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/docs/compatibility/frameworks.md b/docs/compatibility/frameworks.md index e9a7ad25..317b644a 100644 --- a/docs/compatibility/frameworks.md +++ b/docs/compatibility/frameworks.md @@ -50,6 +50,7 @@ to work but are not continuously tested. | Google ADK | `google.adk` | `agent_assembly.adapters.google_adk` | `>=1.0.0,<2.0` | `1.x` line | | 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` | +| 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 — @@ -76,6 +77,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 From 49696c8432fe82431a1b473976f73d13938eaa66 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 15:05:58 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=9A=A8=20(agno):=20Make=20=5Fapply=5F?= =?UTF-8?q?execute=5Fpatch=20a=20void=20procedure=20(Sonar=20S3516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper always returned the constant True (early idempotency guard + end-of-body), which Sonar flags as S3516 "method always returns the same value". Convert it to a void procedure — it installs the patch as a side effect — and have apply() return True explicitly after calling it. The apply() contract (True when patched, False when the framework is absent) is unchanged; the idempotency tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_assembly/adapters/agno/patch.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agent_assembly/adapters/agno/patch.py b/agent_assembly/adapters/agno/patch.py index ec9f8410..3e8cda20 100644 --- a/agent_assembly/adapters/agno/patch.py +++ b/agent_assembly/adapters/agno/patch.py @@ -72,7 +72,8 @@ def apply(self) -> bool: function_call_cls = _load_agno_functioncall_class() if function_call_cls is None: return False - return _apply_execute_patch(function_call_cls, self.callback_handler) + _apply_execute_patch(function_call_cls, self.callback_handler) + return True def revert(self) -> None: """Revert the Agno ``FunctionCall`` patches when present.""" @@ -175,9 +176,9 @@ def _evaluate_decision( return status, reason, is_pending_flow -def _apply_execute_patch(function_call_cls: type[Any], callback_handler: Any) -> bool: +def _apply_execute_patch(function_call_cls: type[Any], callback_handler: Any) -> None: if getattr(function_call_cls, _TOOLS_PATCHED_FLAG, False): - return True + return original_execute = function_call_cls.execute original_aexecute = getattr(function_call_cls, "aexecute", None) @@ -228,7 +229,6 @@ async def patched_aexecute(self: Any, *args: Any, **kwargs: Any) -> Any: function_call_cls.aexecute = patched_aexecute setattr(function_call_cls, _TOOLS_PATCHED_FLAG, True) - return True def _revert_execute_patch(function_call_cls: type[Any]) -> None: From 5990502ddb797e06081d5366fd2e2b3b8adce483 Mon Sep 17 00:00:00 2001 From: Bryant <42397554+Chisanan232@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:27:08 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(agno):=20Keep=20frame?= =?UTF-8?q?work=20adapter=20priorities=20consistent=20at=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflict resolution bumped smolagents 6->7 and set agno=8, which would silently change master's smolagents priority on merge and re-conflict with the llamaindex/MS branches. Match master + siblings: haystack/smolagents/agno all at priority 6 (disjoint frameworks; relative order among them is irrelevant). Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_assembly/adapters/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 13d214e1..bc17db21 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -28,8 +28,8 @@ "openai": 4, "google_adk": 5, "haystack": 6, - "smolagents": 7, - "agno": 8, + "smolagents": 6, + "agno": 6, "mcp": 99, }