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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
104 changes: 0 additions & 104 deletions agent_assembly/adapters/langchain/callback_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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],
Expand All @@ -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,
Expand All @@ -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,
Expand Down
158 changes: 79 additions & 79 deletions test/unit/adapters/langchain/test_callback_handler_async.py
Original file line number Diff line number Diff line change
@@ -1,136 +1,136 @@
"""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 ---


@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