Repository navigation
[AAASM-6445] 🐛 (python-sdk): Govern LangChain tool calls from init_assembly() #389
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Chisanan232
merged 16 commits into
main
from
v0.0.1/AAASM-6445/fix/langchain_govern_on_init
Nov 10, 2026
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
1d98b64
🐛 (langchain): Govern tool calls from init_assembly via configure hook
Chisanan232 e03e6e5
✅ (langchain): Cover init-only governance, fail-closed and thread paths
Chisanan232 55a1a12
📝 (python-sdk): Describe how init_assembly() governs LangChain tools
Chisanan232 935299c
🐛 (langchain): Skip the proxy only for a run the active interceptor a…
Chisanan232 19b5d0e
✅ (langchain): Cover weaker handler, deny audit record and ToolNode
Chisanan232 cc172c6
📝 (python-sdk): State the bounds of the LangChain governance gate
Chisanan232 a280b64
🐛 (langchain): Skip the proxy's on_tool_end for runs it skipped at start
Chisanan232 87e0f83
✅ (langchain): Pin single end, swallowing subclass, bound and sentinel
Chisanan232 eb4e95e
📝 (python-sdk): Say a handler is neither checked nor ended twice
Chisanan232 af584d5
✅ (langchain): Catch Exception, not BaseException, in the thread-capt…
Chisanan232 eda34d4
🔀 (python-sdk): Merge main into the branch
Chisanan232 ac78623
Merge remote-tracking branch 'remote/main' into v0.0.1/AAASM-6445/fix…
Chisanan232 ceb948f
✅ (langchain): Pin runtime-failure, idempotent-registration and no-la…
Chisanan232 b8f06ea
Merge remote-tracking branch 'remote/main' into v0.0.1/AAASM-6445/fix…
Chisanan232 3dbcaa5
✅ (langchain): Assert one LangChain registration across repeated init…
Chisanan232 bd80be5
Merge remote-tracking branch 'remote/main' into v0.0.1/AAASM-6445/fix…
Chisanan232 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| """Process-wide LangChain callback registration through a public extension point. | ||
|
|
||
| ``langchain_core`` lets a library contribute a callback handler to every run's | ||
| configuration with ``register_configure_hook(ContextVar, inheritable=True)``. This is | ||
| how LangChain's own tracers are attached, and it is documented API, not patching. | ||
|
|
||
| Two properties of the registration matter for governance: | ||
|
|
||
| * The ``ContextVar`` carries a **default** (a process-wide proxy handler) instead of | ||
| being ``set()`` by ``init_assembly``. A value ``set()`` in one context is invisible to | ||
| a plain ``threading.Thread`` (threads start with an empty context), so a tool run | ||
| there would silently skip the gate. A default is seen from every thread and task. | ||
| * The proxy skips a run only when an explicitly passed ``AssemblyCallbackHandler`` already | ||
| allowed it with the *active* interceptor, so a tool is not checked twice. A handler built | ||
| around a weaker or different interceptor never suppresses the active governance | ||
| (registering with ``handle_class`` would have: LangChain then skips the proxy for any | ||
| ``AssemblyCallbackHandler`` instance). | ||
|
|
||
| The hook cannot be unregistered, so the proxy resolves the active handler on every | ||
| call and does nothing once ``init_assembly``'s context is shut down. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import importlib | ||
| from contextvars import ContextVar | ||
| from threading import Lock | ||
| from typing import Any | ||
|
|
||
| from agent_assembly.adapters.langchain.callback_handler import ( | ||
| AssemblyCallbackHandler, | ||
| consume_checked_run, | ||
| consume_end_skipped, | ||
| mark_end_skipped, | ||
| ) | ||
| from agent_assembly.adapters.langchain.runtime import get_active_callback_handler | ||
|
|
||
|
|
||
| class ProcessGovernanceHandler(AssemblyCallbackHandler): | ||
| """Forwards LangChain tool callbacks to the currently active governance handler. | ||
|
|
||
| ``raise_error`` stays True (inherited): LangChain swallows a callback's exception | ||
| otherwise, so any failure here, including a runtime/gateway error raised by the | ||
| interceptor, aborts the tool call instead of letting it run ungoverned. | ||
| """ | ||
|
|
||
| def __init__(self) -> None: | ||
| super().__init__(interceptor=None) | ||
|
|
||
| def on_tool_start(self, serialized: dict[str, Any], input_str: str, **kwargs: Any) -> None: | ||
| active = get_active_callback_handler() | ||
| if active is None: | ||
| return | ||
| run_id = kwargs.get("run_id") | ||
| if run_id is not None and consume_checked_run(run_id) is active._interceptor: | ||
| mark_end_skipped(run_id) | ||
| return | ||
| active.on_tool_start(serialized, input_str, **kwargs) | ||
|
|
||
| def on_tool_end(self, output: Any, **kwargs: Any) -> None: | ||
| run_id = kwargs.get("run_id") | ||
| if run_id is not None and consume_end_skipped(run_id): | ||
| return | ||
| active = get_active_callback_handler() | ||
| if active is not None: | ||
| active.on_tool_end(output, **kwargs) | ||
|
|
||
| def on_tool_error(self, _error: BaseException, **kwargs: Any) -> None: | ||
| # Only clears the per-run mark so a failed tool does not leak it. | ||
| run_id = kwargs.get("run_id") | ||
| if run_id is not None: | ||
| consume_end_skipped(run_id) | ||
|
|
||
|
|
||
| _REGISTER_LOCK = Lock() | ||
| _registered_var: ContextVar[Any] | None = None | ||
|
|
||
|
|
||
| def ensure_configure_hook_registered() -> bool: | ||
| """Register the process-wide governance handler with LangChain once. | ||
|
|
||
| Returns ``True`` when the hook is in place and ``False`` when ``langchain_core`` | ||
| is not installed (nothing to govern). Safe to call on every ``init_assembly``. | ||
| """ | ||
| global _registered_var | ||
|
|
||
| with _REGISTER_LOCK: | ||
| if _registered_var is not None: | ||
| return True | ||
| try: | ||
| context = importlib.import_module("langchain_core.tracers.context") | ||
| except ImportError: | ||
| return False | ||
| # B039: the default is deliberately a shared mutable handler; a per-context | ||
| # ``set()`` would not reach plain threads (see module docstring). | ||
| var: ContextVar[Any] = ContextVar( | ||
| "agent_assembly_langchain_governance", | ||
| default=ProcessGovernanceHandler(), # noqa: B039 | ||
| ) | ||
| context.register_configure_hook(var, True) | ||
| _registered_var = var | ||
| return True | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.