What went wrong
The SDK enforces BLOCK/HITL policy for async LangGraph tool execution through ToolNode.ainvoke, but the sync ToolNode.invoke patch only injects callbacks and then calls the original tool execution.
That means sync agent runs can execute tools without waiting for a verdict, even when the backend policy is in BLOCK or HITL mode. Async and sync execution should have equivalent policy behaviour.
Relevant code:
sdk/adrian/__init__.py: _patch_tool_node()
patched_invoke injects callbacks and calls original_invoke
patched_ainvoke waits for LoginAck, checks active policy, waits for the tool-call verdict, and can return blocked tool messages
Reproduction steps
- Configure Adrian policy mode as
block or hitl, with M3/M4 in scope.
- Initialise the SDK with WebSocket enabled.
- Build a LangGraph agent or
ToolNode that emits a tool call which receives an in-scope blocking verdict.
- Run the agent using sync execution, for example
graph.invoke(...) or ToolNode.invoke(...).
- Compare with the async path, for example
await graph.ainvoke(...) or await ToolNode.ainvoke(...).
Expected behaviour
Sync ToolNode.invoke should enforce the same BLOCK/HITL policy as async ToolNode.ainvoke.
If the verdict is in scope for the active policy, the tool should not execute and the SDK should return the same blocked tool response shape used by the async path.
Actual behaviour
Sync ToolNode.invoke currently forwards directly to the original tool execution after callback injection. It does not wait for a verdict and does not call the existing halt logic.
As a result, tools can execute in sync agent runs even though the same tool call would be blocked in async execution.
Environment
- Adrian version / commit: current
main
- OS: not expected to be OS-specific
- Docker version: not required to reproduce
- GPU model: not required to reproduce
Logs
Relevant code path
Sync path currently only injects callbacks:
def patched_invoke(self, input, config=None, **kwargs):
config = _inject_callbacks(config)
return original_invoke(self, input, config=config, **kwargs)
verdict = await ws.wait_for_tool_call_verdict(tool_call_id, timeout)
if _should_halt(verdict):
return _build_blocked_response(tool_calls)
</details>
Suggested fix
Mirror the async policy gate in the sync ToolNode.invoke path, or fail closed when sync enforcement cannot safely wait for a verdict under an active BLOCK/HITL policy.
A regression test should cover sync ToolNode.invoke with an in-scope blocking verdict and verify that the underlying tool does not execute.
What went wrong
The SDK enforces BLOCK/HITL policy for async LangGraph tool execution through
ToolNode.ainvoke, but the syncToolNode.invokepatch only injects callbacks and then calls the original tool execution.That means sync agent runs can execute tools without waiting for a verdict, even when the backend policy is in BLOCK or HITL mode. Async and sync execution should have equivalent policy behaviour.
Relevant code:
sdk/adrian/__init__.py:_patch_tool_node()patched_invokeinjects callbacks and callsoriginal_invokepatched_ainvokewaits for LoginAck, checks active policy, waits for the tool-call verdict, and can return blocked tool messagesReproduction steps
blockorhitl, with M3/M4 in scope.ToolNodethat emits a tool call which receives an in-scope blocking verdict.graph.invoke(...)orToolNode.invoke(...).await graph.ainvoke(...)orawait ToolNode.ainvoke(...).Expected behaviour
Sync
ToolNode.invokeshould enforce the same BLOCK/HITL policy as asyncToolNode.ainvoke.If the verdict is in scope for the active policy, the tool should not execute and the SDK should return the same blocked tool response shape used by the async path.
Actual behaviour
Sync
ToolNode.invokecurrently forwards directly to the original tool execution after callback injection. It does not wait for a verdict and does not call the existing halt logic.As a result, tools can execute in sync agent runs even though the same tool call would be blocked in async execution.
Environment
mainLogs
Relevant code path
Sync path currently only injects callbacks:
Suggested fix
Mirror the async policy gate in the sync ToolNode.invoke path, or fail closed when sync enforcement cannot safely wait for a verdict under an active BLOCK/HITL policy.
A regression test should cover sync ToolNode.invoke with an in-scope blocking verdict and verify that the underlying tool does not execute.