riskgate 1.0.0rc3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
riskgate/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """riskgate: certify, reject or ask -- distribution-free risk control for the actions of LLM agents."""
2
+ from .adapters.tool import ActionRejected, ApprovalRequired, guard
3
+ from .audit import AuditLog, MemoryAudit, MultiSink, summarize_audit
4
+ from .conformal import conformal_quantile, crc_threshold
5
+ from .gate import (
6
+ Calibration,
7
+ CallableModel,
8
+ ConsequenceModel,
9
+ Episode,
10
+ Gate,
11
+ HypothesisProvider,
12
+ SignatureError,
13
+ SingleHypothesis,
14
+ )
15
+ from .monitor import Monitor
16
+ from .replay import ReplayReport, replay, resplit, sweep
17
+ from .spec import Custom, Envelope, HardRule, Lower, Predicate, Spec, Upper
18
+ from .types import Action, Decision, GuaranteeStatus, Hypothesis, RiskGateWarning, Verdict
19
+ from .worlds import DiscreteWorlds, OutcomeTable, nearest_violation, unsafe_mass
20
+
21
+ __version__ = "1.0.0rc3"
22
+
23
+ __all__ = [
24
+ "Action", "ActionRejected", "ApprovalRequired", "AuditLog", "Calibration", "CallableModel", "ConsequenceModel",
25
+ "Custom", "Decision", "DiscreteWorlds", "Envelope", "Episode", "Gate", "GuaranteeStatus", "HardRule", "Hypothesis",
26
+ "HypothesisProvider", "Lower", "MemoryAudit", "Monitor", "MultiSink", "OutcomeTable", "Predicate", "ReplayReport",
27
+ "RiskGateWarning", "SignatureError", "SingleHypothesis", "Spec", "Upper", "Verdict", "conformal_quantile",
28
+ "crc_threshold", "guard", "nearest_violation", "replay", "resplit", "summarize_audit", "sweep", "unsafe_mass",
29
+ ]
@@ -0,0 +1,48 @@
1
+ """Framework adapters for riskgate (all optional: no framework is imported until its adapter is used).
2
+
3
+ ================================================== =========================================== ===============
4
+ name module framework
5
+ ================================================== =========================================== ===============
6
+ ``guard``, ``ActionRejected``, ``ApprovalRequired`` ``riskgate.adapters.tool`` any (decorator)
7
+ ``adk_before_tool_callback``, ``adk_plugin`` ``riskgate.adapters.frameworks`` Google ADK
8
+ ``langgraph_gate_node``, ``route_after_gate``, ``riskgate.adapters.frameworks`` LangGraph
9
+ ``gated_tool_node``, ``langgraph_tool_wrapper``,
10
+ ``langgraph_async_tool_wrapper``
11
+ ``guarded_function_tool``, ``riskgate.adapters.openai_agents`` OpenAI Agents
12
+ ``riskgate_tool_input_guardrail``,
13
+ ``riskgate_needs_approval``
14
+ ``build_proxy_server``, ``serve_stdio`` ``riskgate.adapters.mcp_proxy`` MCP
15
+ ================================================== =========================================== ===============
16
+
17
+ Names are resolved lazily (PEP 562), so ``from riskgate.adapters import gated_tool_node`` imports only the
18
+ module that defines it. See ``docs/integrations.md`` for verified versions and usage.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ from typing import Any
24
+
25
+ _EXPORTS: dict[str, str] = {
26
+ "guard": "tool", "ActionRejected": "tool", "ApprovalRequired": "tool",
27
+ "adk_before_tool_callback": "frameworks", "adk_plugin": "frameworks",
28
+ "langgraph_gate_node": "frameworks", "route_after_gate": "frameworks", "gated_tool_node": "frameworks",
29
+ "langgraph_tool_wrapper": "frameworks", "langgraph_async_tool_wrapper": "frameworks",
30
+ "guarded_function_tool": "openai_agents", "riskgate_tool_input_guardrail": "openai_agents",
31
+ "riskgate_needs_approval": "openai_agents",
32
+ "build_proxy_server": "mcp_proxy", "serve_stdio": "mcp_proxy",
33
+ }
34
+
35
+ __all__ = sorted(_EXPORTS)
36
+
37
+
38
+ def __getattr__(name: str) -> Any:
39
+ module = _EXPORTS.get(name)
40
+ if module is None:
41
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
42
+ value = getattr(importlib.import_module(f".{module}", __name__), name)
43
+ globals()[name] = value
44
+ return value
45
+
46
+
47
+ def __dir__() -> list[str]:
48
+ return sorted(set(globals()) | set(_EXPORTS))
@@ -0,0 +1,415 @@
1
+ """Adapters for Google ADK and LangGraph (duck-typed; no framework import at module load).
2
+
3
+ Verified against google-adk 2.11.0, langgraph 1.2.12 (langgraph-prebuilt 1.1.0) and langchain-core 1.6.6
4
+ by the contract tests in ``tests/contract`` (see ``docs/integrations.md``). Framework imports happen lazily
5
+ inside the functions that need them, so importing this module never imports a framework.
6
+
7
+ Google ADK
8
+ ``LlmAgent(..., before_tool_callback=adk_before_tool_callback(gate, context=...))``. ADK calls the
9
+ callback as ``callback(tool=..., args=..., tool_context=...)``; returning ``None`` runs the tool and
10
+ returning a dict skips it and uses the dict as the tool's response. For ASK the callback uses ADK's
11
+ native human-in-the-loop confirmation (``tool_context.request_confirmation``) unless ``on_ask`` is given.
12
+ ``adk_plugin(gate, ...)`` applies the same logic to every agent of a Runner (``plugins=[...]``).
13
+
14
+ LangGraph
15
+ Pattern A, gate node: ``model -> gate -> (tools | model)`` with ``langgraph_gate_node`` and
16
+ ``route_after_gate`` as the conditional edge. Non-certified calls are answered with ``ToolMessage``s
17
+ carrying the gate's feedback; certified calls are left for the ``ToolNode`` (a subset is dispatched with
18
+ ``Send`` when only some calls of a batch are certified). Pass ``interrupt=True`` (or
19
+ ``langgraph.types.interrupt``) to escalate ASK to a human with ``interrupt()`` / ``Command(resume=...)``.
20
+
21
+ Pattern B, tool wrapper: ``gated_tool_node(gate, tools, context=...)`` returns a ``ToolNode`` whose
22
+ ``wrap_tool_call`` / ``awrap_tool_call`` hooks gate every call. Works anywhere a ``ToolNode`` is accepted
23
+ (custom graphs, ``create_react_agent``).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import inspect
28
+ import json
29
+ import math
30
+ from collections.abc import Callable, Collection, Mapping, Sequence
31
+ from typing import Any, Literal
32
+
33
+ from ..types import Action, Decision, Verdict
34
+ from .tool import _feedback
35
+
36
+ __all__ = [
37
+ "adk_before_tool_callback", "adk_plugin", "gated_tool_node", "langgraph_async_tool_wrapper",
38
+ "langgraph_gate_node", "langgraph_tool_wrapper", "route_after_gate",
39
+ ]
40
+
41
+
42
+ # --------------------------------------------------------------------------------------------
43
+ # Shared helpers
44
+ # --------------------------------------------------------------------------------------------
45
+ def _call_context(fn: Callable[..., Any], *args: Any) -> Any:
46
+ """Call a user context function with ``args`` if it accepts them, otherwise with no arguments.
47
+
48
+ Lets users pass either ``lambda tool_context: ...`` or ``lambda: ...`` to every adapter.
49
+ """
50
+ try:
51
+ inspect.signature(fn).bind(*args)
52
+ except TypeError:
53
+ return fn()
54
+ except ValueError: # builtins without a signature
55
+ return fn(*args)
56
+ return fn(*args)
57
+
58
+
59
+ def _check(gate: Any, action: Action, context: Callable[..., Any], *ctx_args: Any) -> Decision:
60
+ """``gate.check`` with the context built from ``context(*ctx_args)``; a failing context fails closed (ASK)."""
61
+ try:
62
+ ctx = _call_context(context, *ctx_args)
63
+ except Exception as exc: # noqa: BLE001 - rule R3: fail closed
64
+ return Decision(Verdict.ASK, action, reason=f"fail-closed: could not build the gate context: "
65
+ f"{type(exc).__name__}: {exc}")
66
+ return gate.check(action, ctx)
67
+
68
+
69
+ def _safe(obj: Any) -> Any:
70
+ """JSON-safe copy: non-finite floats become None, unknown objects become strings."""
71
+ if isinstance(obj, float):
72
+ return obj if math.isfinite(obj) else None
73
+ if isinstance(obj, Mapping):
74
+ return {str(k): _safe(v) for k, v in obj.items()}
75
+ if isinstance(obj, (list, tuple)):
76
+ return [_safe(v) for v in obj]
77
+ if obj is None or isinstance(obj, (str, int, bool)):
78
+ return obj
79
+ return str(obj)
80
+
81
+
82
+ def _feedback_json(d: Decision) -> str:
83
+ return json.dumps(_safe(_feedback(d)))
84
+
85
+
86
+ def _approved(answer: Any) -> bool:
87
+ """Interpret a human reviewer's answer (``Command(resume=...)`` value or ``on_ask`` result)."""
88
+ if isinstance(answer, bool):
89
+ return answer
90
+ if isinstance(answer, Mapping):
91
+ return bool(answer.get("approve", answer.get("approved", False)))
92
+ if isinstance(answer, str):
93
+ return answer.strip().lower() in ("approve", "approved", "yes", "y", "ok")
94
+ return False
95
+
96
+
97
+ def _resolve_interrupt(interrupt: Callable[[Any], Any] | bool | None) -> Callable[[Any], Any] | None:
98
+ if interrupt is True:
99
+ from langgraph.types import interrupt as lg_interrupt # lazy: only when human review is requested
100
+ return lg_interrupt
101
+ if interrupt is False or interrupt is None:
102
+ return None
103
+ return interrupt
104
+
105
+
106
+ # --------------------------------------------------------------------------------------------
107
+ # Google ADK
108
+ # --------------------------------------------------------------------------------------------
109
+ def _adk_decide(gate: Any, tool: Any, args: Mapping[str, Any] | None, tool_context: Any,
110
+ context: Callable[..., Any], on_ask: Callable[[Decision], bool] | None, confirm: bool,
111
+ passthrough: Collection[str]) -> dict | None:
112
+ name = getattr(tool, "name", None) or str(tool)
113
+ if name in passthrough:
114
+ return None
115
+ d = _check(gate, Action(name, dict(args or {})), context, tool_context)
116
+ if d.verdict is Verdict.CERTIFY:
117
+ return None
118
+ if d.verdict is Verdict.ASK:
119
+ if on_ask is not None:
120
+ return None if on_ask(d) else _feedback(d)
121
+ if confirm and tool_context is not None and hasattr(tool_context, "request_confirmation"):
122
+ confirmation = getattr(tool_context, "tool_confirmation", None)
123
+ if confirmation is None: # first pass: pause the run and ask the user (ADK HITL)
124
+ try:
125
+ tool_context.request_confirmation(
126
+ hint=f"riskgate cannot certify {name}({json.dumps(_safe(dict(args or {})))}): {d.reason}. "
127
+ "Approve or reject this tool call.",
128
+ payload={"riskgate": _safe(_feedback(d))})
129
+ except Exception: # noqa: BLE001 - e.g. no function_call_id: fall back to feedback
130
+ return _feedback(d)
131
+ actions = getattr(tool_context, "actions", None)
132
+ if actions is not None: # mirror ADK's own confirmation gate: do not re-invoke the model
133
+ actions.skip_summarization = True
134
+ return _feedback(d)
135
+ if getattr(confirmation, "confirmed", False):
136
+ return None # the user approved this call
137
+ return {**_feedback(d), "status": "rejected", "reason": f"declined by the user: {d.reason}"}
138
+ return _feedback(d)
139
+
140
+
141
+ def adk_before_tool_callback(gate: Any, *, context: Callable[..., Any] = lambda tool_context: None,
142
+ on_ask: Callable[[Decision], bool] | None = None, confirm: bool = True,
143
+ passthrough: Collection[str] = ()) -> Callable[..., dict | None]:
144
+ """Build an ADK ``before_tool_callback``: ``callback(tool, args, tool_context) -> None | dict``.
145
+
146
+ Args:
147
+ gate: a calibrated ``riskgate.Gate`` (anything with ``check(action, context)``).
148
+ context: ``context(tool_context)`` (or ``context()``) returning the gate context.
149
+ on_ask: optional synchronous approval hook for ASK; True runs the tool.
150
+ confirm: when ``on_ask`` is None, use ADK's native tool confirmation for ASK: the run pauses with an
151
+ ``adk_request_confirmation`` function call; after the user replies ``{"confirmed": true}`` ADK re-runs
152
+ the call, the gate re-checks it and the tool runs. Without a confirming client the model receives
153
+ ``{"status": "needs_approval", ...}``.
154
+ passthrough: tool names that are never gated (e.g. ``{"transfer_to_agent"}``). Use sparingly.
155
+
156
+ Returns ``None`` (run the tool) for CERTIFY or an approved ASK; otherwise a dict (status, reason,
157
+ guarantee[, probe]) that ADK uses as the tool's response, so the model can re-plan.
158
+ """
159
+ frozen = frozenset(passthrough)
160
+
161
+ def callback(tool: Any, args: dict, tool_context: Any = None) -> dict | None:
162
+ return _adk_decide(gate, tool, args, tool_context, context, on_ask, confirm, frozen)
163
+
164
+ return callback
165
+
166
+
167
+ def adk_plugin(gate: Any, *, context: Callable[..., Any] = lambda tool_context: None,
168
+ on_ask: Callable[[Decision], bool] | None = None, confirm: bool = True,
169
+ passthrough: Collection[str] = (), name: str = "riskgate") -> Any:
170
+ """Return an ADK ``BasePlugin`` that gates every tool call of every agent run by a Runner.
171
+
172
+ ``InMemoryRunner(agent=root, plugins=[adk_plugin(gate, context=...)])``. Plugin callbacks run before agent
173
+ callbacks; returning a dict skips the tool. Same semantics as ``adk_before_tool_callback``. Requires
174
+ ``google-adk`` (imported lazily).
175
+ """
176
+ from google.adk.plugins.base_plugin import BasePlugin
177
+
178
+ frozen = frozenset(passthrough)
179
+
180
+ class RiskgatePlugin(BasePlugin):
181
+ """Gates every tool call with a riskgate Gate."""
182
+
183
+ async def before_tool_callback(self, *, tool: Any, tool_args: dict[str, Any],
184
+ tool_context: Any) -> dict | None:
185
+ return _adk_decide(gate, tool, tool_args, tool_context, context, on_ask, confirm, frozen)
186
+
187
+ return RiskgatePlugin(name=name)
188
+
189
+
190
+ # --------------------------------------------------------------------------------------------
191
+ # LangGraph / LangChain messages
192
+ # --------------------------------------------------------------------------------------------
193
+ def _messages(state: Any, key: str = "messages") -> list:
194
+ if isinstance(state, list):
195
+ return state
196
+ if isinstance(state, Mapping):
197
+ return list(state.get(key) or [])
198
+ return list(getattr(state, key, None) or [])
199
+
200
+
201
+ def _calls(message: Any) -> list[dict]:
202
+ """Tool calls of an AI message as ``[{"id", "name", "args"}]``.
203
+
204
+ Accepts ``AIMessage`` objects, LangChain-style dicts and OpenAI-style dicts (``{"function": {...}}``).
205
+ """
206
+ calls = message.get("tool_calls") if isinstance(message, Mapping) else getattr(message, "tool_calls", None)
207
+ out = []
208
+ for c in calls or []:
209
+ if not isinstance(c, Mapping):
210
+ c = {"id": getattr(c, "id", None), "name": getattr(c, "name", None), "args": getattr(c, "args", {})}
211
+ if "function" in c and "name" not in c: # OpenAI chat-completions format
212
+ fn = c.get("function") or {}
213
+ raw = fn.get("arguments") or "{}"
214
+ try:
215
+ args = json.loads(raw) if isinstance(raw, str) else dict(raw)
216
+ except (TypeError, ValueError): # malformed: let the gate see (and fail closed on) it
217
+ args = {"_unparsed_arguments": raw}
218
+ out.append({"id": c.get("id"), "name": fn.get("name"),
219
+ "args": args if isinstance(args, dict) else {"input": args}})
220
+ else:
221
+ out.append({"id": c.get("id"), "name": c.get("name"), "args": dict(c.get("args") or {})})
222
+ return out
223
+
224
+
225
+ def _tool_call_id(message: Any) -> str | None:
226
+ if isinstance(message, Mapping):
227
+ return message.get("tool_call_id")
228
+ return getattr(message, "tool_call_id", None)
229
+
230
+
231
+ def _reply(call: Mapping[str, Any], d: Decision, like: Any, fmt: str) -> Any:
232
+ """Tool reply carrying the gate's feedback: a ``ToolMessage`` (status="error") or an OpenAI-style dict."""
233
+ content = _feedback_json(d)
234
+ as_dict = {"role": "tool", "tool_call_id": call["id"], "name": call["name"], "content": content}
235
+ if fmt == "dict" or (fmt == "auto" and isinstance(like, Mapping)):
236
+ return as_dict
237
+ try:
238
+ from langchain_core.messages import ToolMessage
239
+ except ImportError:
240
+ if fmt == "langchain":
241
+ raise
242
+ return as_dict
243
+ return ToolMessage(content=content, tool_call_id=call["id"], name=call["name"], status="error",
244
+ artifact={"riskgate": _safe(d.to_dict())})
245
+
246
+
247
+ def _resolve_ask(d: Decision, call: Mapping[str, Any], interrupt_fn: Callable[[Any], Any] | None,
248
+ on_ask: Callable[[Decision], bool] | None) -> bool:
249
+ """True if a human (interrupt) or the ``on_ask`` hook approves an ASK decision."""
250
+ if interrupt_fn is not None:
251
+ answer = interrupt_fn({"type": "riskgate_approval",
252
+ "tool_call": {"id": call["id"], "name": call["name"], "args": _safe(call["args"])},
253
+ "reason": d.reason, "guarantee": d.status.value, "feedback": _safe(_feedback(d))})
254
+ return _approved(answer)
255
+ if on_ask is not None:
256
+ return bool(on_ask(d))
257
+ return False
258
+
259
+
260
+ def langgraph_gate_node(gate: Any, *, context: Callable[..., Any] = lambda state: None,
261
+ interrupt: Callable[[Any], Any] | bool | None = None,
262
+ on_ask: Callable[[Decision], bool] | None = None, messages_key: str = "messages",
263
+ decisions_key: str | None = "riskgate",
264
+ message_format: Literal["auto", "langchain", "dict"] = "auto") -> Callable[[Any], dict]:
265
+ """Build a LangGraph node ``node(state) -> update`` that gates the last AI message's tool calls.
266
+
267
+ Place it between the model node and the ``ToolNode`` and use ``route_after_gate`` as its conditional
268
+ edge. Every non-certified call is answered immediately with a tool message whose content is the JSON
269
+ feedback (status, reason, guarantee[, probe]), so the model can re-plan. Certified calls get no reply
270
+ and are executed by the ``ToolNode``.
271
+
272
+ Args:
273
+ context: ``context(state)`` (or ``context()``) returning the gate context; evaluated once per step.
274
+ interrupt: ``True`` (uses ``langgraph.types.interrupt``) or an interrupt-like callable to escalate ASK
275
+ to a human. The payload is ``{"type": "riskgate_approval", "tool_call", "reason", ...}``; resume
276
+ with ``Command(resume={"approve": True})`` (or ``True``/``"approve"``) to run the call. Needs a
277
+ checkpointer. Takes precedence over ``on_ask``.
278
+ on_ask: synchronous approval hook for ASK (True runs the call).
279
+ messages_key: state key holding the messages.
280
+ decisions_key: state key for ``{tool_call_id: decision dict}``; dropped silently by LangGraph if the
281
+ state schema has no such key (routing does not depend on it). ``None`` disables it.
282
+ message_format: ``"auto"`` replies with ``ToolMessage`` objects when the AI message is a LangChain
283
+ message (always the case inside a graph using ``add_messages``) and with dicts when it is a dict.
284
+ """
285
+ def node(state: Any) -> dict:
286
+ msgs = _messages(state, messages_key)
287
+ if not msgs:
288
+ return {}
289
+ last = msgs[-1]
290
+ calls = _calls(last)
291
+ update: dict = {}
292
+ if not calls:
293
+ if decisions_key:
294
+ update[decisions_key] = {}
295
+ return update
296
+ interrupt_fn = _resolve_interrupt(interrupt)
297
+ try:
298
+ ctx = _call_context(context, state)
299
+ ctx_error: Exception | None = None
300
+ except Exception as exc: # noqa: BLE001 - fail closed below
301
+ ctx, ctx_error = None, exc
302
+ decisions, replies = {}, []
303
+ for c in calls:
304
+ action = Action(c["name"], dict(c["args"] or {}))
305
+ if ctx_error is not None:
306
+ d = Decision(Verdict.ASK, action, reason=f"fail-closed: could not build the gate context: "
307
+ f"{type(ctx_error).__name__}: {ctx_error}")
308
+ else:
309
+ d = gate.check(action, ctx)
310
+ rec = _safe(d.to_dict())
311
+ if d.verdict is Verdict.ASK and _resolve_ask(d, c, interrupt_fn, on_ask):
312
+ rec.update(verdict="certify", gate_verdict="ask", approved_by="human")
313
+ decisions[c["id"]] = rec
314
+ if rec["verdict"] != "certify":
315
+ replies.append(_reply(c, d, last, message_format))
316
+ if decisions_key:
317
+ update[decisions_key] = decisions
318
+ if replies:
319
+ update[messages_key] = replies
320
+ return update
321
+
322
+ return node
323
+
324
+
325
+ def route_after_gate(state: Any, tools_node: str = "tools", model_node: str = "agent",
326
+ messages_key: str = "messages", decisions_key: str = "riskgate") -> Any:
327
+ """Conditional edge after ``langgraph_gate_node``.
328
+
329
+ Derived from the messages, so it works with plain ``MessagesState``: if every tool call of the last AI
330
+ message is still unanswered (all certified) -> ``tools_node``; if all were answered by the gate ->
331
+ ``model_node``; if only some were certified -> ``[Send(tools_node, [certified tool calls])]`` so the
332
+ ``ToolNode`` executes exactly those. Falls back to the ``decisions_key`` entry when the state has no AI
333
+ message (e.g. when called on the node's own update in unit tests).
334
+ Use ``functools.partial`` (or a lambda) to rename the nodes.
335
+ """
336
+ msgs = _messages(state, messages_key)
337
+ idx = next((i for i in range(len(msgs) - 1, -1, -1) if _calls(msgs[i])), None)
338
+ if idx is not None:
339
+ calls = _calls(msgs[idx])
340
+ answered = {_tool_call_id(m) for m in msgs[idx + 1:]}
341
+ pending = [c for c in calls if c["id"] not in answered]
342
+ if not pending:
343
+ return model_node
344
+ if len(pending) == len(calls):
345
+ return tools_node
346
+ try:
347
+ from langgraph.types import Send
348
+ except ImportError: # cannot dispatch a subset outside LangGraph: fail closed
349
+ return model_node
350
+ return [Send(tools_node, [{"name": c["name"], "args": c["args"], "id": c["id"], "type": "tool_call"}
351
+ for c in pending])]
352
+ decisions = state.get(decisions_key, {}) if isinstance(state, Mapping) else getattr(state, decisions_key, {})
353
+ decisions = decisions or {}
354
+ return tools_node if decisions and all(d["verdict"] == "certify" for d in decisions.values()) else model_node
355
+
356
+
357
+ def _gate_tool_request(gate: Any, request: Any, context: Callable[..., Any],
358
+ interrupt: Callable[[Any], Any] | bool | None,
359
+ on_ask: Callable[[Decision], bool] | None) -> Any:
360
+ """Return None if the ToolNode request may execute, else a ``ToolMessage`` with the gate's feedback."""
361
+ call = request.tool_call
362
+ c = {"id": call.get("id"), "name": call.get("name"), "args": dict(call.get("args") or {})}
363
+ d = _check(gate, Action(c["name"], c["args"]), context, getattr(request, "state", None))
364
+ if d.verdict is Verdict.CERTIFY:
365
+ return None
366
+ if d.verdict is Verdict.ASK and _resolve_ask(d, c, _resolve_interrupt(interrupt), on_ask):
367
+ return None
368
+ return _reply(c, d, None, "langchain")
369
+
370
+
371
+ def langgraph_tool_wrapper(gate: Any, *, context: Callable[..., Any] = lambda state: None,
372
+ interrupt: Callable[[Any], Any] | bool | None = None,
373
+ on_ask: Callable[[Decision], bool] | None = None) -> Callable[[Any, Callable], Any]:
374
+ """Sync ``wrap_tool_call`` hook for ``langgraph.prebuilt.ToolNode`` (langgraph-prebuilt >= 1.0).
375
+
376
+ ``ToolNode(tools, wrap_tool_call=langgraph_tool_wrapper(gate, context=...))``: ``context(state)`` gets
377
+ the graph state; certified (or approved) calls run via ``execute(request)``, all others return a
378
+ ``ToolMessage(status="error")`` with the JSON feedback. ``interrupt`` works as in ``langgraph_gate_node``
379
+ but note that ToolNode runs a step's calls concurrently: with several ASK calls in one step prefer the
380
+ gate node (deterministic interrupt order) or ``create_react_agent(version="v2")`` (one task per call).
381
+ Interrupts propagate only with ToolNode's default ``handle_tool_errors``.
382
+ """
383
+ def wrap_tool_call(request: Any, execute: Callable[[Any], Any]) -> Any:
384
+ blocked = _gate_tool_request(gate, request, context, interrupt, on_ask)
385
+ return blocked if blocked is not None else execute(request)
386
+
387
+ return wrap_tool_call
388
+
389
+
390
+ def langgraph_async_tool_wrapper(gate: Any, *, context: Callable[..., Any] = lambda state: None,
391
+ interrupt: Callable[[Any], Any] | bool | None = None,
392
+ on_ask: Callable[[Decision], bool] | None = None) -> Callable[[Any, Callable], Any]:
393
+ """Async ``awrap_tool_call`` counterpart of ``langgraph_tool_wrapper`` (used by ``ainvoke``/``astream``)."""
394
+ async def awrap_tool_call(request: Any, execute: Callable[[Any], Any]) -> Any:
395
+ blocked = _gate_tool_request(gate, request, context, interrupt, on_ask)
396
+ return blocked if blocked is not None else await execute(request)
397
+
398
+ return awrap_tool_call
399
+
400
+
401
+ def gated_tool_node(gate: Any, tools: Sequence[Any], *, context: Callable[..., Any] = lambda state: None,
402
+ interrupt: Callable[[Any], Any] | bool | None = None,
403
+ on_ask: Callable[[Decision], bool] | None = None, **tool_node_kwargs: Any) -> Any:
404
+ """A ``langgraph.prebuilt.ToolNode`` whose every call is gated (sync and async paths).
405
+
406
+ ``create_react_agent(model, tools=gated_tool_node(gate, [add], context=lambda state: snapshot()))`` or
407
+ ``graph.add_node("tools", gated_tool_node(...))``. Extra keyword arguments go to ``ToolNode``.
408
+ """
409
+ from langgraph.prebuilt import ToolNode
410
+
411
+ return ToolNode(list(tools),
412
+ wrap_tool_call=langgraph_tool_wrapper(gate, context=context, interrupt=interrupt, on_ask=on_ask),
413
+ awrap_tool_call=langgraph_async_tool_wrapper(gate, context=context, interrupt=interrupt,
414
+ on_ask=on_ask),
415
+ **tool_node_kwargs)