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, and MCP servers — drop in, no SDK rewrites required.
- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, Haystack, 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 @@ -37,6 +37,7 @@ are expected to work but are not continuously tested.
| Pydantic AI | `pydantic_ai` | `>=0.1.0` | `>=0.3.0` |
| CrewAI | `crewai` | `>=0.1.0` | `1.14.x` |
| Google ADK | `google.adk` | `>=1.0.0,<2.0` | `1.x` line |
| 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` |

Expand Down
12 changes: 12 additions & 0 deletions agent_assembly/adapters/haystack/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Haystack adapter package.

Governs deepset Haystack 2.x agents by intercepting tool execution at
``haystack.tools.Tool.invoke`` — the single chokepoint through which both
direct ``Tool.invoke()`` calls and the ``Agent``/``ToolInvoker`` tool-call
path flow. See ``patch.py`` for the hook-point rationale.
"""

from agent_assembly.adapters.haystack.adapter import HaystackAdapter
from agent_assembly.adapters.haystack.patch import HaystackPatch

__all__ = ["HaystackAdapter", "HaystackPatch"]
31 changes: 31 additions & 0 deletions agent_assembly/adapters/haystack/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Haystack framework adapter."""

from __future__ import annotations

from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.adapters.haystack.patch import HaystackPatch


class HaystackAdapter(FrameworkAdapter):
"""Adapter for deepset Haystack 2.x governance hook installation."""

def __init__(self) -> None:
self._patch: HaystackPatch | None = None

def get_framework_name(self) -> str:
return "haystack"

def get_supported_versions(self) -> list[str]:
# Haystack 2.x is the line that exposes the agentic ``Agent`` / ``ToolInvoker``
# tool-call path and the ``haystack.tools.Tool.invoke`` hook point this adapter
# patches. The 1.x line predates that API and is out of scope.
return [">=2.0.0,<3.0"]

def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
self._patch = HaystackPatch(interceptor)
self._patch.apply()

def unregister_hooks(self) -> None:
if self._patch is not None:
self._patch.revert()
self._patch = None
265 changes: 265 additions & 0 deletions agent_assembly/adapters/haystack/patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
"""Haystack runtime monkey-patch.

Haystack (deepset; ``pip install haystack-ai``, imported as ``haystack``) executes
every tool through ``haystack.tools.Tool.invoke(**kwargs)``. The agentic
``haystack.components.agents.Agent`` dispatches its tool calls via
``haystack.components.tools.ToolInvoker``, which itself ends up calling
``tool_to_invoke.invoke(**final_args)`` (see ``ToolInvoker._make_context_bound_invoke``
in Haystack 2.x). ``Tool.invoke`` is therefore the *single* execution chokepoint that
governs both the bare ``Tool.invoke()`` path and the full Agent tool-call loop —
patching it intercepts every tool execution, which is why governance is wired here and
not at the higher-level ``Agent`` / ``ToolInvoker`` layer.

The interceptor contract mirrors the other tool-call adapters (CrewAI, Pydantic AI):
a ``check_tool_start`` pre-execution gate that returns ``allow`` / ``deny`` /
``pending``, an optional ``wait_for_tool_approval`` for the pending flow, and a
post-execution ``record_result`` / ``on_tool_end`` audit hook. Under the fail-closed
``enforce`` posture an unknown or malformed verdict denies (AAASM-3107).
"""

from __future__ import annotations

import importlib as importlib
from collections.abc import Mapping
from dataclasses import dataclass
from functools import wraps
from typing import Any, Literal, cast

_TOOL_PATCHED_FLAG = "_agent_assembly_haystack_tool_patched"
_ORIGINAL_TOOL_INVOKE = "_agent_assembly_original_haystack_tool_invoke"
_DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS = 300


@dataclass(slots=True)
class HaystackPatch:
"""Applies Haystack runtime monkey-patching hooks on ``Tool.invoke``."""

callback_handler: Any

def apply(self) -> bool:
"""Patch ``Tool.invoke`` and return whether Haystack is available."""
tool_cls = _load_haystack_tool_class()
if tool_cls is None:
return False
_apply_tool_invoke_patch(tool_cls, self.callback_handler)
return True

def revert(self) -> None:
"""Revert the ``Tool.invoke`` monkey patch when present."""
tool_cls = _load_haystack_tool_class()
if tool_cls is not None:
_revert_tool_invoke_patch(tool_cls)
return None


def _load_haystack_tool_class() -> type[Any] | None:
try:
module = importlib.import_module("haystack.tools")
except ImportError:
return None

tool_cls = getattr(module, "Tool", None)
if isinstance(tool_cls, type):
return tool_cls
return None


def _format_blocked_message(reason: str | None) -> str:
reason_text = reason or "No reason provided."
return f"[BLOCKED by governance policy] {reason_text}. Please choose a different approach to accomplish this task."


def _format_approval_rejected_message(reason: str | None) -> str:
reason_text = reason or "No reason provided."
return f"[APPROVAL REJECTED] Action was reviewed and denied: {reason_text}"


_UNKNOWN_DECISION_REASON = "Unrecognized governance decision; denied under enforce."


def _interceptor_enforces(callback_handler: Any) -> bool:
"""Return whether the wired interceptor is in fail-closed ``enforce`` posture.

The governance interceptor carries ``_enforce`` set from
``enforcement_mode == "enforce"`` (AAASM-3106). A bare ``GatewayClient`` has no
such attribute and defaults to fail-open. Compared strictly against ``True`` so a
stub interceptor whose ``__getattr__`` synthesizes truthy values for missing
attributes is not mistaken for the enforce posture; the real flag is always a
``bool``.
"""
target = getattr(callback_handler, "_interceptor", callback_handler)
return getattr(target, "_enforce", False) is True


def _unknown_decision(enforce: bool) -> tuple[Literal["allow", "deny", "pending"], str | None]:
"""Map an unrecognized / malformed verdict, failing closed under ``enforce`` (AAASM-3107)."""
if enforce:
return "deny", _UNKNOWN_DECISION_REASON
return "allow", None


_KNOWN_STATUSES: frozenset[str] = frozenset({"allow", "deny", "pending"})


def _coerce_known_status(value: str) -> Literal["allow", "deny", "pending"] | None:
if value in _KNOWN_STATUSES:
return cast("Literal['allow', 'deny', 'pending']", value)
return None


def _normalize_decision(
decision: object,
*,
enforce: bool = False,
) -> tuple[Literal["allow", "deny", "pending"], str | None]:
if isinstance(decision, str):
status = _coerce_known_status(decision.strip().lower())
if status is not None:
return status, None
return _unknown_decision(enforce)

if isinstance(decision, Mapping):
reason_value = decision.get("reason")
reason = str(reason_value) if reason_value is not None else None
status = _coerce_known_status(str(decision.get("status", "")).strip().lower())
if status is not None:
return status, reason
unknown_status, unknown_reason = _unknown_decision(enforce)
return unknown_status, reason if reason is not None else unknown_reason

return _unknown_decision(enforce)


def _invoke_tool_check(
callback_handler: Any,
*,
tool_name: str,
tool_args: dict[str, Any],
) -> object:
method = getattr(callback_handler, "check_tool_start", None)
if callable(method):
return method(
serialized={"name": tool_name},
input_str=str(tool_args),
tool_name=tool_name,
args=tool_args,
agent_id=None,
)
return {"status": "allow"}


def _wait_for_tool_approval(
callback_handler: Any,
*,
tool_name: str,
timeout_seconds: int,
tool_args: dict[str, Any],
) -> object:
method = getattr(callback_handler, "wait_for_tool_approval", None)
if callable(method):
return method(
tool_name=tool_name,
timeout_seconds=timeout_seconds,
args=tool_args,
agent_id=None,
)
return {"status": "deny", "reason": "Approval handler is unavailable."}


def _get_pending_tool_approval_timeout_seconds(callback_handler: Any) -> int:
provider = getattr(callback_handler, "get_pending_tool_approval_timeout_seconds", None)
if callable(provider):
configured = provider()
else:
configured = getattr(callback_handler, "pending_tool_approval_timeout_seconds", None)

if isinstance(configured, str):
stripped = configured.strip()
if stripped.isdigit():
parsed = int(stripped)
if parsed > 0:
return parsed
return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS

if isinstance(configured, bool):
return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS

if isinstance(configured, int) and configured > 0:
return configured

return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS


def _record_tool_result(
callback_handler: Any,
*,
tool_name: str,
result: object,
) -> None:
record_method = getattr(callback_handler, "record_result", None)
if callable(record_method):
record_method(tool_name=tool_name, result=result)
return None

tool_end_method = getattr(callback_handler, "on_tool_end", None)
if callable(tool_end_method):
tool_end_method(output=result, tool_name=tool_name)
return None


def _apply_tool_invoke_patch(tool_cls: type[Any], callback_handler: Any) -> None:
if getattr(tool_cls, _TOOL_PATCHED_FLAG, False):
return None

original_invoke = tool_cls.invoke
enforce = _interceptor_enforces(callback_handler)

@wraps(original_invoke)
def patched_invoke(self: Any, **kwargs: Any) -> Any:
tool_name = str(getattr(self, "name", self.__class__.__name__))
tool_args = dict(kwargs)
decision = _invoke_tool_check(
callback_handler,
tool_name=tool_name,
tool_args=tool_args,
)
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_tool_approval(
callback_handler,
tool_name=tool_name,
timeout_seconds=timeout_seconds,
tool_args=tool_args,
)
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_invoke(self, **kwargs)
_record_tool_result(callback_handler, tool_name=tool_name, result=result)
return result

setattr(tool_cls, _ORIGINAL_TOOL_INVOKE, original_invoke)
tool_cls.invoke = patched_invoke
setattr(tool_cls, _TOOL_PATCHED_FLAG, True)


def _revert_tool_invoke_patch(tool_cls: type[Any]) -> None:
if not getattr(tool_cls, _TOOL_PATCHED_FLAG, False):
return None

original_invoke = getattr(tool_cls, _ORIGINAL_TOOL_INVOKE, None)
if callable(original_invoke):
tool_cls.invoke = original_invoke

if hasattr(tool_cls, _ORIGINAL_TOOL_INVOKE):
delattr(tool_cls, _ORIGINAL_TOOL_INVOKE)
if hasattr(tool_cls, _TOOL_PATCHED_FLAG):
delattr(tool_cls, _TOOL_PATCHED_FLAG)
return None
3 changes: 3 additions & 0 deletions agent_assembly/adapters/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from agent_assembly.adapters.base import FrameworkAdapter
from agent_assembly.adapters.crewai.adapter import CrewAIAdapter
from agent_assembly.adapters.google_adk.adapter import GoogleADKAdapter
from agent_assembly.adapters.haystack.adapter import HaystackAdapter
from agent_assembly.adapters.langchain.adapter import LangChainAdapter
from agent_assembly.adapters.langgraph.adapter import LangGraphAdapter
from agent_assembly.adapters.mcp.adapter import MCPAdapter
Expand All @@ -24,6 +25,7 @@
"pydantic_ai": 3,
"openai": 4,
"google_adk": 5,
"haystack": 6,
"mcp": 99,
}

Expand Down Expand Up @@ -66,6 +68,7 @@ def __init__(self) -> None:
PydanticAIAdapter(),
OpenAIAgentsAdapter(),
GoogleADKAdapter(),
HaystackAdapter(),
MCPAdapter(),
]
for adapter in builtin_adapters:
Expand Down
1 change: 1 addition & 0 deletions docs/compatibility/frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ to work but are not continuously tested.
| Pydantic AI | `pydantic_ai` | `agent_assembly.adapters.pydantic_ai` | `>=0.1.0` (both the `<0.3` `Tool._run` and `>=0.3` `AbstractToolset.call_tool` hook points) | `>=0.3.0` — see [note below](#pydantic-ai-version-range) |
| CrewAI | `crewai` | `agent_assembly.adapters.crewai` | `>=0.1.0` | `1.14.x` |
| Google ADK | `google.adk` | `agent_assembly.adapters.google_adk` | `>=1.0.0,<2.0` | `1.x` line |
| 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` |

Expand Down
Empty file.
Loading