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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ cargo test --manifest-path native/aa-ffi-python/Cargo.toml

```bash
uv run ruff check . # lint (config: ruff.toml; line length 120, target py312)
uv run ruff format . # auto-format
uv run black . # auto-format (formatter gate; see .pre-commit-config.yaml)
uv run mypy agent_assembly # type check (mypy.ini; strict on adapters.base/registry)
uv run pre-commit run --all-files # full pre-commit suite (isort + black + autoflake + mypy)
```
Expand Down
12 changes: 10 additions & 2 deletions agent_assembly/adapters/agno/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ def patched_execute(self: Any, *args: Any, **kwargs: Any) -> Any:
tool_args=tool_args,
enforce=enforce,
)
if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal "pending"
# (approval timed out or the resolver returned pending again) is a
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason)
return _build_denied_result(message)

Expand All @@ -217,7 +221,11 @@ async def patched_aexecute(self: Any, *args: Any, **kwargs: Any) -> Any:
tool_args=tool_args,
enforce=enforce,
)
if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal
# "pending" (approval timed out or the resolver returned pending
# again) is a non-decision, not a grant β€” blocking it here stops it
# from falling through and running the tool, matching LangChain.
if status != "allow":
message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason)
return _build_denied_result(message)

Expand Down
6 changes: 5 additions & 1 deletion agent_assembly/adapters/haystack/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,11 @@ def patched_invoke(self: Any, *args: Any, **kwargs: Any) -> Any:
)
status, reason = _normalize_decision(final_decision, enforce=enforce)

if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal "pending"
# (approval timed out or the resolver returned pending again) is a
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
if is_pending_flow:
return _format_approval_rejected_message(reason)
return _format_blocked_message(reason)
Expand Down
12 changes: 10 additions & 2 deletions agent_assembly/adapters/llamaindex/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@ def patched_call(self: Any, *args: Any, **kwargs: Any) -> Any:
agent_id=agent_id,
enforce=enforce,
)
if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal "pending"
# (approval timed out or the resolver returned pending again) is a
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason)
return _denied_tool_output(self, tool_name=tool_name, message=message)

Expand Down Expand Up @@ -327,7 +331,11 @@ async def patched_acall(self: Any, *args: Any, **kwargs: Any) -> Any:
agent_id=agent_id,
enforce=enforce,
)
if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal "pending"
# (approval timed out or the resolver returned pending again) is a
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason)
return _denied_tool_output(self, tool_name=tool_name, message=message)

Expand Down
6 changes: 5 additions & 1 deletion agent_assembly/adapters/mcp/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,11 @@ async def patched_call_tool(self: Any, *args: Any, **kwargs: Any) -> Any:
)
status, reason = _normalize_decision(final_decision, enforce=enforce)

if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal "pending"
# (approval timed out or the resolver returned pending again) is a
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
raise _build_blocked_error(
tool_name=tool_name,
server_identifier=server_identifier,
Expand Down
6 changes: 5 additions & 1 deletion agent_assembly/adapters/microsoft_agent_framework/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,11 @@ async def patched_invoke(self: Any, *args: Any, **kwargs: Any) -> Any:
)
status, reason = _normalize_decision(final_decision, enforce=enforce)

if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal "pending"
# (approval timed out or the resolver returned pending again) is a
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
if is_pending_flow:
raise _build_pending_rejected_error(tool_name, reason)
raise _build_denied_error(tool_name, reason)
Expand Down
6 changes: 5 additions & 1 deletion agent_assembly/adapters/openai_agents/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,11 @@ async def governed_invoke(ctx: Any, tool_input: Any) -> Any:
)
status, reason = _normalize_decision(final_decision, enforce=enforce)

if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal
# "pending" (approval timed out or the resolver returned pending
# again) is a non-decision, not a grant β€” blocking it here stops it
# from falling through and running the tool, matching LangChain.
if status != "allow":
blocked_result = _build_tool_deny_error(
tool_name=tool_name,
reason=reason,
Expand Down
6 changes: 5 additions & 1 deletion agent_assembly/adapters/smolagents/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ def patched_call(self: Any, *args: Any, **kwargs: Any) -> Any:
)
status, reason = _normalize_decision(final_decision, enforce=enforce)

