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 +29 -0
- riskgate/adapters/__init__.py +48 -0
- riskgate/adapters/frameworks.py +415 -0
- riskgate/adapters/mcp_proxy.py +239 -0
- riskgate/adapters/openai_agents.py +175 -0
- riskgate/adapters/tool.py +97 -0
- riskgate/audit.py +77 -0
- riskgate/cli.py +172 -0
- riskgate/config.py +181 -0
- riskgate/conformal.py +98 -0
- riskgate/doers.py +69 -0
- riskgate/gate.py +889 -0
- riskgate/monitor.py +147 -0
- riskgate/multirisk.py +266 -0
- riskgate/provenance.py +206 -0
- riskgate/py.typed +0 -0
- riskgate/replay.py +182 -0
- riskgate/rules.py +86 -0
- riskgate/spec.py +163 -0
- riskgate/stl.py +492 -0
- riskgate/telemetry.py +67 -0
- riskgate/types.py +205 -0
- riskgate/weighted.py +175 -0
- riskgate/worlds.py +237 -0
- riskgate-1.0.0rc3.dist-info/METADATA +338 -0
- riskgate-1.0.0rc3.dist-info/RECORD +30 -0
- riskgate-1.0.0rc3.dist-info/WHEEL +5 -0
- riskgate-1.0.0rc3.dist-info/entry_points.txt +2 -0
- riskgate-1.0.0rc3.dist-info/licenses/LICENSE +202 -0
- riskgate-1.0.0rc3.dist-info/top_level.txt +1 -0
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)
|