diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index bc4b526e..166e5a59 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -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 @@ -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( diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index dcaee05c..6352c1e0 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -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, @@ -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) diff --git a/test/unit/adapters/langchain/test_callback_handler_sync.py b/test/unit/adapters/langchain/test_callback_handler_sync.py index c484d39e..3920c00f 100644 --- a/test/unit/adapters/langchain/test_callback_handler_sync.py +++ b/test/unit/adapters/langchain/test_callback_handler_sync.py @@ -238,3 +238,35 @@ def test_explicit_callback_methods_are_not_delegated() -> None: 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): + 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(), + ) diff --git a/test/unit/test_assembly.py b/test/unit/test_assembly.py index a3776e72..48f4b904 100644 --- a/test/unit/test_assembly.py +++ b/test/unit/test_assembly.py @@ -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 @@ -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: