diff --git a/README.md b/README.md index 1b16004..bb09a19 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ pip install --pre 'agent-assembly[runtime]' # SDK + bundled aasm runtime binary `aasm` sidecar binary, so you don't need a separate runtime install. Plain `agent-assembly` is the pure-Python client and expects an `aasm` runtime to be reachable some other way. +Confirm the `aasm` CLI is on your `PATH`: + +```bash +aasm --version # e.g. aasm 0.0.1rc5 +``` + > **Supply-chain verification.** The only official PyPI package is `agent-assembly` > (anything else is a typosquat). Every release ships PEP 740 attestations on its > [PyPI files page](https://pypi.org/project/agent-assembly/#files) and a CycloneDX @@ -240,6 +246,12 @@ except ConfigurationError as exc: print(f"Invalid configuration: {exc}") ``` +> **Auto-discovery caveat.** Local auto-discovery tries to auto-start a gateway via +> the `aasm` CLI. If `aasm` is **not** on your `PATH`, discovery can't start one and +> raises `ConfigurationError` immediately (rather than the `GatewayError` you'd get +> after the ~5 s health-check timeout when `aasm` is present but the gateway never +> becomes ready). Install the runtime with `pip install --pre 'agent-assembly[runtime]'`. + ## Development Run tests: diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index caf9a1f..bc4b526 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib -import inspect from collections.abc import Mapping from typing import Any, Literal, cast from uuid import UUID @@ -181,47 +180,6 @@ def _resolve_pending_approval( **kwargs, ) - async def aon_tool_start( - self, - serialized: dict[str, Any], - input_str: str, - *, - run_id: UUID, - **kwargs: Any, - ) -> None: - method = getattr(self._interceptor, "check_tool_start", None) - if not callable(method): - return None - - decision = method( - serialized=serialized, - input_str=input_str, - run_id=run_id, - **kwargs, - ) - if inspect.isawaitable(decision): - decision = await decision - - status, reason = self._normalize_decision(decision) - if status == "deny": - raise ToolExecutionBlockedError(reason or "Tool execution blocked by governance.") - if status == "pending": - approval = self._resolve_pending_approval( - serialized=serialized, - input_str=input_str, - run_id=run_id, - **kwargs, - ) - if inspect.isawaitable(approval): - approval = await approval - approval_status, approval_reason = self._normalize_decision(approval) - if approval_status != "allow": - raise ToolExecutionBlockedError( - approval_reason or reason or "Tool execution was not approved by governance." - ) - - return None - def on_tool_end( self, output: Any, @@ -240,26 +198,6 @@ def on_tool_end( ) return None - async def aon_tool_end( - self, - output: Any, - *, - run_id: UUID, - **kwargs: Any, - ) -> None: - method = getattr(self._interceptor, "on_tool_end", None) - if not callable(method): - return None - - result = method( - output=output, - run_id=run_id, - **kwargs, - ) - if inspect.isawaitable(result): - await result - return None - def on_llm_start( self, serialized: dict[str, Any], @@ -280,28 +218,6 @@ def on_llm_start( ) return None - async def aon_llm_start( - self, - serialized: dict[str, Any], - prompts: list[str], - *, - run_id: UUID, - **kwargs: Any, - ) -> None: - method = getattr(self._interceptor, "on_llm_start_scan", None) - if not callable(method): - return None - - result = method( - serialized=serialized, - prompts=prompts, - run_id=run_id, - **kwargs, - ) - if inspect.isawaitable(result): - await result - return None - def on_llm_end( self, response: Any, @@ -320,26 +236,6 @@ def on_llm_end( ) return None - async def aon_llm_end( - self, - response: Any, - *, - run_id: UUID, - **kwargs: Any, - ) -> None: - method = getattr(self._interceptor, "on_llm_end", None) - if not callable(method): - return None - - result = method( - response=response, - run_id=run_id, - **kwargs, - ) - if inspect.isawaitable(result): - await result - return None - def on_graph_node_start( self, node_name: str, diff --git a/test/unit/adapters/langchain/test_callback_handler_async.py b/test/unit/adapters/langchain/test_callback_handler_async.py index 4e953a2..aacf87e 100644 --- a/test/unit/adapters/langchain/test_callback_handler_async.py +++ b/test/unit/adapters/langchain/test_callback_handler_async.py @@ -1,105 +1,116 @@ +"""Async-dispatch coverage for ``AssemblyCallbackHandler``. + +LangChain decides whether to run a callback sync or async by looking the +``on_*`` method up on the handler and testing ``inspect.iscoroutinefunction`` +on it. ``AssemblyCallbackHandler`` defines every ``on_*`` method **sync**, so +under an async agent the async callback manager runs those *sync* methods in a +thread executor via ``ahandle_event`` — there is no separate ``aon_*`` code +path (an earlier set of ``aon_*`` methods was dead and removed in AAASM-4710). + +These tests therefore drive the **real async manager** (``ahandle_event``) +against the sync handler, rather than calling handler methods directly, and +assert that governance still enforces across that async path. They need the +real ``langchain_core`` base class, so the module is guarded by +``importorskip`` and runs under the ``langchain-test`` dependency group: + + uv sync --group langchain-test + .venv/bin/python -m pytest \ + test/unit/adapters/langchain/test_callback_handler_async.py +""" + from __future__ import annotations from uuid import uuid4 import pytest -from agent_assembly.adapters.langchain import AssemblyCallbackHandler -from agent_assembly.exceptions import ToolExecutionBlockedError +pytest.importorskip("langchain_core") + +from langchain_core.callbacks.manager import ahandle_event # noqa: E402 + +from agent_assembly.adapters.langchain import AssemblyCallbackHandler # noqa: E402 +from agent_assembly.exceptions import ToolExecutionBlockedError # noqa: E402 + +class SyncInterceptor: + """Sync governance interceptor. + + The handler's ``on_tool_start`` calls ``check_tool_start`` **without + awaiting**, so the wired interceptor is sync even when the dispatch that + reaches it is async — that coupling is exactly what these tests exercise. + """ -class AsyncInterceptor: def __init__(self) -> None: - self.tool_end_calls = 0 - self.llm_scan_calls = 0 - self.llm_end_calls = 0 + self.tool_start_calls = 0 self.pending_wait_calls = 0 - async def check_tool_start(self, **kwargs: object) -> object: + def check_tool_start(self, **kwargs: object) -> object: + self.tool_start_calls += 1 return kwargs.get("decision", {"status": "allow"}) - async def wait_for_tool_approval(self, **kwargs: object) -> object: + def wait_for_tool_approval(self, **kwargs: object) -> object: self.pending_wait_calls += 1 return kwargs.get("approval_decision", {"status": "allow"}) - async def on_tool_end(self, **kwargs: object) -> None: - self.tool_end_calls += 1 - - async def on_llm_start_scan(self, **kwargs: object) -> None: - self.llm_scan_calls += 1 - - async def on_llm_end(self, **kwargs: object) -> None: - self.llm_end_calls += 1 - -@pytest.mark.asyncio -async def test_aon_tool_start_raises_when_governance_denies() -> None: - handler = AssemblyCallbackHandler(AsyncInterceptor()) - run_id = uuid4() - - with pytest.raises(ToolExecutionBlockedError): - await handler.aon_tool_start( - serialized={"name": "web_search"}, - input_str="query", - run_id=run_id, - decision={"status": "deny", "reason": "blocked"}, - ) +class _EnforcingSyncInterceptor(SyncInterceptor): + """SyncInterceptor carrying the fail-closed enforce posture (AAASM-3106).""" + _enforce = True -@pytest.mark.asyncio -async def test_aon_tool_start_waits_for_pending_approval() -> None: - interceptor = AsyncInterceptor() - handler = AssemblyCallbackHandler(interceptor) - await handler.aon_tool_start( - serialized={"name": "calendar_write"}, - input_str="create event", +async def _dispatch_tool_start(handler: AssemblyCallbackHandler, **kwargs: object) -> None: + """Fire ``on_tool_start`` through the real async callback manager. + + ``ahandle_event`` is the async dispatcher LangChain uses for an async run; + it runs the sync ``on_tool_start`` in an executor and — because the handler + sets ``raise_error=True`` — re-raises a ``ToolExecutionBlockedError`` from + the callback instead of swallowing it. + """ + await ahandle_event( + [handler], + "on_tool_start", + None, + {"name": "web_search"}, + "query", run_id=uuid4(), - decision={"status": "pending"}, - approval_decision={"status": "allow"}, + **kwargs, ) - assert interceptor.pending_wait_calls == 1 - @pytest.mark.asyncio -async def test_aon_tool_end_delegates_to_interceptor() -> None: - interceptor = AsyncInterceptor() +async def test_async_dispatch_blocks_on_deny() -> None: + interceptor = SyncInterceptor() handler = AssemblyCallbackHandler(interceptor) - await handler.aon_tool_end(output={"ok": True}, run_id=uuid4()) + with pytest.raises(ToolExecutionBlockedError): + await _dispatch_tool_start(handler, decision={"status": "deny", "reason": "blocked"}) - assert interceptor.tool_end_calls == 1 + assert interceptor.tool_start_calls == 1, "sync handler was not reached via the async manager" @pytest.mark.asyncio -async def test_aon_llm_start_delegates_to_interceptor() -> None: - interceptor = AsyncInterceptor() +async def test_async_dispatch_allows_on_allow() -> None: + interceptor = SyncInterceptor() handler = AssemblyCallbackHandler(interceptor) - await handler.aon_llm_start( - serialized={"name": "gpt"}, - prompts=["hello", "world"], - run_id=uuid4(), - ) + await _dispatch_tool_start(handler, decision={"status": "allow"}) - assert interceptor.llm_scan_calls == 1 + assert interceptor.tool_start_calls == 1 @pytest.mark.asyncio -async def test_aon_llm_end_delegates_to_interceptor() -> None: - interceptor = AsyncInterceptor() +async def test_async_dispatch_waits_for_pending_approval() -> None: + interceptor = SyncInterceptor() handler = AssemblyCallbackHandler(interceptor) - await handler.aon_llm_end(response={"text": "done"}, run_id=uuid4()) - - assert interceptor.llm_end_calls == 1 - - -class _EnforcingAsyncInterceptor(AsyncInterceptor): - """AsyncInterceptor carrying the fail-closed enforce posture (AAASM-3106).""" + await _dispatch_tool_start( + handler, + decision={"status": "pending"}, + approval_decision={"status": "allow"}, + ) - _enforce = True + assert interceptor.pending_wait_calls == 1 # --- AAASM-3107: unknown/None/malformed verdicts must fail closed under enforce --- @@ -107,30 +118,19 @@ class _EnforcingAsyncInterceptor(AsyncInterceptor): @pytest.mark.asyncio @pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}]) -async def test_aon_tool_start_denies_unknown_under_enforce(decision: object) -> None: - handler = AssemblyCallbackHandler(_EnforcingAsyncInterceptor()) - run_id = uuid4() +async def test_async_dispatch_denies_unknown_under_enforce(decision: object) -> None: + handler = AssemblyCallbackHandler(_EnforcingSyncInterceptor()) with pytest.raises(ToolExecutionBlockedError): - await handler.aon_tool_start( - serialized={"name": "web_search"}, - input_str="query", - run_id=run_id, - decision=decision, - ) + await _dispatch_tool_start(handler, decision=decision) @pytest.mark.asyncio @pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}]) -async def test_aon_tool_start_allows_unknown_when_not_enforcing(decision: object) -> None: - interceptor = AsyncInterceptor() +async def test_async_dispatch_allows_unknown_when_not_enforcing(decision: object) -> None: + interceptor = SyncInterceptor() handler = AssemblyCallbackHandler(interceptor) - await handler.aon_tool_start( - serialized={"name": "web_search"}, - input_str="query", - run_id=uuid4(), - decision=decision, - ) + await _dispatch_tool_start(handler, decision=decision) assert interceptor.pending_wait_calls == 0