if status == "deny":
# Fail closed: only an explicit "allow" may proceed. A terminal "pending"
# (approval timed out or the resolver returned pending again) is a
# non-decision, not a grant β€” blocking it here stops it from falling
# through and running the tool, matching the LangChain handler.
if status != "allow":
if is_pending_flow:
return _format_approval_rejected_message(reason)
return _format_blocked_message(reason)
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/authoring-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ repos but should still meet this bar), confirm:
lifecycle (framework mocked).
- [ ] Integration test under `test/integration/adapters/<framework_name>/` exercises a minimal
real flow.
- [ ] `uv run ruff check .`, `uv run ruff format --check .`, and `uv run mypy agent_assembly`
- [ ] `uv run ruff check .`, `uv run black --check .`, and `uv run mypy agent_assembly`
are clean.
- [ ] Entry point declared in `pyproject.toml` under
`[project.entry-points."agent_assembly.adapters"]` (for standalone packages).
Expand Down
14 changes: 14 additions & 0 deletions test/bench/test_init_assembly_coldstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@
from agent_assembly.core.assembly import init_assembly


@pytest.fixture(autouse=True)
def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep init_assembly() hermetic whether or not the native ``_core`` extension
is built (AAASM-4906, sibling of AAASM-4898).

With a native ``.so`` present, ``_native_core_available()`` returns True and
init dials a real gateway over gRPC (``connect_runtime_client`` /
``register_agent``); this benchmark stubs neither, so it would hang/fail.
Forcing the pure-Python path (the CI default) keeps the measurement
deterministic in both build modes.
"""
monkeypatch.setattr(assembly_mod, "_native_core_available", lambda: False)


@pytest.mark.benchmark(group="init")
def test_init_assembly_coldstart(benchmark: Any) -> None:
def cold_start() -> None:
Expand Down
16 changes: 16 additions & 0 deletions test/bench/test_latency_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from typing import Any
from uuid import uuid4

import pytest

import agent_assembly.core.assembly as assembly_mod
from agent_assembly.adapters.crewai.patch import (
_apply_basetool_run_patch,
Expand Down Expand Up @@ -49,6 +51,20 @@
_ITERATIONS = 100


@pytest.fixture(autouse=True)
def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep init_assembly() hermetic whether or not the native ``_core`` extension
is built (AAASM-4906, sibling of AAASM-4898).

With a native ``.so`` present, ``_native_core_available()`` returns True and
init dials a real gateway over gRPC (``connect_runtime_client`` /
``register_agent``); the cold-start contract stubs neither, so it would
hang/fail. Forcing the pure-Python path (the CI default) keeps the latency
measurement deterministic in both build modes.
"""
monkeypatch.setattr(assembly_mod, "_native_core_available", lambda: False)


def _percentiles(samples: list[int]) -> tuple[float, float, float]:
"""Return (P50, P95, P99) from a list of nanosecond measurements."""
sorted_samples = sorted(samples)
Expand Down
15 changes: 15 additions & 0 deletions test/integration/test_assembly_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,24 @@
import pytest

from agent_assembly import init_assembly
from agent_assembly.core import assembly as core_assembly
from agent_assembly.exceptions import ConfigurationError


@pytest.fixture(autouse=True)
def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep init_assembly() hermetic whether or not the native ``_core`` extension
is built (AAASM-4906, sibling of AAASM-4898).

With a native ``.so`` present, ``_native_core_available()`` returns True and
init dials a real gateway over gRPC (``connect_runtime_client`` /
``register_agent``). These tests exercise SDK wiring without a live gateway,
so forcing the pure-Python path (the CI default) keeps them deterministic in
both build modes.
"""
monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False)


@pytest.mark.integration
def test_init_assembly_with_valid_config() -> None:
"""Test that assembly initialization works with valid configuration."""
Expand Down
16 changes: 16 additions & 0 deletions test/integration/test_spawn_lineage_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,25 @@

import pytest

from agent_assembly.core import assembly as core_assembly
from agent_assembly.core.assembly import init_assembly
from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope


@pytest.fixture(autouse=True)
def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep init_assembly() hermetic whether or not the native ``_core`` extension
is built (AAASM-4906, sibling of AAASM-4898).

``_call_init_assembly`` mocks GatewayClient / adapter / network layers but not
the native gRPC registration path (``connect_runtime_client`` /
``register_agent``), so a stray native ``.so`` made init dial a real gateway.
Forcing the pure-Python path (the CI default) keeps these tests deterministic
in both build modes.
"""
monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
Expand Down
45 changes: 45 additions & 0 deletions test/unit/adapters/agno/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,48 @@ def test_resolve_tool_name_falls_back_to_class_name() -> None:
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}


class _TerminalPendingInterceptor:
"""Returns "pending" at check AND again at approval β€” a terminal non-decision."""

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": "pending", "reason": "still pending"}


