From 2d72f4d0424887178d27ccce17c9535f05ac8026 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:22:05 +0800 Subject: [PATCH 01/32] =?UTF-8?q?=F0=9F=93=A6=20(adapters):=20Create=20emp?= =?UTF-8?q?ty=20langgraph=20adapter=20package=20scaffold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 agent_assembly/adapters/langgraph/__init__.py diff --git a/agent_assembly/adapters/langgraph/__init__.py b/agent_assembly/adapters/langgraph/__init__.py new file mode 100644 index 0000000..be148d8 --- /dev/null +++ b/agent_assembly/adapters/langgraph/__init__.py @@ -0,0 +1 @@ +"""LangGraph adapter package.""" From 5f44803ddd93b2b19ae6fd81d0c098c9a5aa58fa Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:22:16 +0800 Subject: [PATCH 02/32] =?UTF-8?q?=F0=9F=93=A6=20(adapters):=20Create=20emp?= =?UTF-8?q?ty=20LangGraph=20patch=20module=20scaffold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 agent_assembly/adapters/langgraph/patch.py diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py new file mode 100644 index 0000000..a0381f3 --- /dev/null +++ b/agent_assembly/adapters/langgraph/patch.py @@ -0,0 +1 @@ +"""LangGraph patch module.""" From ef91af4858936b14f9126ee143d8e38b6c88a6b6 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:22:25 +0800 Subject: [PATCH 03/32] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20LangGraph?= =?UTF-8?q?Patch=20class=20skeleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index a0381f3..6d047a1 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -1 +1,17 @@ """LangGraph patch module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(slots=True) +class LangGraphPatch: + """Applies LangGraph runtime monkey-patching for node-level governance hooks.""" + + callback_handler: Any + + def apply(self) -> bool: + """Apply patching once and return whether patch wiring is active.""" + return False From 4538b4b720ce527bf1dac0e789ba8bbdf343d67d Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:22:36 +0800 Subject: [PATCH 04/32] =?UTF-8?q?=E2=9C=A8=20(adapters):=20Add=20apply=20s?= =?UTF-8?q?hell=20for=20StateGraph=20compile=20patching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 6d047a1..7e15b89 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import importlib from typing import Any @@ -14,4 +15,20 @@ class LangGraphPatch: def apply(self) -> bool: """Apply patching once and return whether patch wiring is active.""" - return False + state_graph_cls = _load_stategraph_class() + if state_graph_cls is None: + return False + return True + + +def _load_stategraph_class() -> type[Any] | None: + try: + module = importlib.import_module("langgraph.graph.state") + except ImportError: + return None + + state_graph_cls = getattr(module, "StateGraph", None) + if isinstance(state_graph_cls, type): + return state_graph_cls + + return None From 06cdf1c2722a43d4047130520b7d06919ad2590f Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:22:46 +0800 Subject: [PATCH 05/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Add=20compile?= =?UTF-8?q?=20patch=20idempotent=20guard=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 7e15b89..8aa8e56 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -6,6 +6,9 @@ import importlib from typing import Any +_PATCHED_FLAG = "_agent_assembly_compile_patched" +_ORIGINAL_COMPILE = "_agent_assembly_original_compile" + @dataclass(slots=True) class LangGraphPatch: @@ -18,6 +21,8 @@ def apply(self) -> bool: state_graph_cls = _load_stategraph_class() if state_graph_cls is None: return False + if getattr(state_graph_cls, _PATCHED_FLAG, False): + return True return True From dcdecd97fef151333fe7dfb446227095913866f3 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:23:01 +0800 Subject: [PATCH 06/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Add=20compiled?= =?UTF-8?q?=20graph=20node-map=20discovery=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 8aa8e56..fdc149e 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -26,6 +26,26 @@ def apply(self) -> bool: return True +def _discover_compiled_graph_node_maps(compiled_graph: Any) -> list[Any]: + candidate_maps = [ + getattr(compiled_graph, "nodes", None), + getattr(compiled_graph, "_nodes", None), + ] + + pregel = getattr(compiled_graph, "pregel", None) + if pregel is None: + pregel = getattr(compiled_graph, "_pregel", None) + if pregel is not None: + candidate_maps.extend( + [ + getattr(pregel, "nodes", None), + getattr(pregel, "_nodes", None), + ] + ) + + return [node_map for node_map in candidate_maps if node_map is not None] + + def _load_stategraph_class() -> type[Any] | None: try: module = importlib.import_module("langgraph.graph.state") From b3d4c3a0da9102631b809d04da90d394af0c1ada Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:23:12 +0800 Subject: [PATCH 07/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Add=20sync=20n?= =?UTF-8?q?ode=20wrapper=20shell=20with=20passthrough=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index fdc149e..c3672ee 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -26,6 +26,20 @@ def apply(self) -> bool: return True +def _extract_state(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: + if args: + return args[0] + return kwargs.get("state") + + +def _make_sync_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: + def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: + del node_name, callback_handler + return original_func(*node_args, **node_kwargs) + + return wrapped_node + + def _discover_compiled_graph_node_maps(compiled_graph: Any) -> list[Any]: candidate_maps = [ getattr(compiled_graph, "nodes", None), From 94d4b8a4947aa292d417d700f73711b41554635a Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:23:25 +0800 Subject: [PATCH 08/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Add=20async=20?= =?UTF-8?q?node=20wrapper=20shell=20with=20passthrough=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index c3672ee..b4d6a1f 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -40,6 +40,14 @@ def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: return wrapped_node +def _make_async_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: + async def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: + del node_name, callback_handler + return await original_func(*node_args, **node_kwargs) + + return wrapped_node + + def _discover_compiled_graph_node_maps(compiled_graph: Any) -> list[Any]: candidate_maps = [ getattr(compiled_graph, "nodes", None), From 5dfd0241268682f1fa4caa79e7a754656fa276c1 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:23:37 +0800 Subject: [PATCH 09/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Add=20agent=5F?= =?UTF-8?q?id=20extraction=20helper=20from=20RunnableConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index b4d6a1f..7b371de 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -32,6 +32,29 @@ def _extract_state(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: return kwargs.get("state") +def _extract_agent_id(config: object) -> str | None: + if not isinstance(config, dict): + return None + + direct_agent_id = config.get("agent_id") + if isinstance(direct_agent_id, str) and direct_agent_id: + return direct_agent_id + + configurable = config.get("configurable") + if isinstance(configurable, dict): + nested_agent_id = configurable.get("agent_id") + if isinstance(nested_agent_id, str) and nested_agent_id: + return nested_agent_id + + metadata = config.get("metadata") + if isinstance(metadata, dict): + metadata_agent_id = metadata.get("agent_id") + if isinstance(metadata_agent_id, str) and metadata_agent_id: + return metadata_agent_id + + return None + + def _make_sync_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: del node_name, callback_handler From 01475795eba8ce1cfa57c6a1d34237467cd2690d Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:23:49 +0800 Subject: [PATCH 10/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Add=20input=20?= =?UTF-8?q?state=20key=20summary=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 7b371de..11d52ee 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -55,6 +55,12 @@ def _extract_agent_id(config: object) -> str | None: return None +def _summarize_state_keys(state: object) -> list[str]: + if not isinstance(state, dict): + return [] + return [str(key) for key in state.keys()] + + def _make_sync_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: del node_name, callback_handler From f7d8958f17f0b94ed2d9a7a107c2a901d61841cb Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:24:01 +0800 Subject: [PATCH 11/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Add=20state=20?= =?UTF-8?q?delta=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 11d52ee..17771d5 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -61,6 +61,27 @@ def _summarize_state_keys(state: object) -> list[str]: return [str(key) for key in state.keys()] +def _compute_state_delta(previous_state: object, next_state: object) -> dict[str, object]: + if not isinstance(previous_state, dict) or not isinstance(next_state, dict): + return {"changed_keys": [], "new_values": {}, "removed_keys": []} + + changed_keys: list[str] = [] + new_values: dict[str, object] = {} + for key, value in next_state.items(): + if key not in previous_state or previous_state[key] != value: + key_str = str(key) + changed_keys.append(key_str) + new_values[key_str] = value + + removed_keys = [str(key) for key in previous_state.keys() if key not in next_state] + + return { + "changed_keys": changed_keys, + "new_values": new_values, + "removed_keys": removed_keys, + } + + def _make_sync_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: del node_name, callback_handler From e77f26f36ac9581dfb04d647ba5900c8a9e3c340 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:24:19 +0800 Subject: [PATCH 12/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Record=20pre-n?= =?UTF-8?q?ode=20entry=20payload=20in=20wrappers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 31 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 17771d5..28cd81e 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -32,6 +32,14 @@ def _extract_state(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: return kwargs.get("state") +def _extract_config(args: tuple[Any, ...], kwargs: dict[str, Any]) -> object: + if "config" in kwargs: + return kwargs["config"] + if len(args) >= 2: + return args[1] + return None + + def _extract_agent_id(config: object) -> str | None: if not isinstance(config, dict): return None @@ -84,7 +92,9 @@ def _compute_state_delta(previous_state: object, next_state: object) -> dict[str def _make_sync_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: - del node_name, callback_handler + state = _extract_state(node_args, node_kwargs) + config = _extract_config(node_args, node_kwargs) + _record_node_enter(callback_handler, node_name=node_name, state=state, config=config) return original_func(*node_args, **node_kwargs) return wrapped_node @@ -92,12 +102,29 @@ def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: def _make_async_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: async def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: - del node_name, callback_handler + state = _extract_state(node_args, node_kwargs) + config = _extract_config(node_args, node_kwargs) + _record_node_enter(callback_handler, node_name=node_name, state=state, config=config) return await original_func(*node_args, **node_kwargs) return wrapped_node +def _record_node_enter(callback_handler: Any, *, node_name: str, state: object, config: object) -> None: + method = getattr(callback_handler, "on_graph_node_start", None) + if not callable(method): + return None + + method( + node_name=node_name, + agent_id=_extract_agent_id(config), + state=state, + state_keys=_summarize_state_keys(state), + config=config, + ) + return None + + def _discover_compiled_graph_node_maps(compiled_graph: Any) -> list[Any]: candidate_maps = [ getattr(compiled_graph, "nodes", None), From 73622cab36555247a37b1d0e8cec87c5e1a0d5b1 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:24:36 +0800 Subject: [PATCH 13/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Record=20post-?= =?UTF-8?q?node=20exit=20payload=20with=20state=20delta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 43 +++++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 28cd81e..a63e4b7 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -95,7 +95,15 @@ def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: state = _extract_state(node_args, node_kwargs) config = _extract_config(node_args, node_kwargs) _record_node_enter(callback_handler, node_name=node_name, state=state, config=config) - return original_func(*node_args, **node_kwargs) + result = original_func(*node_args, **node_kwargs) + _record_node_exit( + callback_handler, + node_name=node_name, + previous_state=state, + next_state=result, + config=config, + ) + return result return wrapped_node @@ -105,7 +113,15 @@ async def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: state = _extract_state(node_args, node_kwargs) config = _extract_config(node_args, node_kwargs) _record_node_enter(callback_handler, node_name=node_name, state=state, config=config) - return await original_func(*node_args, **node_kwargs) + result = await original_func(*node_args, **node_kwargs) + _record_node_exit( + callback_handler, + node_name=node_name, + previous_state=state, + next_state=result, + config=config, + ) + return result return wrapped_node @@ -125,6 +141,29 @@ def _record_node_enter(callback_handler: Any, *, node_name: str, state: object, return None +def _record_node_exit( + callback_handler: Any, + *, + node_name: str, + previous_state: object, + next_state: object, + config: object, +) -> None: + method = getattr(callback_handler, "on_graph_node_end", None) + if not callable(method): + return None + + method( + node_name=node_name, + agent_id=_extract_agent_id(config), + state=previous_state, + result=next_state, + state_delta=_compute_state_delta(previous_state, next_state), + config=config, + ) + return None + + def _discover_compiled_graph_node_maps(compiled_graph: Any) -> list[Any]: candidate_maps = [ getattr(compiled_graph, "nodes", None), From 3cc73e81bc144c13e3f339f626908f33de09ae43 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:24:59 +0800 Subject: [PATCH 14/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Select=20sync?= =?UTF-8?q?=20or=20async=20wrappers=20by=20node=20callable=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index a63e4b7..4f76a33 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -4,10 +4,13 @@ from dataclasses import dataclass import importlib +import inspect from typing import Any _PATCHED_FLAG = "_agent_assembly_compile_patched" _ORIGINAL_COMPILE = "_agent_assembly_original_compile" +_NODE_WRAPPED_FLAG = "_agent_assembly_node_wrapped" +_INVOKE_WRAPPED_FLAG = "_agent_assembly_invoke_wrapped" @dataclass(slots=True) @@ -126,6 +129,19 @@ async def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: return wrapped_node +def _make_assembly_node_wrapper(node_name: str, original_func: Any, callback_handler: Any) -> Any: + if getattr(original_func, _NODE_WRAPPED_FLAG, False): + return original_func + + if inspect.iscoroutinefunction(original_func): + wrapped = _make_async_node_wrapper(node_name, original_func, callback_handler) + else: + wrapped = _make_sync_node_wrapper(node_name, original_func, callback_handler) + + setattr(wrapped, _NODE_WRAPPED_FLAG, True) + return wrapped + + def _record_node_enter(callback_handler: Any, *, node_name: str, state: object, config: object) -> None: method = getattr(callback_handler, "on_graph_node_start", None) if not callable(method): From 4dde672a74df367454e548a715b4dfd98b488bd0 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:25:13 +0800 Subject: [PATCH 15/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Wrap=20discove?= =?UTF-8?q?red=20compiled=20graph=20node=20executors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 4f76a33..54124de 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -142,6 +142,49 @@ def _make_assembly_node_wrapper(node_name: str, original_func: Any, callback_han return wrapped +def _wrap_node_map(node_map: Any, callback_handler: Any) -> bool: + items_method = getattr(node_map, "items", None) + if not callable(items_method): + return False + + wrapped_any = False + for node_name, node_executor in list(items_method()): + if callable(node_executor): + wrapped_executor = _make_assembly_node_wrapper( + str(node_name), node_executor, callback_handler + ) + if wrapped_executor is node_executor: + continue + try: + node_map[node_name] = wrapped_executor + except Exception: + continue + wrapped_any = True + continue + + invoke = getattr(node_executor, "invoke", None) + if callable(invoke): + wrapped_invoke = _make_assembly_node_wrapper(str(node_name), invoke, callback_handler) + setattr(node_executor, "invoke", wrapped_invoke) + wrapped_any = True + + ainvoke = getattr(node_executor, "ainvoke", None) + if callable(ainvoke): + wrapped_ainvoke = _make_assembly_node_wrapper(str(node_name), ainvoke, callback_handler) + setattr(node_executor, "ainvoke", wrapped_ainvoke) + wrapped_any = True + + return wrapped_any + + +def _wrap_compiled_graph_nodes(compiled_graph: Any, callback_handler: Any) -> bool: + wrapped_any = False + for node_map in _discover_compiled_graph_node_maps(compiled_graph): + if _wrap_node_map(node_map, callback_handler): + wrapped_any = True + return wrapped_any + + def _record_node_enter(callback_handler: Any, *, node_name: str, state: object, config: object) -> None: method = getattr(callback_handler, "on_graph_node_start", None) if not callable(method): From 1e46da39cbff12e23630bf1a3831ebd06e2964ee Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:25:27 +0800 Subject: [PATCH 16/32] =?UTF-8?q?=E2=9C=A8=20(langgraph):=20Patch=20StateG?= =?UTF-8?q?raph.compile=20to=20wrap=20nodes=20and=20invoke=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 54124de..f515fd3 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -26,6 +26,7 @@ def apply(self) -> bool: return False if getattr(state_graph_cls, _PATCHED_FLAG, False): return True + _apply_stategraph_compile_patch(state_graph_cls, self.callback_handler) return True @@ -185,6 +186,59 @@ def _wrap_compiled_graph_nodes(compiled_graph: Any, callback_handler: Any) -> bo return wrapped_any +def _apply_stategraph_compile_patch(state_graph_cls: type[Any], callback_handler: Any) -> None: + original_compile = state_graph_cls.compile + + def patched_compile(self: Any, *args: Any, **kwargs: Any) -> Any: + compiled_graph = original_compile(self, *args, **kwargs) + nodes_wrapped = _wrap_compiled_graph_nodes(compiled_graph, callback_handler) + if not nodes_wrapped: + _wrap_graph_invoke_fallback(compiled_graph, callback_handler) + return compiled_graph + + setattr(state_graph_cls, _ORIGINAL_COMPILE, original_compile) + setattr(state_graph_cls, "compile", patched_compile) + setattr(state_graph_cls, _PATCHED_FLAG, True) + + +def _wrap_graph_invoke_fallback(compiled_graph: Any, callback_handler: Any) -> None: + invoke = getattr(compiled_graph, "invoke", None) + if not callable(invoke) or getattr(invoke, _INVOKE_WRAPPED_FLAG, False): + return None + + if inspect.iscoroutinefunction(invoke): + async def wrapped_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: + state = _extract_state(invoke_args, invoke_kwargs) + config = _extract_config(invoke_args, invoke_kwargs) + _record_node_enter(callback_handler, node_name="graph.invoke", state=state, config=config) + result = await invoke(*invoke_args, **invoke_kwargs) + _record_node_exit( + callback_handler, + node_name="graph.invoke", + previous_state=state, + next_state=result, + config=config, + ) + return result + else: + def wrapped_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: + state = _extract_state(invoke_args, invoke_kwargs) + config = _extract_config(invoke_args, invoke_kwargs) + _record_node_enter(callback_handler, node_name="graph.invoke", state=state, config=config) + result = invoke(*invoke_args, **invoke_kwargs) + _record_node_exit( + callback_handler, + node_name="graph.invoke", + previous_state=state, + next_state=result, + config=config, + ) + return result + + setattr(wrapped_invoke, _INVOKE_WRAPPED_FLAG, True) + setattr(compiled_graph, "invoke", wrapped_invoke) + + def _record_node_enter(callback_handler: Any, *, node_name: str, state: object, config: object) -> None: method = getattr(callback_handler, "on_graph_node_start", None) if not callable(method): From b081ff6511971d4de0a1ef8cb3aa67f9c50d8421 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:25:37 +0800 Subject: [PATCH 17/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Export?= =?UTF-8?q?=20LangGraphPatch=20from=20langgraph=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent_assembly/adapters/langgraph/__init__.py b/agent_assembly/adapters/langgraph/__init__.py index be148d8..6f7f2db 100644 --- a/agent_assembly/adapters/langgraph/__init__.py +++ b/agent_assembly/adapters/langgraph/__init__.py @@ -1 +1,5 @@ """LangGraph adapter package.""" + +from agent_assembly.adapters.langgraph.patch import LangGraphPatch + +__all__ = ["LangGraphPatch"] From 97bf77ccea1dbe50b51db0f963a51a0e9b24cddd Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:25:51 +0800 Subject: [PATCH 18/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Add=20?= =?UTF-8?q?compatibility=20patch=5Fstategraph=5Fcompile=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index f515fd3..e6a9945 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -30,6 +30,11 @@ def apply(self) -> bool: return True +def patch_stategraph_compile(callback_handler: Any) -> bool: + """Backward-compatible helper used by existing runtime wiring.""" + return LangGraphPatch(callback_handler=callback_handler).apply() + + def _extract_state(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: if args: return args[0] From 7b807e4d3ea2aabbe5b5c42d12509c7dc37b7091 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:26:06 +0800 Subject: [PATCH 19/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(runtime):=20Switch?= =?UTF-8?q?=20LangGraph=20integration=20to=20LangGraphPatch.apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langchain/runtime.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agent_assembly/adapters/langchain/runtime.py b/agent_assembly/adapters/langchain/runtime.py index 81c2e1a..50bb20f 100644 --- a/agent_assembly/adapters/langchain/runtime.py +++ b/agent_assembly/adapters/langchain/runtime.py @@ -6,7 +6,7 @@ from typing import Any from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler -from agent_assembly.adapters.langchain.langgraph_patch import patch_stategraph_compile +from agent_assembly.adapters.langgraph import LangGraphPatch _ACTIVE_CALLBACK_HANDLER: AssemblyCallbackHandler | None = None _RUNTIME_LOCK = Lock() @@ -18,12 +18,12 @@ def auto_inject_callback_handler(interceptor: Any) -> AssemblyCallbackHandler: with _RUNTIME_LOCK: if _ACTIVE_CALLBACK_HANDLER is not None: - patch_stategraph_compile(_ACTIVE_CALLBACK_HANDLER) + LangGraphPatch(_ACTIVE_CALLBACK_HANDLER).apply() return _ACTIVE_CALLBACK_HANDLER handler = AssemblyCallbackHandler(interceptor) _ACTIVE_CALLBACK_HANDLER = handler - patch_stategraph_compile(handler) + LangGraphPatch(handler).apply() return handler From 77814d79c8fa6b8170abf162df209103fa61043e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:26:24 +0800 Subject: [PATCH 20/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Keep?= =?UTF-8?q?=20langchain=20langgraph=5Fpatch=20imports=20via=20compatibilit?= =?UTF-8?q?y=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/langchain/langgraph_patch.py | 201 +----------------- 1 file changed, 2 insertions(+), 199 deletions(-) diff --git a/agent_assembly/adapters/langchain/langgraph_patch.py b/agent_assembly/adapters/langchain/langgraph_patch.py index 80da5d8..3618aa6 100644 --- a/agent_assembly/adapters/langchain/langgraph_patch.py +++ b/agent_assembly/adapters/langchain/langgraph_patch.py @@ -1,200 +1,3 @@ -"""LangGraph compile-time patching for governance interception.""" +"""Backward-compatible shim for LangGraph patch utilities.""" -from __future__ import annotations - -import importlib -import inspect -from typing import Any - -_PATCHED_FLAG = "_agent_assembly_compile_patched" -_ORIGINAL_COMPILE = "_agent_assembly_original_compile" -_NODE_WRAPPED_FLAG = "_agent_assembly_node_wrapped" -_INVOKE_WRAPPED_FLAG = "_agent_assembly_invoke_wrapped" - - -def _extract_state(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: - if args: - return args[0] - return kwargs.get("state") - - -def _invoke_pre_node_hook(callback_handler: Any, node_name: str, state: Any) -> None: - method = getattr(callback_handler, "on_graph_node_start", None) - if not callable(method): - return None - - result = method(node_name=node_name, state=state) - if inspect.isawaitable(result): - return None - - return None - - -def _invoke_post_node_hook(callback_handler: Any, node_name: str, state: Any, result: Any) -> None: - method = getattr(callback_handler, "on_graph_node_end", None) - if not callable(method): - return None - - callback_result = method(node_name=node_name, state=state, result=result) - if inspect.isawaitable(callback_result): - return None - - return None - - -def _wrap_node_callable(node_name: str, node_callable: Any, callback_handler: Any) -> Any: - if getattr(node_callable, _NODE_WRAPPED_FLAG, False): - return node_callable - - def wrapped_node(*node_args: Any, **node_kwargs: Any) -> Any: - state = _extract_state(node_args, node_kwargs) - _invoke_pre_node_hook(callback_handler, node_name=node_name, state=state) - - node_result = node_callable(*node_args, **node_kwargs) - if inspect.isawaitable(node_result): - async def awaited_node_result() -> Any: - resolved_result = await node_result - _invoke_post_node_hook( - callback_handler, - node_name=node_name, - state=state, - result=resolved_result, - ) - return resolved_result - - return awaited_node_result() - - _invoke_post_node_hook( - callback_handler, - node_name=node_name, - state=state, - result=node_result, - ) - return node_result - - setattr(wrapped_node, _NODE_WRAPPED_FLAG, True) - return wrapped_node - - -def _wrap_node_map(node_map: Any, callback_handler: Any) -> bool: - items_method = getattr(node_map, "items", None) - if not callable(items_method): - return False - - wrapped_any = False - for node_name, node_executor in list(items_method()): - if callable(node_executor): - wrapped_executor = _wrap_node_callable(str(node_name), node_executor, callback_handler) - if wrapped_executor is node_executor: - continue - try: - node_map[node_name] = wrapped_executor - except Exception: - continue - wrapped_any = True - continue - - invoke = getattr(node_executor, "invoke", None) - if callable(invoke): - setattr( - node_executor, - "invoke", - _wrap_node_callable(str(node_name), invoke, callback_handler), - ) - wrapped_any = True - - ainvoke = getattr(node_executor, "ainvoke", None) - if callable(ainvoke): - setattr( - node_executor, - "ainvoke", - _wrap_node_callable(str(node_name), ainvoke, callback_handler), - ) - wrapped_any = True - - return wrapped_any - - -def _wrap_compiled_graph_nodes(compiled_graph: Any, callback_handler: Any) -> bool: - candidate_maps = [ - getattr(compiled_graph, "nodes", None), - getattr(compiled_graph, "_nodes", None), - ] - - pregel = getattr(compiled_graph, "pregel", None) - if pregel is None: - pregel = getattr(compiled_graph, "_pregel", None) - if pregel is not None: - candidate_maps.extend( - [ - getattr(pregel, "nodes", None), - getattr(pregel, "_nodes", None), - ] - ) - - wrapped_any = False - for node_map in candidate_maps: - if node_map is None: - continue - if _wrap_node_map(node_map, callback_handler): - wrapped_any = True - - return wrapped_any - - -def patch_stategraph_compile(callback_handler: Any) -> bool: - """Patch `StateGraph.compile()` to attach runtime governance hooks.""" - try: - module = importlib.import_module("langgraph.graph.state") - except ImportError: - return False - - state_graph_cls = getattr(module, "StateGraph", None) - if state_graph_cls is None: - return False - - if getattr(state_graph_cls, _PATCHED_FLAG, False): - return True - - original_compile = state_graph_cls.compile - - def patched_compile(self: Any, *args: Any, **kwargs: Any) -> Any: - compiled_graph = original_compile(self, *args, **kwargs) - nodes_wrapped = _wrap_compiled_graph_nodes(compiled_graph, callback_handler) - if not nodes_wrapped: - invoke = getattr(compiled_graph, "invoke", None) - if callable(invoke) and not getattr(invoke, _INVOKE_WRAPPED_FLAG, False): - def wrapped_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: - state = _extract_state(invoke_args, invoke_kwargs) - _invoke_pre_node_hook(callback_handler, node_name="graph.invoke", state=state) - - invoke_result = invoke(*invoke_args, **invoke_kwargs) - if inspect.isawaitable(invoke_result): - async def awaited_invoke_result() -> Any: - resolved_result = await invoke_result - _invoke_post_node_hook( - callback_handler, - node_name="graph.invoke", - state=state, - result=resolved_result, - ) - return resolved_result - - return awaited_invoke_result() - - _invoke_post_node_hook( - callback_handler, - node_name="graph.invoke", - state=state, - result=invoke_result, - ) - return invoke_result - - setattr(wrapped_invoke, _INVOKE_WRAPPED_FLAG, True) - setattr(compiled_graph, "invoke", wrapped_invoke) - return compiled_graph - - setattr(state_graph_cls, _ORIGINAL_COMPILE, original_compile) - setattr(state_graph_cls, "compile", patched_compile) - setattr(state_graph_cls, _PATCHED_FLAG, True) - return True +from agent_assembly.adapters.langgraph.patch import * # noqa: F403 From eff42bb4416e619892adf8bb2be2a586a0623a01 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:26:41 +0800 Subject: [PATCH 21/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Re-exp?= =?UTF-8?q?ort=20LangGraphPatch=20from=20langchain=20adapter=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langchain/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agent_assembly/adapters/langchain/__init__.py b/agent_assembly/adapters/langchain/__init__.py index b2a365f..fa6cf78 100644 --- a/agent_assembly/adapters/langchain/__init__.py +++ b/agent_assembly/adapters/langchain/__init__.py @@ -1,6 +1,7 @@ """LangChain adapter package.""" from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler +from agent_assembly.adapters.langgraph import LangGraphPatch from agent_assembly.adapters.langchain.langgraph_patch import patch_stategraph_compile from agent_assembly.adapters.langchain.runtime import ( auto_inject_callback_handler, @@ -9,6 +10,7 @@ __all__ = [ "AssemblyCallbackHandler", + "LangGraphPatch", "patch_stategraph_compile", "auto_inject_callback_handler", "get_active_callback_handler", From 52bf677d81bb98db84ecd183183dfff06156989d Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:27:56 +0800 Subject: [PATCH 22/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Re-exp?= =?UTF-8?q?ort=20private=20LangGraph=20patch=20symbols=20in=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/langchain/langgraph_patch.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/langchain/langgraph_patch.py b/agent_assembly/adapters/langchain/langgraph_patch.py index 3618aa6..9c3e05a 100644 --- a/agent_assembly/adapters/langchain/langgraph_patch.py +++ b/agent_assembly/adapters/langchain/langgraph_patch.py @@ -1,3 +1,29 @@ """Backward-compatible shim for LangGraph patch utilities.""" -from agent_assembly.adapters.langgraph.patch import * # noqa: F403 +from agent_assembly.adapters.langgraph import patch as _impl + +LangGraphPatch = _impl.LangGraphPatch +patch_stategraph_compile = _impl.patch_stategraph_compile + +_PATCHED_FLAG = _impl._PATCHED_FLAG +_ORIGINAL_COMPILE = _impl._ORIGINAL_COMPILE +_NODE_WRAPPED_FLAG = _impl._NODE_WRAPPED_FLAG +_INVOKE_WRAPPED_FLAG = _impl._INVOKE_WRAPPED_FLAG + +_extract_state = _impl._extract_state +_extract_config = _impl._extract_config +_extract_agent_id = _impl._extract_agent_id +_summarize_state_keys = _impl._summarize_state_keys +_compute_state_delta = _impl._compute_state_delta +_make_sync_node_wrapper = _impl._make_sync_node_wrapper +_make_async_node_wrapper = _impl._make_async_node_wrapper +_make_assembly_node_wrapper = _impl._make_assembly_node_wrapper +_wrap_node_map = _impl._wrap_node_map +_wrap_compiled_graph_nodes = _impl._wrap_compiled_graph_nodes +_discover_compiled_graph_node_maps = _impl._discover_compiled_graph_node_maps +_apply_stategraph_compile_patch = _impl._apply_stategraph_compile_patch +_wrap_graph_invoke_fallback = _impl._wrap_graph_invoke_fallback +_record_node_enter = _impl._record_node_enter +_record_node_exit = _impl._record_node_exit +_load_stategraph_class = _impl._load_stategraph_class +importlib = _impl.importlib From d7121562adaa3ff04603387d39a350bb0544b6df Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:28:34 +0800 Subject: [PATCH 23/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(langgraph):=20Fallb?= =?UTF-8?q?ack=20graph=20hook=20invocation=20for=20legacy=20signatures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 38 +++++++++++++--------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index e6a9945..f2e448d 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -249,13 +249,17 @@ def _record_node_enter(callback_handler: Any, *, node_name: str, state: object, if not callable(method): return None - method( - node_name=node_name, - agent_id=_extract_agent_id(config), - state=state, - state_keys=_summarize_state_keys(state), - config=config, - ) + hook_kwargs = { + "node_name": node_name, + "agent_id": _extract_agent_id(config), + "state": state, + "state_keys": _summarize_state_keys(state), + "config": config, + } + try: + method(**hook_kwargs) + except TypeError: + method(node_name=node_name, state=state) return None @@ -271,14 +275,18 @@ def _record_node_exit( if not callable(method): return None - method( - node_name=node_name, - agent_id=_extract_agent_id(config), - state=previous_state, - result=next_state, - state_delta=_compute_state_delta(previous_state, next_state), - config=config, - ) + hook_kwargs = { + "node_name": node_name, + "agent_id": _extract_agent_id(config), + "state": previous_state, + "result": next_state, + "state_delta": _compute_state_delta(previous_state, next_state), + "config": config, + } + try: + method(**hook_kwargs) + except TypeError: + method(node_name=node_name, state=previous_state, result=next_state) return None From b6f765ca845140e268020161b43672b307e1bb7e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:28:45 +0800 Subject: [PATCH 24/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(langgraph):=20Resto?= =?UTF-8?q?re=20legacy=20helper=20aliases=20for=20shim=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/langchain/langgraph_patch.py | 3 ++ agent_assembly/adapters/langgraph/patch.py | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/agent_assembly/adapters/langchain/langgraph_patch.py b/agent_assembly/adapters/langchain/langgraph_patch.py index 9c3e05a..8bd2808 100644 --- a/agent_assembly/adapters/langchain/langgraph_patch.py +++ b/agent_assembly/adapters/langchain/langgraph_patch.py @@ -15,6 +15,9 @@ _extract_agent_id = _impl._extract_agent_id _summarize_state_keys = _impl._summarize_state_keys _compute_state_delta = _impl._compute_state_delta +_invoke_pre_node_hook = _impl._invoke_pre_node_hook +_invoke_post_node_hook = _impl._invoke_post_node_hook +_wrap_node_callable = _impl._wrap_node_callable _make_sync_node_wrapper = _impl._make_sync_node_wrapper _make_async_node_wrapper = _impl._make_async_node_wrapper _make_assembly_node_wrapper = _impl._make_assembly_node_wrapper diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index f2e448d..6ac1e7e 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -35,6 +35,39 @@ def patch_stategraph_compile(callback_handler: Any) -> bool: return LangGraphPatch(callback_handler=callback_handler).apply() +def _invoke_pre_node_hook(callback_handler: Any, node_name: str, state: object) -> None: + """Backward-compatible pre-node hook helper.""" + _record_node_enter( + callback_handler, + node_name=node_name, + state=state, + config=None, + ) + return None + + +def _invoke_post_node_hook( + callback_handler: Any, + node_name: str, + state: object, + result: object, +) -> None: + """Backward-compatible post-node hook helper.""" + _record_node_exit( + callback_handler, + node_name=node_name, + previous_state=state, + next_state=result, + config=None, + ) + return None + + +def _wrap_node_callable(node_name: str, node_func: Any, callback_handler: Any) -> Any: + """Backward-compatible node wrapper helper.""" + return _make_assembly_node_wrapper(node_name, node_func, callback_handler) + + def _extract_state(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: if args: return args[0] From 24f8f0153d96586db4139c30a64edcfd1bf131cc Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:28:52 +0800 Subject: [PATCH 25/32] =?UTF-8?q?=E2=9C=A8=20(callbacks):=20Accept=20enric?= =?UTF-8?q?hed=20LangGraph=20node=20hook=20metadata=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/langchain/callback_handler.py | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/agent_assembly/adapters/langchain/callback_handler.py b/agent_assembly/adapters/langchain/callback_handler.py index 751feec..b015224 100644 --- a/agent_assembly/adapters/langchain/callback_handler.py +++ b/agent_assembly/adapters/langchain/callback_handler.py @@ -277,16 +277,46 @@ async def aon_llm_end( await result return None - def on_graph_node_start(self, node_name: str, state: Any) -> None: + def on_graph_node_start( + self, + node_name: str, + state: Any, + *, + agent_id: str | None = None, + state_keys: list[str] | None = None, + config: Any = None, + ) -> None: method = getattr(self._interceptor, "on_graph_node_start", None) if not callable(method): return None - method(node_name=node_name, state=state) + method( + node_name=node_name, + agent_id=agent_id, + state=state, + state_keys=state_keys, + config=config, + ) return None - def on_graph_node_end(self, node_name: str, state: Any, result: Any) -> None: + def on_graph_node_end( + self, + node_name: str, + state: Any, + result: Any, + *, + agent_id: str | None = None, + state_delta: dict[str, Any] | None = None, + config: Any = None, + ) -> 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) + method( + node_name=node_name, + agent_id=agent_id, + state=state, + result=result, + state_delta=state_delta, + config=config, + ) return None From cd7639712617908fdef442a5c0c58f3bad842a18 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:29:23 +0800 Subject: [PATCH 26/32] =?UTF-8?q?=E2=9C=85=20(langgraph):=20Cover=20idempo?= =?UTF-8?q?tent=20apply=20and=20node=20metadata=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../langchain/test_langgraph_patch.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/unit/adapters/langchain/test_langgraph_patch.py b/test/unit/adapters/langchain/test_langgraph_patch.py index 2c735e6..c16af4a 100644 --- a/test/unit/adapters/langchain/test_langgraph_patch.py +++ b/test/unit/adapters/langchain/test_langgraph_patch.py @@ -206,6 +206,70 @@ def compile(self) -> FallbackCompiledGraph: ] +def test_patch_stategraph_compile_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeStateGraph: + def compile(self) -> object: + return object() + + monkeypatch.setattr( + "agent_assembly.adapters.langchain.langgraph_patch.importlib.import_module", + lambda name: SimpleNamespace(StateGraph=FakeStateGraph), + ) + + assert langgraph_patch.patch_stategraph_compile(GraphEventRecorder()) is True + patched_compile = FakeStateGraph.compile + assert langgraph_patch.patch_stategraph_compile(GraphEventRecorder()) is True + assert FakeStateGraph.compile is patched_compile + + +def test_wrap_node_callable_records_metadata_and_preserves_config_passthrough() -> None: + captured_events: list[tuple[str, dict[str, object]]] = [] + captured_configs: list[object] = [] + + class Recorder: + def on_graph_node_start(self, **kwargs: object) -> None: + captured_events.append(("start", dict(kwargs))) + + def on_graph_node_end(self, **kwargs: object) -> None: + captured_events.append(("end", dict(kwargs))) + + def node(state: dict[str, object], config: object) -> dict[str, object]: + captured_configs.append(config) + return {**state, "node_done": True} + + wrapped = langgraph_patch._wrap_node_callable("node_x", node, Recorder()) + config = {"configurable": {"agent_id": "agent-007"}} + result = wrapped({"step": "run"}, config) + + assert result == {"step": "run", "node_done": True} + assert captured_configs == [config] + assert captured_events[0] == ( + "start", + { + "node_name": "node_x", + "agent_id": "agent-007", + "state": {"step": "run"}, + "state_keys": ["step"], + "config": config, + }, + ) + assert captured_events[1] == ( + "end", + { + "node_name": "node_x", + "agent_id": "agent-007", + "state": {"step": "run"}, + "result": {"step": "run", "node_done": True}, + "state_delta": { + "changed_keys": ["node_done"], + "new_values": {"node_done": True}, + "removed_keys": [], + }, + "config": config, + }, + ) + + @pytest.mark.asyncio async def test_patch_stategraph_compile_fallback_wraps_async_invoke( monkeypatch: pytest.MonkeyPatch, From 1fa1b43ee9eae68e5c168837ec004ab6579ea791 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:29:39 +0800 Subject: [PATCH 27/32] =?UTF-8?q?=E2=9C=85=20(integration):=20Verify=20dow?= =?UTF-8?q?nstream=20nodes=20continue=20after=20handled=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...test_langgraph_interception_integration.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/test/integration/test_langgraph_interception_integration.py b/test/integration/test_langgraph_interception_integration.py index 606af97..c90f185 100644 --- a/test/integration/test_langgraph_interception_integration.py +++ b/test/integration/test_langgraph_interception_integration.py @@ -90,3 +90,73 @@ def fake_import_module(module_name: str) -> object: "end:node_a", "start:node_b", ] + + +@pytest.mark.integration +def test_langgraph_compile_patch_allows_downstream_node_after_blocked_tool_handled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + interceptor = GraphInterceptor() + handler = AssemblyCallbackHandler(interceptor) + + class FakeCompiledGraph: + def __init__(self) -> None: + self.nodes = { + "node_a": self._node_a, + "node_b": self._node_b, + "node_c": self._node_c, + } + + def _node_a(self, state: dict[str, object]) -> dict[str, object]: + return {**state, "node_a": "ok"} + + def _node_b(self, state: dict[str, object]) -> dict[str, object]: + try: + handler.on_tool_start( + serialized={"name": "blocked_tool"}, + input_str="{}", + run_id=uuid4(), + ) + except ToolExecutionBlockedError: + return {**state, "node_b": "blocked"} + return {**state, "node_b": "unexpected-allow"} + + def _node_c(self, state: dict[str, object]) -> dict[str, object]: + return {**state, "node_c": "ok"} + + def invoke(self, state: dict[str, object]) -> dict[str, object]: + current_state = state + for node_name in ("node_a", "node_b", "node_c"): + current_state = self.nodes[node_name](current_state) + return current_state + + class FakeStateGraph: + def compile(self) -> FakeCompiledGraph: + return FakeCompiledGraph() + + fake_module = SimpleNamespace(StateGraph=FakeStateGraph) + monkeypatch.setattr( + "agent_assembly.adapters.langchain.langgraph_patch.importlib.import_module", + lambda module_name: fake_module + if module_name == "langgraph.graph.state" + else (_ for _ in ()).throw(ImportError(module_name)), + ) + + assert patch_stategraph_compile(handler) is True + compiled = FakeStateGraph().compile() + result = compiled.invoke({"step": "run"}) + + assert result == { + "step": "run", + "node_a": "ok", + "node_b": "blocked", + "node_c": "ok", + } + assert interceptor.events == [ + "start:node_a", + "end:node_a", + "start:node_b", + "end:node_b", + "start:node_c", + "end:node_c", + ] From fa55b3370f10aeda1f622f0547e288da00e43032 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:30:53 +0800 Subject: [PATCH 28/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(langgraph):=20Split?= =?UTF-8?q?=20async=20and=20sync=20fallback=20wrappers=20for=20mypy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 6ac1e7e..7c560fa 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -245,7 +245,7 @@ def _wrap_graph_invoke_fallback(compiled_graph: Any, callback_handler: Any) -> N return None if inspect.iscoroutinefunction(invoke): - async def wrapped_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: + async def wrapped_async_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: state = _extract_state(invoke_args, invoke_kwargs) config = _extract_config(invoke_args, invoke_kwargs) _record_node_enter(callback_handler, node_name="graph.invoke", state=state, config=config) @@ -258,8 +258,9 @@ async def wrapped_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: config=config, ) return result + wrapped_invoke: Any = wrapped_async_invoke else: - def wrapped_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: + def wrapped_sync_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: state = _extract_state(invoke_args, invoke_kwargs) config = _extract_config(invoke_args, invoke_kwargs) _record_node_enter(callback_handler, node_name="graph.invoke", state=state, config=config) @@ -272,6 +273,7 @@ def wrapped_invoke(*invoke_args: Any, **invoke_kwargs: Any) -> Any: config=config, ) return result + wrapped_invoke = wrapped_sync_invoke setattr(wrapped_invoke, _INVOKE_WRAPPED_FLAG, True) setattr(compiled_graph, "invoke", wrapped_invoke) From 2c2a563c799efea9352e8948431cf2fd17816ad3 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 20:31:00 +0800 Subject: [PATCH 29/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Import?= =?UTF-8?q?=20stdlib=20importlib=20in=20langgraph=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langchain/langgraph_patch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/langchain/langgraph_patch.py b/agent_assembly/adapters/langchain/langgraph_patch.py index 8bd2808..161995c 100644 --- a/agent_assembly/adapters/langchain/langgraph_patch.py +++ b/agent_assembly/adapters/langchain/langgraph_patch.py @@ -1,5 +1,7 @@ """Backward-compatible shim for LangGraph patch utilities.""" +import importlib + from agent_assembly.adapters.langgraph import patch as _impl LangGraphPatch = _impl.LangGraphPatch @@ -29,4 +31,3 @@ _record_node_enter = _impl._record_node_enter _record_node_exit = _impl._record_node_exit _load_stategraph_class = _impl._load_stategraph_class -importlib = _impl.importlib From 9c2f0fc5180c2fa02dc4912263f61a0706d2fadc Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 21:03:05 +0800 Subject: [PATCH 30/32] =?UTF-8?q?=F0=9F=90=9B=20(langgraph):=20Re-raise=20?= =?UTF-8?q?non-signature=20TypeError=20in=20node=20hook=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langgraph/patch.py | 21 +++++++++++++-- .../langchain/test_langgraph_patch.py | 27 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/langgraph/patch.py b/agent_assembly/adapters/langgraph/patch.py index 7c560fa..51aa7db 100644 --- a/agent_assembly/adapters/langgraph/patch.py +++ b/agent_assembly/adapters/langgraph/patch.py @@ -293,7 +293,9 @@ def _record_node_enter(callback_handler: Any, *, node_name: str, state: object, } try: method(**hook_kwargs) - except TypeError: + except TypeError as exc: + if not _is_call_signature_type_error(exc): + raise method(node_name=node_name, state=state) return None @@ -320,11 +322,26 @@ def _record_node_exit( } try: method(**hook_kwargs) - except TypeError: + except TypeError as exc: + if not _is_call_signature_type_error(exc): + raise method(node_name=node_name, state=previous_state, result=next_state) return None +def _is_call_signature_type_error(error: TypeError) -> bool: + message = str(error) + signature_markers = ( + "unexpected keyword argument", + "missing 1 required positional argument", + "missing required positional argument", + "takes ", + "positional arguments but", + "keyword-only argument", + ) + return any(marker in message for marker in signature_markers) + + def _discover_compiled_graph_node_maps(compiled_graph: Any) -> list[Any]: candidate_maps = [ getattr(compiled_graph, "nodes", None), diff --git a/test/unit/adapters/langchain/test_langgraph_patch.py b/test/unit/adapters/langchain/test_langgraph_patch.py index c16af4a..f340fe3 100644 --- a/test/unit/adapters/langchain/test_langgraph_patch.py +++ b/test/unit/adapters/langchain/test_langgraph_patch.py @@ -57,6 +57,33 @@ def test_invoke_hooks_handle_missing_methods_and_awaitables() -> None: ] +def test_invoke_hooks_only_fallback_on_signature_mismatch() -> None: + class SignatureMismatchRecorder: + def __init__(self) -> None: + self.events: list[tuple[str, object]] = [] + + def on_graph_node_start(self, *, node_name: str, state: object) -> None: + self.events.append(("start", state)) + + def on_graph_node_end(self, *, node_name: str, state: object, result: object) -> None: + self.events.append(("end", result)) + + recorder = SignatureMismatchRecorder() + langgraph_patch._invoke_pre_node_hook(recorder, "n3", {"state": 3}) + langgraph_patch._invoke_post_node_hook(recorder, "n3", {"state": 3}, {"result": 3}) + assert recorder.events == [("start", {"state": 3}), ("end", {"result": 3})] + + +def test_invoke_hooks_reraise_internal_typeerror() -> None: + class InternalTypeErrorRecorder: + def on_graph_node_start(self, **kwargs: object) -> None: + del kwargs + raise TypeError("internal callback failure") + + with pytest.raises(TypeError, match="internal callback failure"): + langgraph_patch._invoke_pre_node_hook(InternalTypeErrorRecorder(), "n4", {"state": 4}) + + @pytest.mark.asyncio async def test_wrap_node_callable_handles_already_wrapped_and_async_results() -> None: recorder = GraphEventRecorder() From 687f84c45c2b86947c305958374e78506cfc17c8 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 21:49:18 +0800 Subject: [PATCH 31/32] =?UTF-8?q?=F0=9F=94=A7=20(ci):=20Grant=20Sonar=20PR?= =?UTF-8?q?=20scan=20explicit=20pull-request=20read=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/rw_run_all_test_and_record.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rw_run_all_test_and_record.yaml b/.github/workflows/rw_run_all_test_and_record.yaml index dfeb223..5e3229a 100644 --- a/.github/workflows/rw_run_all_test_and_record.yaml +++ b/.github/workflows/rw_run_all_test_and_record.yaml @@ -19,6 +19,10 @@ on: description: "The API token for uploading testing coverage report to Coveralls." required: true +permissions: + contents: read + pull-requests: read + jobs: build-and-test: # name: Run all tests and organize all test reports @@ -164,6 +168,6 @@ jobs: - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v7.1.0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} SONAR_TOKEN: ${{ secrets.sonar_token }} SONAR_HOST_URL: https://sonarcloud.io From a9c4245c19de87b1bf3a902a28766ccf1248df14 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 21:50:11 +0800 Subject: [PATCH 32/32] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(ci):=20Remove=20uns?= =?UTF-8?q?upported=20reusable-workflow=20permissions=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/rw_run_all_test_and_record.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/rw_run_all_test_and_record.yaml b/.github/workflows/rw_run_all_test_and_record.yaml index 5e3229a..8809e34 100644 --- a/.github/workflows/rw_run_all_test_and_record.yaml +++ b/.github/workflows/rw_run_all_test_and_record.yaml @@ -19,10 +19,6 @@ on: description: "The API token for uploading testing coverage report to Coveralls." required: true -permissions: - contents: read - pull-requests: read - jobs: build-and-test: # name: Run all tests and organize all test reports