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
9 changes: 9 additions & 0 deletions agent_assembly/adapters/langchain/callback_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class AssemblyCallbackHandler(_CallbackHandlerBase): # type: ignore[valid-type,
raise_error: bool = True

_UNKNOWN_DECISION_REASON = "Unrecognized governance decision; denied under enforce."
_MISSING_CHECK_TOOL_START_REASON = "Governance interceptor exposes no check_tool_start; denied under enforce."

def __init__(self, interceptor: Any) -> None:
self._interceptor = interceptor
Expand Down Expand Up @@ -135,6 +136,14 @@ def on_tool_start(
) -> None:
method = getattr(self._interceptor, "check_tool_start", None)
if not callable(method):
# Mirrors the other adapters' ``_missing_interceptor_decision``
# fallback (AAASM-4790): a co-installed adapter can hand this
# handler an interceptor that exposes no ``check_tool_start``.
# Silently allowing there skipped pre-execution governance under
# ``enforce``, so fail closed there and only fail open under
# observe / disabled, consistent with ``_unknown_decision``.
if self._enforce:
raise ToolExecutionBlockedError(self._MISSING_CHECK_TOOL_START_REASON)
return None

decision = method(
Expand Down
21 changes: 20 additions & 1 deletion agent_assembly/core/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,24 @@ def _register_agent_with_gateway(
return True


def _warn_adapter_registration_failed(adapter: FrameworkAdapter, error: Exception) -> None:
"""Emit a loud, unconditional stderr warning that a framework adapter failed to attach.

``register_hooks`` failing was previously swallowed with a bare ``continue``
(AAASM-4790), leaving a co-installed framework running fully ungoverned with
no trace. Mirrors :func:`_warn_agent_unregistered`'s unconditional
``sys.stderr.write`` so ``logging`` configuration cannot silence it. Init
still proceeds β€” other adapters may register fine β€” matching the existing
fail-open-and-continue policy in :func:`_register_adapters`.
"""
sys.stderr.write(
"[agent-assembly] WARNING: framework adapter "
f"{adapter.get_framework_name()!r} failed to register governance hooks "
f"({error}); this framework will run UNGOVERNED by the SDK layer. The "
"proxy / eBPF layers remain authoritative.\n"
)


def _register_adapters(
client: GatewayClient,
process_agent_id: str,
Expand Down Expand Up @@ -462,7 +480,8 @@ def _register_adapters(

try:
adapter.register_hooks(interceptor)
except Exception:
except Exception as error:
_warn_adapter_registration_failed(adapter, error)
continue

registered.append(adapter)
Expand Down
32 changes: 32 additions & 0 deletions test/unit/adapters/langchain/test_callback_handler_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,35 @@
handler.on_tool_end(output="done", run_id=uuid4())

assert interceptor.tool_end_calls == 1


# --- AAASM-4790: a check_tool_start-less interceptor must fail closed under enforce ---


class _NoCheckToolStartInterceptor:
"""An interceptor that does not expose ``check_tool_start`` at all."""

def __init__(self, *, enforce: bool) -> None:
self._enforce = enforce


def test_on_tool_start_blocks_when_check_tool_start_missing_under_enforce() -> None:
handler = AssemblyCallbackHandler(_NoCheckToolStartInterceptor(enforce=True))

with pytest.raises(ToolExecutionBlockedError):

Check warning on line 256 in test/unit/adapters/langchain/test_callback_handler_sync.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=ai-agent-assembly_python-sdk&issues=AZ9vMzyjy7c8NdCNtIYY&open=AZ9vMzyjy7c8NdCNtIYY&pullRequest=274
handler.on_tool_start(
serialized={"name": "web_search"},
input_str="query",
run_id=uuid4(),
)


def test_on_tool_start_allows_when_check_tool_start_missing_under_observe() -> None:
handler = AssemblyCallbackHandler(_NoCheckToolStartInterceptor(enforce=False))

# Must not raise: observe/disabled preserves the fail-open dry-run posture.
handler.on_tool_start(
serialized={"name": "web_search"},
input_str="query",
run_id=uuid4(),
)
43 changes: 43 additions & 0 deletions test/unit/test_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from agent_assembly import init_assembly
from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor
from agent_assembly.client.gateway import GatewayClient
from agent_assembly.core import assembly as core_assembly
from agent_assembly.exceptions import AssemblyError, ConfigurationError

Expand Down Expand Up @@ -214,6 +215,48 @@ def unregister_hooks(self) -> None:
assert context.is_shutdown is True


def test_register_adapters_warns_on_stderr_when_register_hooks_raises(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""A framework adapter whose ``register_hooks`` raises must not fail
silently (AAASM-4790) β€” the operator gets a stderr warning naming the
framework, and other adapters still register (the ``continue`` is kept)."""

class _BrokenAdapter(_FakeAdapter):
def register_hooks(self, interceptor: GovernanceInterceptor) -> None:
raise RuntimeError("monkeypatch failed to apply")

broken = _BrokenAdapter("broken-framework")
healthy = _FakeAdapter("healthy-framework")

class _FakeRegistry:
def get_available_adapters_by_priority(self) -> list[FrameworkAdapter]:
return [broken, healthy]

monkeypatch.setattr(core_assembly, "AdapterRegistry", _FakeRegistry)
monkeypatch.setattr(
core_assembly,
"build_governance_interceptor",
lambda *args, **kwargs: object(),
)

client = GatewayClient(
gateway_url="http://localhost:8080",
agent_id="test-agent-001",
api_key="test-api-key",
)
registered = core_assembly._register_adapters(
client=client,
process_agent_id="test-agent-001",
)

assert registered == [healthy]
stderr = capsys.readouterr().err
assert "broken-framework" in stderr
assert "UNGOVERNED" in stderr


def test_init_assembly_rejects_conflicting_reinit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
Loading