Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agen

## Why use it

- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, Haystack, and MCP servers — drop in, no SDK rewrites required.
- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, Haystack, 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.
Expand All @@ -40,6 +40,7 @@ are expected to work but are not continuously tested.
| Haystack | `haystack` | `>=2.0.0,<3.0` | `2.30.x` |
| 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
Expand Down
3 changes: 3 additions & 0 deletions agent_assembly/adapters/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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
Expand All @@ -26,6 +27,7 @@
"openai": 4,
"google_adk": 5,
"haystack": 6,
"smolagents": 6,
"mcp": 99,
}

Expand Down Expand Up @@ -69,6 +71,7 @@ def __init__(self) -> None:
OpenAIAgentsAdapter(),
GoogleADKAdapter(),
HaystackAdapter(),
SmolagentsAdapter(),
MCPAdapter(),
]
for adapter in builtin_adapters:
Expand Down
6 changes: 6 additions & 0 deletions agent_assembly/adapters/smolagents/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
30 changes: 30 additions & 0 deletions agent_assembly/adapters/smolagents/adapter.py
Original file line number Diff line number Diff line change
@@ -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
183 changes: 183 additions & 0 deletions agent_assembly/adapters/smolagents/patch.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion docs/compatibility/frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ to work but are not continuously tested.
| Haystack | `haystack` | `agent_assembly.adapters.haystack` | `>=2.0.0,<3.0` (the Haystack 2.x `Tool.invoke` hook point, used by both `Tool.invoke()` and the `Agent`/`ToolInvoker` tool-call loop) | `2.30.x` |
| 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 —
Expand Down Expand Up @@ -85,7 +86,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
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Empty file.
104 changes: 104 additions & 0 deletions test/integration/smolagents/test_real_tool_governance.py
Original file line number Diff line number Diff line change
@@ -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"
Empty file.
Loading