Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f819a09
✨ (adapters): Add empty langchain adapter package scaffold
Chisanan232 Apr 27, 2026
fa35cf1
✨ (adapters): Add empty callback handler module scaffold
Chisanan232 Apr 27, 2026
4d8b977
✨ (errors): Add ToolExecutionBlockedError for governance deny
Chisanan232 Apr 27, 2026
0730e0d
✨ (adapters): Add AssemblyCallbackHandler class skeleton
Chisanan232 Apr 27, 2026
601962c
✨ (adapters): Add governance decision normalization helper
Chisanan232 Apr 27, 2026
da77a3c
✨ (adapters): Implement on_tool_start deny path
Chisanan232 Apr 27, 2026
38e9ec4
✨ (adapters): Implement on_tool_start pending approval path
Chisanan232 Apr 27, 2026
9f99d04
✨ (adapters): Implement on_tool_end interception hook
Chisanan232 Apr 27, 2026
c361bbd
✨ (adapters): Implement on_llm_start scan-only hook
Chisanan232 Apr 27, 2026
20562cd
✨ (adapters): Implement on_llm_end interception hook
Chisanan232 Apr 27, 2026
7c9e201
✨ (adapters): Add async on_tool_start interception
Chisanan232 Apr 27, 2026
262a47a
✨ (adapters): Add async on_tool_end interception
Chisanan232 Apr 27, 2026
903eeb2
✨ (adapters): Add async on_llm_start interception
Chisanan232 Apr 27, 2026
729ea88
✨ (adapters): Add async on_llm_end interception
Chisanan232 Apr 27, 2026
df1e2e2
✨ (runtime): Auto-inject callback handler in init_assembly
Chisanan232 Apr 27, 2026
92966e2
♻️ (runtime): Make callback injection idempotent
Chisanan232 Apr 27, 2026
67f89f1
✨ (langgraph): Add StateGraph compile patch scaffold
Chisanan232 Apr 27, 2026
8c007da
✨ (langgraph): Add pre-node governance hook
Chisanan232 Apr 27, 2026
0cb1ae9
✨ (langgraph): Add post-node governance hook
Chisanan232 Apr 27, 2026
fc34ba9
✨ (adapters): Add graph node interception methods
Chisanan232 Apr 27, 2026
97b4743
✨ (runtime): Auto-patch LangGraph compile during injection
Chisanan232 Apr 27, 2026
9e21f08
✨ (runtime): Add runtime reset helper for tests
Chisanan232 Apr 27, 2026
1297f43
♻️ (adapters): Export LangChain adapter runtime symbols
Chisanan232 Apr 27, 2026
9356821
✅ (tests): Add unit tests for callback sync methods
Chisanan232 Apr 27, 2026
e14272f
✅ (tests): Add unit tests for callback async methods
Chisanan232 Apr 27, 2026
aaa1c77
✅ (tests): Add unit tests for auto-injection and idempotency
Chisanan232 Apr 27, 2026
4c0b52e
✅ (tests): Add integration test for LangGraph interception path
Chisanan232 Apr 27, 2026
475c91b
📝 (docs): Document LangChain and LangGraph interception behavior
Chisanan232 Apr 27, 2026
957f115
🔧 (typing): Make BaseCallbackHandler import mypy-safe without extras
Chisanan232 Apr 27, 2026
166f185
🔧 (typing): Tighten decision status literals for mypy
Chisanan232 Apr 27, 2026
1eed3b9
🔧 (typing): Silence dynamic callback base mypy base-class checks
Chisanan232 Apr 27, 2026
ae6b118
🔧 (tests): Register integration pytest marker
Chisanan232 Apr 27, 2026
a2ce867
♻️ (exports): Re-export ToolExecutionBlockedError from package root
Chisanan232 Apr 27, 2026
5fb8dd7
♻️ (langgraph): Wrap compiled node executors with per-node hooks
Chisanan232 Apr 27, 2026
ed7cfa5
✅ (tests): Cover multi-node LangGraph tool-block integration path
Chisanan232 Apr 27, 2026
4fbd153
✅ (tests): Add explicit tool-allow unit coverage for callback handler
Chisanan232 Apr 27, 2026
318663b
✅ (tests): Add branch coverage for LangGraph patch fallback paths
Chisanan232 Apr 27, 2026
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
2 changes: 2 additions & 0 deletions agent_assembly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ConfigurationError,
GatewayError,
PolicyError,
ToolExecutionBlockedError,
)

