From 995c28d97c8f1fd376179182deee92c636a70703 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:15:22 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20smolagents?= =?UTF-8?q?=20patch=20wrapping=20Tool.=5F=5Fcall=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smolagents routes every tool execution (ToolCallingAgent and CodeAgent paths) through smolagents.tools.Tool.__call__ -> self.forward, so wrapping __call__ is the single chokepoint to govern tool name + args before the body runs. Reuses the CrewAI decision plumbing; fail-closed under enforce. Refs AAASM-3539 Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_assembly/adapters/smolagents/patch.py | 183 ++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 agent_assembly/adapters/smolagents/patch.py diff --git a/agent_assembly/adapters/smolagents/patch.py b/agent_assembly/adapters/smolagents/patch.py new file mode 100644 index 0000000..0c6e990 --- /dev/null +++ b/agent_assembly/adapters/smolagents/patch.py @@ -0,0 +1,183 @@ +"""Smolagents patch module. + +Smolagents (Hugging Face, pip ``smolagents``) routes *every* tool execution +through ``smolagents.tools.Tool.__call__`` — both the ``ToolCallingAgent`` path +(``MultiStepAgent.execute_tool_call`` resolves the tool and calls ``tool(...)``) +and the ``CodeAgent`` path (tools are injected into the sandbox namespace and +invoked as plain callables, which dispatches to ``Tool.__call__``). ``__call__`` +runs ``self.forward(*args, **kwargs)`` to execute the tool body, so wrapping it is +the single chokepoint that lets governance observe the tool name + arguments and +decide *before* the body runs. + +This is the SDK (in-process) interception layer: the wrapper denies before +``forward`` executes (fail-closed under ``enforce``) and records the result on +allow. Reverting restores the original unbound ``__call__``. +""" + +from __future__ import annotations + +import importlib as importlib +from dataclasses import dataclass +from functools import wraps +from threading import local +from typing import Any + +# Reuse the governance-decision plumbing proven by the CrewAI adapter so every +# framework normalizes verdicts and fail-closed posture identically. +from agent_assembly.adapters.crewai.patch import ( + _format_approval_rejected_message, + _format_blocked_message, + _get_pending_tool_approval_timeout_seconds, + _interceptor_enforces, + _invoke_sync_tool_check, + _normalize_decision, + _record_sync_tool_result, + _wait_for_sync_tool_approval, +) + +_TOOL_PATCHED_FLAG = "_agent_assembly_smolagents_tool_patched" +_ORIGINAL_TOOL_CALL = "_agent_assembly_original_smolagents_tool_call" + +# ``sanitize_inputs_outputs`` is a smolagents-internal control flag on +# ``Tool.__call__``, never a tool input — strip it before reporting tool args to +# governance so policies match on the real arguments only. +_INTERNAL_CALL_KWARGS = frozenset({"sanitize_inputs_outputs"}) + +_AGENT_CONTEXT = local() + + +@dataclass(slots=True) +class SmolagentsPatch: + """Applies smolagents runtime monkey-patching hooks.""" + + callback_handler: Any + + def apply(self) -> bool: + """Apply patch wiring and return whether smolagents is available.""" + tool_cls = _load_smolagents_tool_class() + if tool_cls is None: + return False + + _apply_tool_call_patch(tool_cls, self.callback_handler) + return True + + def revert(self) -> None: + """Revert smolagents runtime monkey patches when available.""" + tool_cls = _load_smolagents_tool_class() + if tool_cls is not None: + _revert_tool_call_patch(tool_cls) + return None + + +def _load_smolagents_tool_class() -> type[Any] | None: + try: + module = importlib.import_module("smolagents") + except ImportError: + return None + + tool_cls = getattr(module, "Tool", None) + if isinstance(tool_cls, type): + return tool_cls + return None + + +def _set_thread_local_agent_id(agent_id: str | None) -> None: + _AGENT_CONTEXT.agent_id = agent_id + + +def _get_thread_local_agent_id() -> str | None: + agent_id = getattr(_AGENT_CONTEXT, "agent_id", None) + if isinstance(agent_id, str) and agent_id: + return agent_id + return None + + +def _extract_tool_args(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: + """Build the governance-visible argument mapping for a tool call. + + smolagents tools may be invoked with keyword inputs (``tool(text="hi")``) or a + single positional dict (``tool({"text": "hi"})`` — the dict-arg convenience + path in ``Tool.__call__``). Both shapes are flattened so the policy sees the + real tool inputs. Framework-internal control kwargs are excluded. + """ + tool_args: dict[str, Any] = {key: value for key, value in kwargs.items() if key not in _INTERNAL_CALL_KWARGS} + + if len(args) == 1 and isinstance(args[0], dict): + for key, value in args[0].items(): + tool_args.setdefault(str(key), value) + elif args: + for index, value in enumerate(args): + tool_args.setdefault(f"arg{index}", value) + + return tool_args + + +def _resolve_tool_name(tool: Any) -> str: + name = getattr(tool, "name", None) + if isinstance(name, str) and name: + return name + return str(tool.__class__.__name__) + + +def _apply_tool_call_patch(tool_cls: type[Any], callback_handler: Any) -> None: + if getattr(tool_cls, _TOOL_PATCHED_FLAG, False): + return None + + original_call = tool_cls.__call__ + enforce = _interceptor_enforces(callback_handler) + + @wraps(original_call) + def patched_call(self: Any, *args: Any, **kwargs: Any) -> Any: + tool_name = _resolve_tool_name(self) + tool_args = _extract_tool_args(args, kwargs) + agent_id = _get_thread_local_agent_id() + + decision = _invoke_sync_tool_check( + callback_handler, + tool_name=tool_name, + tool_args=tool_args, + agent_id=agent_id, + ) + status, reason = _normalize_decision(decision, enforce=enforce) + + is_pending_flow = False + if status == "pending": + is_pending_flow = True + timeout_seconds = _get_pending_tool_approval_timeout_seconds(callback_handler) + final_decision = _wait_for_sync_tool_approval( + callback_handler, + tool_name=tool_name, + timeout_seconds=timeout_seconds, + tool_args=tool_args, + agent_id=agent_id, + ) + status, reason = _normalize_decision(final_decision, enforce=enforce) + + if status == "deny": + if is_pending_flow: + return _format_approval_rejected_message(reason) + return _format_blocked_message(reason) + + result = original_call(self, *args, **kwargs) + _record_sync_tool_result(callback_handler, tool_name=tool_name, result=result) + return result + + setattr(tool_cls, _ORIGINAL_TOOL_CALL, original_call) + tool_cls.__call__ = patched_call + setattr(tool_cls, _TOOL_PATCHED_FLAG, True) + return None + + +def _revert_tool_call_patch(tool_cls: type[Any]) -> None: + if not getattr(tool_cls, _TOOL_PATCHED_FLAG, False): + return None + + original_call = getattr(tool_cls, _ORIGINAL_TOOL_CALL, None) + if callable(original_call): + tool_cls.__call__ = original_call + + if hasattr(tool_cls, _ORIGINAL_TOOL_CALL): + delattr(tool_cls, _ORIGINAL_TOOL_CALL) + if hasattr(tool_cls, _TOOL_PATCHED_FLAG): + delattr(tool_cls, _TOOL_PATCHED_FLAG) + return None From 66d467cf037a0090403f7b04c71a908a1e7aa314 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:15:34 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20SmolagentsA?= =?UTF-8?q?dapter=20framework=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the FrameworkAdapter contract for smolagents: framework name, supported range >=1.0.0,<2.0.0 (the 1.x Tool.__call__ chokepoint), and register/unregister delegating to SmolagentsPatch. Refs AAASM-3539 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adapters/smolagents/__init__.py | 6 ++++ agent_assembly/adapters/smolagents/adapter.py | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 agent_assembly/adapters/smolagents/__init__.py create mode 100644 agent_assembly/adapters/smolagents/adapter.py diff --git a/agent_assembly/adapters/smolagents/__init__.py b/agent_assembly/adapters/smolagents/__init__.py new file mode 100644 index 0000000..ab43126 --- /dev/null +++ b/agent_assembly/adapters/smolagents/__init__.py @@ -0,0 +1,6 @@ +"""Smolagents adapter package.""" + +from agent_assembly.adapters.smolagents.adapter import SmolagentsAdapter +from agent_assembly.adapters.smolagents.patch import SmolagentsPatch + +__all__ = ["SmolagentsAdapter", "SmolagentsPatch"] diff --git a/agent_assembly/adapters/smolagents/adapter.py b/agent_assembly/adapters/smolagents/adapter.py new file mode 100644 index 0000000..7a7940f --- /dev/null +++ b/agent_assembly/adapters/smolagents/adapter.py @@ -0,0 +1,30 @@ +"""Smolagents framework adapter.""" + +from __future__ import annotations + +from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor +from agent_assembly.adapters.smolagents.patch import SmolagentsPatch + + +class SmolagentsAdapter(FrameworkAdapter): + """Adapter for Smolagents (Hugging Face) governance hook installation.""" + + def __init__(self) -> None: + self._patch: SmolagentsPatch | None = None + + def get_framework_name(self) -> str: + return "smolagents" + + def get_supported_versions(self) -> list[str]: + # Tool.__call__ -> self.forward has been the tool-execution chokepoint + # since the 1.x line; pin the lower bound to the first stable 1.0 release. + return [">=1.0.0,<2.0.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + self._patch = SmolagentsPatch(interceptor) + self._patch.apply() + + def unregister_hooks(self) -> None: + if self._patch is not None: + self._patch.revert() + self._patch = None From 54904051ca9ed05422a3d4cb69c62f0d3e79f570 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:15:36 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Register=20Smolag?= =?UTF-8?q?entsAdapter=20as=20a=20builtin=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the smolagents priority (6, before the MCP fallback) and wires the adapter into the builtin registry so init_assembly() auto-detects it. Refs AAASM-3539 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 6dd3d29..5898492 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -13,6 +13,7 @@ from agent_assembly.adapters.mcp.adapter import MCPAdapter from agent_assembly.adapters.openai_agents.adapter import OpenAIAgentsAdapter from agent_assembly.adapters.pydantic_ai.adapter import PydanticAIAdapter +from agent_assembly.adapters.smolagents.adapter import SmolagentsAdapter # LangChain must be first: its callback handler threads through to all # subsequent adapters. MCP must be last: it acts as a fallback for @@ -24,6 +25,7 @@ "pydantic_ai": 3, "openai": 4, "google_adk": 5, + "smolagents": 6, "mcp": 99, } @@ -66,6 +68,7 @@ def __init__(self) -> None: PydanticAIAdapter(), OpenAIAgentsAdapter(), GoogleADKAdapter(), + SmolagentsAdapter(), MCPAdapter(), ] for adapter in builtin_adapters: From dfdef08367a3a30ebd647c221d1e18cffcd01798 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:15:47 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=94=A7=20(test):=20Add=20smolagents?= =?UTF-8?q?=20to=20the=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 smolagents lets the importorskip-guarded adapter tests drive a real Tool subclass, so a regression to a fail-open no-op patch is caught instead of silently skipped (AAASM-3528 lesson). Refs AAASM-3539 Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 6 ++++ uv.lock | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c6f7f99..b996955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,12 @@ 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-3539: dev/test-only (NOT a runtime dependency). smolagents routes + # every tool execution through `smolagents.tools.Tool.__call__` (which calls + # `self.forward`); installing it lets the `importorskip`-guarded adapter + # 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", ] pre-commit-ci = [ "pre-commit>=3.5.0,<5", diff --git a/uv.lock b/uv.lock index 324218b..2379fd6 100644 --- a/uv.lock +++ b/uv.lock @@ -40,6 +40,7 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "python-dotenv" }, { name = "ruff" }, + { name = "smolagents" }, ] docs = [ { name = "mike" }, @@ -69,6 +70,7 @@ test = [ { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-rerunfailures" }, + { name = "smolagents" }, ] [package.metadata] @@ -96,6 +98,7 @@ dev = [ { name = "pytest-rerunfailures", specifier = ">=14.0,<17" }, { name = "python-dotenv", specifier = ">=1.0.1,<2" }, { name = "ruff", specifier = ">=0.1.0" }, + { name = "smolagents", specifier = ">=1.0.0,<2.0.0" }, ] docs = [ { name = "mike", specifier = ">=2.1.0,<3" }, @@ -125,6 +128,7 @@ test = [ { name = "pytest-benchmark", specifier = ">=4.0.0,<6" }, { name = "pytest-cov", specifier = ">=5.0.0,<8" }, { name = "pytest-rerunfailures", specifier = ">=14.0,<17" }, + { name = "smolagents", specifier = ">=1.0.0,<2.0.0" }, ] [[package]] @@ -2412,6 +2416,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + [[package]] name = "platformdirs" version = "4.4.0" @@ -3460,6 +3533,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] +[[package]] +name = "smolagents" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "jinja2" }, + { name = "pillow" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/85/3ca67bb57743434ac321821ea54cbdbf0d3dcc8d55f37199709e83141340/smolagents-1.26.0.tar.gz", hash = "sha256:4ec92313265f9cfbcabfc88e192b4bc4505f8475dc5f33dc872062fc567037bd", size = 239034, upload-time = "2026-05-29T05:08:44.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/72bd5edc13288e3f27d4d9c1ef65adc0a68c950ee1fc6d3be270e1110f6c/smolagents-1.26.0-py3-none-any.whl", hash = "sha256:70e1cfb1576f782da93190ee31d9bb2659e5ca4bd84fda0c412e1f20498f28b6", size = 161466, upload-time = "2026-05-29T05:08:43.28Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" From e2f3308d7125c91f7232a29f725690101e2c11b4 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:15:57 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=85=20(test):=20Add=20smolagents=20ad?= =?UTF-8?q?apter=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers apply/revert idempotency, deny blocks the tool body, allow runs + records, sanitize-flag exclusion, dict-positional arg flattening, the adapter contract, and a no-op negative control that fails if the patch is ever reduced to a no-op. Refs AAASM-3539 Co-Authored-By: Claude Opus 4.8 (1M context) --- test/unit/adapters/smolagents/__init__.py | 0 test/unit/adapters/smolagents/test_adapter.py | 39 ++++ test/unit/adapters/smolagents/test_patch.py | 186 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 test/unit/adapters/smolagents/__init__.py create mode 100644 test/unit/adapters/smolagents/test_adapter.py create mode 100644 test/unit/adapters/smolagents/test_patch.py diff --git a/test/unit/adapters/smolagents/__init__.py b/test/unit/adapters/smolagents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/adapters/smolagents/test_adapter.py b/test/unit/adapters/smolagents/test_adapter.py new file mode 100644 index 0000000..2755b72 --- /dev/null +++ b/test/unit/adapters/smolagents/test_adapter.py @@ -0,0 +1,39 @@ +"""Unit tests for the smolagents adapter contract and lifecycle.""" + +from __future__ import annotations + +from typing import Any + +from agent_assembly.adapters.smolagents.adapter import SmolagentsAdapter + + +def test_framework_name_is_smolagents() -> None: + assert SmolagentsAdapter().get_framework_name() == "smolagents" + + +def test_supported_versions_are_non_empty() -> None: + versions = SmolagentsAdapter().get_supported_versions() + assert versions + assert all(v.strip() for v in versions) + + +def test_validate_registration_passes_contract() -> None: + # Must not raise — name and version ranges satisfy the base contract. + SmolagentsAdapter().validate_registration() + + +def test_register_and_unregister_are_idempotent() -> None: + class _Interceptor: + _enforce = True + + def check_tool_start(self, **kwargs: Any) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + adapter = SmolagentsAdapter() + # register() validates the contract then installs hooks; with smolagents + # absent in a pure unit run, apply() returns False but registration succeeds. + adapter.register(_Interceptor()) + adapter.unregister_hooks() + # Double-unregister must be safe. + adapter.unregister_hooks() diff --git a/test/unit/adapters/smolagents/test_patch.py b/test/unit/adapters/smolagents/test_patch.py new file mode 100644 index 0000000..20b4663 --- /dev/null +++ b/test/unit/adapters/smolagents/test_patch.py @@ -0,0 +1,186 @@ +"""Unit tests for the smolagents governance patch. + +These tests use a fake ``smolagents`` module (monkeypatched ``importlib``) so they +run even when the framework is not installed, mirroring the CrewAI adapter tests. +The *real-framework* governance proof — that ``deny`` blocks ``Tool.forward`` on a +genuine ``smolagents.Tool`` — lives in +``test/integration/smolagents/test_real_tool_governance.py``. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.smolagents import patch as smol_patch + + +class _RecordingInterceptor: + """Allows everything and records each governed tool result.""" + + def __init__(self, enforce: bool = True) -> None: + self._enforce = enforce + self.checked: list[dict[str, Any]] = [] + self.recorded: list[Any] = [] + + def check_tool_start(self, **kwargs: Any) -> dict[str, str]: + self.checked.append(kwargs) + return {"status": "allow"} + + def record_result(self, **kwargs: Any) -> None: + self.recorded.append(kwargs) + + +class _DenyInterceptor: + def __init__(self, enforce: bool = True) -> None: + self._enforce = enforce + + def check_tool_start(self, **kwargs: Any) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "blocked by policy"} + + +def _install_fake_smolagents(monkeypatch: pytest.MonkeyPatch) -> type[Any]: + """Install a fake ``smolagents`` module exposing a ``Tool`` whose ``__call__`` + runs a real (observable) body, so deny-blocking is testable.""" + + class FakeTool: + name = "fake_tool" + + def __call__(self, *args: Any, sanitize_inputs_outputs: bool = False, **kwargs: Any) -> dict[str, Any]: + return {"ran": True, "sanitized": sanitize_inputs_outputs, "args": args, "kwargs": kwargs} + + fake_module = SimpleNamespace(Tool=FakeTool) + + def fake_import_module(module_name: str) -> object: + if module_name == "smolagents": + return fake_module + raise ImportError(module_name) + + monkeypatch.setattr(smol_patch.importlib, "import_module", fake_import_module) + return FakeTool + + +def test_apply_patches_tool_call_and_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_smolagents(monkeypatch) + + patcher = smol_patch.SmolagentsPatch(_RecordingInterceptor()) + assert patcher.apply() is True + first_ref = FakeTool.__call__ + assert getattr(FakeTool, smol_patch._TOOL_PATCHED_FLAG, False) is True + + assert patcher.apply() is True + assert FakeTool.__call__ is first_ref + + +def test_revert_restores_original_call(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_smolagents(monkeypatch) + original_call = FakeTool.__call__ + + patcher = smol_patch.SmolagentsPatch(_RecordingInterceptor()) + assert patcher.apply() is True + assert FakeTool.__call__ is not original_call + + patcher.revert() + assert FakeTool.__call__ is original_call + assert getattr(FakeTool, smol_patch._TOOL_PATCHED_FLAG, False) is False + + +def test_allow_runs_tool_body_and_records_result(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_smolagents(monkeypatch) + interceptor = _RecordingInterceptor() + + patcher = smol_patch.SmolagentsPatch(interceptor) + assert patcher.apply() is True + try: + tool = FakeTool() + result = tool(text="hello") + finally: + patcher.revert() + + # Real tool body executed and result was recorded by governance. + assert result["ran"] is True + assert interceptor.checked[0]["tool_name"] == "fake_tool" + assert interceptor.checked[0]["args"] == {"text": "hello"} + assert len(interceptor.recorded) == 1 + + +def test_deny_blocks_tool_body(monkeypatch: pytest.MonkeyPatch) -> None: + """The core governance guarantee: a deny verdict short-circuits before the + real tool body runs, returning the governance block message.""" + FakeTool = _install_fake_smolagents(monkeypatch) + + patcher = smol_patch.SmolagentsPatch(_DenyInterceptor()) + assert patcher.apply() is True + try: + tool = FakeTool() + result = tool(text="hello") + finally: + patcher.revert() + + assert isinstance(result, str) + assert "[BLOCKED by governance policy]" in result + assert "blocked by policy" in result + # The body returns a dict on success; a string proves it never executed. + assert not isinstance(result, dict) + + +def test_noop_patch_negative_control(monkeypatch: pytest.MonkeyPatch) -> None: + """Negative control: without the patch applied, a deny interceptor cannot + block the body. This fails if the adapter were ever reduced to a no-op, + proving the deny path above is real interception (AAASM-3528 lesson).""" + FakeTool = _install_fake_smolagents(monkeypatch) + + # No patch applied — deny interceptor exists but is never wired in. + tool = FakeTool() + result = tool(text="hello") + + assert isinstance(result, dict) + assert result["ran"] is True + + +def test_sanitize_flag_excluded_from_governed_args(monkeypatch: pytest.MonkeyPatch) -> None: + """``sanitize_inputs_outputs`` is a framework control flag, not a tool input, + so it must never appear in the arguments shown to governance policy.""" + FakeTool = _install_fake_smolagents(monkeypatch) + interceptor = _RecordingInterceptor() + + patcher = smol_patch.SmolagentsPatch(interceptor) + assert patcher.apply() is True + try: + tool = FakeTool() + tool(text="hi", sanitize_inputs_outputs=True) + finally: + patcher.revert() + + governed_args = interceptor.checked[0]["args"] + assert governed_args == {"text": "hi"} + assert "sanitize_inputs_outputs" not in governed_args + + +def test_dict_positional_args_are_flattened(monkeypatch: pytest.MonkeyPatch) -> None: + """smolagents supports ``tool({"x": 1})`` — the dict-arg convenience path. + Those inputs must be surfaced to governance as named args.""" + FakeTool = _install_fake_smolagents(monkeypatch) + interceptor = _RecordingInterceptor() + + patcher = smol_patch.SmolagentsPatch(interceptor) + assert patcher.apply() is True + try: + tool = FakeTool() + tool({"text": "world"}) + finally: + patcher.revert() + + assert interceptor.checked[0]["args"] == {"text": "world"} + + +def test_apply_false_when_smolagents_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_import_error(module_name: str) -> object: + raise ImportError(module_name) + + monkeypatch.setattr(smol_patch.importlib, "import_module", raise_import_error) + assert smol_patch._load_smolagents_tool_class() is None + assert smol_patch.SmolagentsPatch(_RecordingInterceptor()).apply() is False From aef272df1591a06e61bcbfd2b35a62f2e907cf5b Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:15:59 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E2=9C=85=20(test):=20Add=20real-smolagents?= =?UTF-8?q?=20governance=20integration=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives a genuine smolagents.Tool subclass through Tool.__call__ exactly as the agent runner does: deny blocks forward(), allow runs + records. Includes a post-revert negative control proving the interception is real, not no-op. Refs AAASM-3539 Co-Authored-By: Claude Opus 4.8 (1M context) --- test/integration/smolagents/__init__.py | 0 .../smolagents/test_real_tool_governance.py | 104 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 test/integration/smolagents/__init__.py create mode 100644 test/integration/smolagents/test_real_tool_governance.py diff --git a/test/integration/smolagents/__init__.py b/test/integration/smolagents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/smolagents/test_real_tool_governance.py b/test/integration/smolagents/test_real_tool_governance.py new file mode 100644 index 0000000..46de10c --- /dev/null +++ b/test/integration/smolagents/test_real_tool_governance.py @@ -0,0 +1,104 @@ +"""Integration test against the real ``smolagents`` framework. + +WHY this uses the real framework (AAASM-3528 / AAASM-3539): the failure mode to +guard against is an adapter that patches the wrong attribute and silently never +intercepts anything (fail-open no-op). This test installs the patch and then +drives a genuine ``smolagents.Tool`` subclass exactly as the agent runner does — +calling ``tool(**inputs)`` (which dispatches to ``Tool.__call__`` -> +``self.forward``) — asserting deny blocks the body and allow runs+records it. If +the patch reverts to a no-op, the denied tool's ``forward`` would execute and +this test fails. +""" + +from __future__ import annotations + +import pytest + +from agent_assembly.adapters.smolagents import patch as smol_patch + + +@pytest.mark.integration +def test_real_smolagents_tool_is_governed() -> None: + pytest.importorskip("smolagents") + from smolagents import Tool + + recorded: list[str] = [] + + class Interceptor: + _enforce = True + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + if kwargs.get("tool_name") == "blocked_tool": + return {"status": "deny", "reason": "blocked by policy"} + return {"status": "allow"} + + def record_result(self, **kwargs: object) -> None: + recorded.append(str(kwargs.get("tool_name"))) + + class BlockedTool(Tool): # type: ignore[misc] # base is a runtime-imported framework class (untyped) + name = "blocked_tool" + description = "A real tool whose body must NOT run when denied." + inputs = {"value": {"type": "string", "description": "input value"}} + output_type = "string" + + def forward(self, value: str) -> str: + return f"executed:{value}" + + class SafeTool(Tool): # type: ignore[misc] # base is a runtime-imported framework class (untyped) + name = "safe_tool" + description = "A real tool that is allowed and must run." + inputs = {"value": {"type": "string", "description": "input value"}} + output_type = "string" + + def forward(self, value: str) -> str: + return f"real-output:{value}" + + patcher = smol_patch.SmolagentsPatch(Interceptor()) + try: + assert patcher.apply() is True + + blocked_result = BlockedTool()(value="x") + safe_result = SafeTool()(value="x") + finally: + patcher.revert() + + # Denied: governance block message returned, the real forward() never ran. + assert isinstance(blocked_result, str) + assert "blocked by policy" in blocked_result + assert "executed:" not in blocked_result + + # Allowed: real tool output returned and the result recorded. + assert safe_result == "real-output:x" + assert "safe_tool" in recorded + + +@pytest.mark.integration +def test_real_smolagents_tool_runs_ungoverned_after_revert() -> None: + """Negative control: after revert, a deny verdict no longer blocks the body, + proving the governed state above is real (non-no-op) interception.""" + pytest.importorskip("smolagents") + from smolagents import Tool + + class DenyAll: + _enforce = True + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "should not apply after revert"} + + class EchoTool(Tool): # type: ignore[misc] # base is a runtime-imported framework class (untyped) + name = "echo" + description = "Echoes its input." + inputs = {"value": {"type": "string", "description": "input value"}} + output_type = "string" + + def forward(self, value: str) -> str: + return f"executed:{value}" + + patcher = smol_patch.SmolagentsPatch(DenyAll()) + assert patcher.apply() is True + patcher.revert() + + result = EchoTool()(value="y") + + assert result == "executed:y" From be34266831a402f14ab683f226cb73560ff5e036 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:16:00 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E2=9C=85=20(test):=20Assert=20smolagents?= =?UTF-8?q?=20is=20a=20builtin=20registry=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs AAASM-3539 Co-Authored-By: Claude Opus 4.8 (1M context) --- test/unit/adapters/test_registry.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/adapters/test_registry.py b/test/unit/adapters/test_registry.py index 72a23ac..0b714eb 100644 --- a/test/unit/adapters/test_registry.py +++ b/test/unit/adapters/test_registry.py @@ -282,3 +282,6 @@ def test_builtin_registry_keys_match_adapter_framework_name() -> None: assert "pydantic_ai" in registry._registered assert registry._registered["pydantic_ai"].get_framework_name() == "pydantic_ai" + + assert "smolagents" in registry._registered + assert registry._registered["smolagents"].get_framework_name() == "smolagents" From f9a76ef2b54bde3c878cbb8cd720e85cb1a3ef1b Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 22 Jun 2026 14:16:14 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Document=20smolage?= =?UTF-8?q?nts=20framework=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the smolagents row to the README and the authoritative Python framework-compatibility matrix (range >=1.0.0,<2.0.0, tested 1.26.x). Refs AAASM-3539 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 ++- docs/compatibility/frameworks.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 55f0186..6ca6862 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, Smolagents, 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` | +| Smolagents | `smolagents` | `>=1.0.0,<2.0.0` | `1.26.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 e9a7ad2..19069ba 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` | +| Smolagents | `smolagents` | `agent_assembly.adapters.smolagents` | `>=1.0.0,<2.0.0` | `1.26.x` | !!! note "Adapter present vs. example present" Every framework above has an adapter that is implemented and registered — @@ -84,7 +85,7 @@ your agent already uses, alongside the SDK, and the matching adapter activates automatically: ```bash -pip install agent-assembly langchain # or crewai, pydantic-ai, mcp, openai-agents, ... +pip install agent-assembly langchain # or crewai, pydantic-ai, mcp, openai-agents, smolagents, ... ``` The SDK deliberately does **not** declare these frameworks as `pip install