def test_terminal_pending_blocks_sync_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""AAASM-4906: a still-"pending" verdict after the approval round-trip must
fail closed on ``execute`` β€” only an explicit allow may run the tool."""
ran: list[object] = []
cls = _make_fake_function_call_cls(_recording_body(ran, "ran"))
_install_fake_agno(monkeypatch, cls)

patcher = agno_patch.AgnoPatch(_TerminalPendingInterceptor())
assert patcher.apply() is True

result = cls("deploy", {}).execute()

assert ran == []
assert result.status == "failure"
assert result.error.startswith("[APPROVAL REJECTED]")


def test_terminal_pending_blocks_async_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""AAASM-4906: the same fail-closed guarantee on the ``aexecute`` gate."""
ran: list[object] = []
cls = _make_fake_function_call_cls(_recording_body(ran, "ran"))
_install_fake_agno(monkeypatch, cls)

patcher = agno_patch.AgnoPatch(_TerminalPendingInterceptor())
assert patcher.apply() is True

result = asyncio.run(cls("deploy", {}).aexecute())

assert ran == []
assert result.status == "failure"
assert result.error.startswith("[APPROVAL REJECTED]")
23 changes: 23 additions & 0 deletions test/unit/adapters/haystack/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,26 @@ def record_result(self, **kwargs: object) -> None:
assert recorded == ["hello world"]
finally:
patcher.revert()


def test_terminal_pending_blocks_tool_and_does_not_run(monkeypatch: pytest.MonkeyPatch) -> None:
"""AAASM-4906: a still-"pending" verdict after the approval round-trip must
fail closed β€” only an explicit allow may run the tool, matching LangChain."""
FakeTool = _install_fake_haystack_module(monkeypatch)

class TerminalPendingInterceptor:
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": "pending", "reason": "still pending"}

patcher = haystack_patch.HaystackPatch(TerminalPendingInterceptor())
assert patcher.apply() is True

result = FakeTool().invoke(param="value")

assert isinstance(result, str)
assert result.startswith("[APPROVAL REJECTED]")
14 changes: 14 additions & 0 deletions test/unit/adapters/langchain/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ def _reset_assembly_state() -> None:
core_assembly._ACTIVE_CONTEXT = None


@pytest.fixture(autouse=True)
def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep init_assembly() hermetic whether or not the native ``_core`` extension
is built (AAASM-4906, sibling of AAASM-4898).

The init_assembly tests below stub ``_register_adapters`` / ``_start_network_layer``
but not the native gRPC registration path (``connect_runtime_client`` /
``register_agent``), so a stray native ``.so`` made init dial a real gateway.
Forcing the pure-Python path (the CI default) keeps them deterministic in both
build modes.
"""
monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False)


def test_auto_inject_callback_handler_is_idempotent() -> None:
_reset_runtime_state_for_tests()
_reset_assembly_state()
Expand Down
46 changes: 46 additions & 0 deletions test/unit/adapters/llamaindex/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,49 @@ def test_negative_control_noop_patch_would_pass_through(monkeypatch: pytest.Monk
result = tool_cls().call(param="value")
assert body == ["call"]
assert result == {"args": (), "kwargs": {"param": "value"}}


class _TerminalPendingInterceptor:
"""Returns "pending" at check AND again at approval β€” a terminal non-decision."""

def check_tool_start(self, **kwargs: object) -> dict[str, str]:
del kwargs
return {"status": "pending", "reason": "needs review"}

def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]:
del kwargs
return {"status": "pending", "reason": "still pending"}


def test_terminal_pending_blocks_sync_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""AAASM-4906: a still-"pending" verdict after the approval round-trip must
fail closed on ``call`` β€” only an explicit allow may run the tool."""
body: list[str] = []
tool_cls = _make_fake_function_tool_class(body)
_install_fake_llamaindex(monkeypatch, tool_cls)

patcher = li_patch.LlamaIndexPatch(_TerminalPendingInterceptor())
assert patcher.apply() is True

result = tool_cls().call(param="value")

assert body == []
assert isinstance(result, _FakeToolOutput)
assert "[APPROVAL REJECTED]" in result.content


@pytest.mark.asyncio
async def test_terminal_pending_blocks_async_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""AAASM-4906: the same fail-closed guarantee on the ``acall`` gate."""
body: list[str] = []
tool_cls = _make_fake_function_tool_class(body)
_install_fake_llamaindex(monkeypatch, tool_cls)

patcher = li_patch.LlamaIndexPatch(_TerminalPendingInterceptor())
assert patcher.apply() is True

result = await tool_cls().acall(param="value")

assert body == []
assert isinstance(result, _FakeToolOutput)
assert "[APPROVAL REJECTED]" in result.content
Loading