From 83f3e7f274afa054f0cc32bf851c3c5c74ba9a2f Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 16 Jul 2026 09:38:42 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=93=9D=20(readme):=20Document=20aasm?= =?UTF-8?q?=20--version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 1b16004..0e70612 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 From ffdedcf74b295f4227d0857e8bff45f033142d71 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 16 Jul 2026 09:38:43 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=20(readme):=20Caveat=20aasm-no?= =?UTF-8?q?t-on-PATH=20ConfigurationError=20in=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 0e70612..bb09a19 100644 --- a/README.md +++ b/README.md @@ -246,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: From 000a4e6fa08c55c944d774a2e38705f9eed37fa1 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 16 Jul 2026 09:39:53 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20(langchain):=20Remo?= =?UTF-8?q?ve=20dead=20aon=5F*=20async=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LangChain dispatches a callback sync-or-async by looking up the on_* name and testing inspect.iscoroutinefunction on it; this handler defines all on_* methods sync, so the async aon_* variants were never reached. The async manager still drives the sync handler. Drop the unused methods (and the now-unused inspect import). --- .../adapters/langchain/callback_handler.py | 104 ------------------ 1 file changed, 104 deletions(-) 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, From ed27c91faab14503a941348add2d2ce181975255 Mon Sep 17 00:00:00 2001 From: Bryant Date: Thu, 16 Jul 2026 09:42:37 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=85=20(langchain):=20Drive=20async=20?= =?UTF-8?q?test=20through=20ahandle=5Fevent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async suite called the now-removed aon_* methods directly, giving false async coverage. Rewrite it to fire on_tool_start through LangChain's real async callback manager (ahandle_event), which runs the sync handler in an executor — proving governance deny/enforce still blocks across the async dispatch path. Guarded by importorskip on langchain_core. --- .../langchain/test_callback_handler_async.py | 156 +++++++++--------- 1 file changed, 79 insertions(+), 77 deletions(-) diff --git a/test/unit/adapters/langchain/test_callback_handler_async.py b/test/unit/adapters/langchain/test_callback_handler_async.py index 21d17e4..aacf87e 100644 --- a/test/unit/adapters/langchain/test_callback_handler_async.py +++ b/test/unit/adapters/langchain/test_callback_handler_async.py @@ -1,104 +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()) - - with pytest.raises(ToolExecutionBlockedError): - await handler.aon_tool_start( - serialized={"name": "web_search"}, - input_str="query", - run_id=uuid4(), - 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 --- @@ -106,29 +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()) +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=uuid4(), - 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