__version__ = "0.0.0"
Expand All @@ -24,4 +25,5 @@
"GatewayError",
"ConfigurationError",
"AdapterValidationError",
"ToolExecutionBlockedError",
]
15 changes: 15 additions & 0 deletions agent_assembly/adapters/langchain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""LangChain adapter package."""

from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler
from agent_assembly.adapters.langchain.langgraph_patch import patch_stategraph_compile
from agent_assembly.adapters.langchain.runtime import (
auto_inject_callback_handler,
get_active_callback_handler,
)

__all__ = [
"AssemblyCallbackHandler",
"patch_stategraph_compile",
"auto_inject_callback_handler",
"get_active_callback_handler",
]
292 changes: 292 additions & 0 deletions agent_assembly/adapters/langchain/callback_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
"""LangChain callback handler module."""

from __future__ import annotations

import importlib
import inspect
from typing import Any, Literal, Mapping, cast
from uuid import UUID

from agent_assembly.exceptions import ToolExecutionBlockedError


class _FallbackBaseCallbackHandler:
"""Fallback base type when langchain-core is not installed."""

pass


_CallbackHandlerBase: type[object] = _FallbackBaseCallbackHandler
try: # pragma: no cover - import availability depends on installed extras.
callbacks_module = importlib.import_module("langchain_core.callbacks")
maybe_base = getattr(callbacks_module, "BaseCallbackHandler", _FallbackBaseCallbackHandler)
if isinstance(maybe_base, type):
_CallbackHandlerBase = cast(type[object], maybe_base)
except ImportError: # pragma: no cover - fallback keeps runtime import-safe.
pass


class AssemblyCallbackHandler(_CallbackHandlerBase): # type: ignore[valid-type,misc]
"""Callback handler that delegates runtime events to governance interception."""

def __init__(self, interceptor: Any) -> None:
self._interceptor = interceptor

def _normalize_decision(
self,
decision: object,
) -> tuple[Literal["allow", "deny", "pending"], str | None]:
if isinstance(decision, str):
normalized = decision.strip().lower()
if normalized == "allow":
return "allow", None
if normalized == "deny":
return "deny", None
if normalized == "pending":
return "pending", None
return "allow", None

if isinstance(decision, Mapping):
raw_status = str(decision.get("status", "allow")).strip().lower()
if raw_status == "allow":
status: Literal["allow", "deny", "pending"] = "allow"
elif raw_status == "deny":
status = "deny"
elif raw_status == "pending":
status = "pending"
else:
status = "allow"

reason_value = decision.get("reason")
reason = str(reason_value) if reason_value is not None else None
return status, reason

return "allow", None

def on_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,
)
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,
)
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 _resolve_pending_approval(
self,
*,
serialized: dict[str, Any],
input_str: str,
run_id: UUID,
**kwargs: Any,
) -> object:
wait_method = getattr(self._interceptor, "wait_for_tool_approval", None)
if not callable(wait_method):
return "deny"

return wait_method(
serialized=serialized,
input_str=input_str,
run_id=run_id,
**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,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
method = getattr(self._interceptor, "on_tool_end", None)
if not callable(method):
return None

method(
output=output,
run_id=run_id,
**kwargs,
)
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],
prompts: list[str],
*,
run_id: UUID,
**kwargs: Any,
) -> None:
method = getattr(self._interceptor, "on_llm_start_scan", None)
if not callable(method):
return None

method(
serialized=serialized,
prompts=prompts,
run_id=run_id,
**kwargs,
)
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,
*,
run_id: UUID,
**kwargs: Any,
) -> None:
method = getattr(self._interceptor, "on_llm_end", None)
if not callable(method):
return None

method(
response=response,
run_id=run_id,
**kwargs,
)
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, state: Any) -> None:
method = getattr(self._interceptor, "on_graph_node_start", None)
if not callable(method):
return None
method(node_name=node_name, state=state)
return None

def on_graph_node_end(self, node_name: str, state: Any, result: Any) -> None:
method = getattr(self._interceptor, "on_graph_node_end", None)
if not callable(method):
return None
method(node_name=node_name, state=state, result=result)
return None
Loading