riskkernel 0.3.0__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.
- riskkernel/__init__.py +59 -0
- riskkernel/adapters/__init__.py +8 -0
- riskkernel/adapters/claude_agent.py +75 -0
- riskkernel/adapters/langchain.py +82 -0
- riskkernel/adapters/openai_agents.py +49 -0
- riskkernel/approval.py +86 -0
- riskkernel/client.py +153 -0
- riskkernel/errors.py +40 -0
- riskkernel/runtime.py +226 -0
- riskkernel-0.3.0.dist-info/METADATA +121 -0
- riskkernel-0.3.0.dist-info/RECORD +12 -0
- riskkernel-0.3.0.dist-info/WHEEL +4 -0
riskkernel/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""RiskKernel Python SDK — Surface 2 (deep control).
|
|
2
|
+
|
|
3
|
+
A thin client over the self-hosted RiskKernel daemon. The Go core makes every
|
|
4
|
+
deterministic decision (budgets, halts, approval policy); this package just makes
|
|
5
|
+
governed runs ergonomic from Python.
|
|
6
|
+
|
|
7
|
+
Quickstart::
|
|
8
|
+
|
|
9
|
+
import riskkernel as rk
|
|
10
|
+
|
|
11
|
+
rt = rk.Runtime(base_url="http://localhost:7070")
|
|
12
|
+
with rt.governed_run(name="research", budget=rt.budget(dollars=1.00, loops=20)) as run:
|
|
13
|
+
cfg = run.proxy_config() # route your LLM client through the governing proxy
|
|
14
|
+
for _ in range(100):
|
|
15
|
+
run.step() # raises BudgetExceeded when loops/time run out
|
|
16
|
+
... # your agent reasoning + tool calls
|
|
17
|
+
run.checkpoint("after-step", {"messages": messages})
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .client import RiskKernel
|
|
21
|
+
from .errors import (
|
|
22
|
+
APIError,
|
|
23
|
+
ApprovalDenied,
|
|
24
|
+
ApprovalTimeout,
|
|
25
|
+
BudgetExceeded,
|
|
26
|
+
RiskKernelError,
|
|
27
|
+
)
|
|
28
|
+
from .approval import ApprovalGate, governed_tool
|
|
29
|
+
from .runtime import (
|
|
30
|
+
Budget,
|
|
31
|
+
Decision,
|
|
32
|
+
Run,
|
|
33
|
+
Runtime,
|
|
34
|
+
configure,
|
|
35
|
+
current_run,
|
|
36
|
+
default_runtime,
|
|
37
|
+
governed_run,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "0.3.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"RiskKernel",
|
|
44
|
+
"Runtime",
|
|
45
|
+
"Run",
|
|
46
|
+
"Budget",
|
|
47
|
+
"Decision",
|
|
48
|
+
"ApprovalGate",
|
|
49
|
+
"governed_run",
|
|
50
|
+
"governed_tool",
|
|
51
|
+
"current_run",
|
|
52
|
+
"configure",
|
|
53
|
+
"default_runtime",
|
|
54
|
+
"RiskKernelError",
|
|
55
|
+
"APIError",
|
|
56
|
+
"BudgetExceeded",
|
|
57
|
+
"ApprovalDenied",
|
|
58
|
+
"ApprovalTimeout",
|
|
59
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Framework adapters that bind a RiskKernel governed run to popular agent
|
|
2
|
+
frameworks. Each adapter lazily imports its framework, so the core SDK has no
|
|
3
|
+
third-party dependencies and you only pay for what you use.
|
|
4
|
+
|
|
5
|
+
- ``langchain`` — a CallbackHandler (loop/time enforcement per LLM call).
|
|
6
|
+
- ``claude_agent`` — a PreToolUse hook for the Claude Agent SDK (approval gate).
|
|
7
|
+
- ``openai_agents`` — RunHooks for the OpenAI Agents SDK (steps + approval gate).
|
|
8
|
+
"""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Claude Agent SDK adapter: a PreToolUse hook that routes side-effecting tool
|
|
2
|
+
calls through the RiskKernel approval gate. Maps cleanly to the Claude Agent SDK's
|
|
3
|
+
permission model (``permissionDecision: "deny"`` blocks a tool).
|
|
4
|
+
|
|
5
|
+
from riskkernel.adapters.claude_agent import make_pre_tool_use_hook
|
|
6
|
+
hook = make_pre_tool_use_hook(run, side_effect_for={"Bash": "exec", "Write": "write"})
|
|
7
|
+
# register `hook` as your PreToolUse hook in the Claude Agent SDK options.
|
|
8
|
+
|
|
9
|
+
The hook signature follows the Claude Agent SDK: it receives the hook input
|
|
10
|
+
(containing the tool name and input) and returns a decision dict. Because SDK
|
|
11
|
+
versions differ slightly, the returned shape is the documented
|
|
12
|
+
``hookSpecificOutput`` / ``permissionDecision`` form; adjust if your version
|
|
13
|
+
differs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Callable, Dict, Optional
|
|
19
|
+
|
|
20
|
+
from ..runtime import Run
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_pre_tool_use_hook(
|
|
24
|
+
run: Run,
|
|
25
|
+
side_effect_for: Optional[Dict[str, str]] = None,
|
|
26
|
+
default_side_effect: str = "write",
|
|
27
|
+
timeout: Optional[float] = None,
|
|
28
|
+
) -> Callable[..., dict]:
|
|
29
|
+
"""Build a PreToolUse hook bound to a governed run.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
run: the governed Run.
|
|
33
|
+
side_effect_for: map of tool name -> side-effect label. Tools not listed
|
|
34
|
+
use ``default_side_effect``. A tool mapped to "" (empty) is treated as
|
|
35
|
+
read-only and never gated.
|
|
36
|
+
default_side_effect: side effect for unlisted tools.
|
|
37
|
+
timeout: max seconds to await a human decision.
|
|
38
|
+
"""
|
|
39
|
+
side_effect_for = side_effect_for or {}
|
|
40
|
+
|
|
41
|
+
def hook(input_data: Any = None, *args: Any, **kwargs: Any) -> dict:
|
|
42
|
+
tool_name, tool_input = _extract(input_data, kwargs)
|
|
43
|
+
side_effect = side_effect_for.get(tool_name, default_side_effect)
|
|
44
|
+
decision = run.approve(
|
|
45
|
+
tool_name or "tool", side_effect=side_effect,
|
|
46
|
+
arguments={"input": _stringify(tool_input)}, timeout=timeout,
|
|
47
|
+
)
|
|
48
|
+
if decision.approved:
|
|
49
|
+
return {} # allow (no decision == proceed)
|
|
50
|
+
return {
|
|
51
|
+
"hookSpecificOutput": {
|
|
52
|
+
"hookEventName": "PreToolUse",
|
|
53
|
+
"permissionDecision": "deny",
|
|
54
|
+
"permissionDecisionReason": decision.reason or "denied via RiskKernel approval gate",
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return hook
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract(input_data: Any, kwargs: dict):
|
|
62
|
+
"""Pull tool name + input out of the hook payload across SDK shapes."""
|
|
63
|
+
data = input_data if isinstance(input_data, dict) else kwargs
|
|
64
|
+
name = data.get("tool_name") or data.get("toolName") or data.get("name") or ""
|
|
65
|
+
tinput = data.get("tool_input") or data.get("toolInput") or data.get("input")
|
|
66
|
+
return name, tinput
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stringify(v: Any) -> Any:
|
|
70
|
+
try:
|
|
71
|
+
import json
|
|
72
|
+
json.dumps(v)
|
|
73
|
+
return v
|
|
74
|
+
except Exception:
|
|
75
|
+
return repr(v)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""LangChain / LangGraph adapter: a callback handler that enforces a governed
|
|
2
|
+
run's loop and time budgets, ticking one step per LLM call. Point your LangChain
|
|
3
|
+
LLM at the governing proxy (``run.proxy_config()``) for token/cost/budget metering;
|
|
4
|
+
this handler adds the outer-loop enforcement the proxy can't see.
|
|
5
|
+
|
|
6
|
+
from riskkernel.adapters.langchain import RiskKernelCallbackHandler
|
|
7
|
+
handler = RiskKernelCallbackHandler(run)
|
|
8
|
+
llm.invoke(prompt, config={"callbacks": [handler]})
|
|
9
|
+
|
|
10
|
+
A BudgetExceeded raised here propagates out of the LangChain call, halting the
|
|
11
|
+
chain — exactly when the run is out of budget.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from ..approval import ApprovalGate
|
|
19
|
+
from ..runtime import Run
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _base_handler():
|
|
23
|
+
# Inherit the real base class when LangChain is installed (best integration);
|
|
24
|
+
# otherwise fall back to object so the module still imports.
|
|
25
|
+
try:
|
|
26
|
+
from langchain_core.callbacks import BaseCallbackHandler # type: ignore
|
|
27
|
+
return BaseCallbackHandler
|
|
28
|
+
except Exception:
|
|
29
|
+
try:
|
|
30
|
+
from langchain.callbacks.base import BaseCallbackHandler # type: ignore
|
|
31
|
+
return BaseCallbackHandler
|
|
32
|
+
except Exception:
|
|
33
|
+
return object
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RiskKernelCallbackHandler(_base_handler()): # type: ignore[misc]
|
|
37
|
+
"""Enforces loop/time budgets and (optionally) gates tools on approval.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
run: the governed Run.
|
|
41
|
+
gate_tools: if True, every tool call must pass the approval gate.
|
|
42
|
+
tool_side_effect: side-effect label reported for gated tools.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# LangChain swallows exceptions raised inside a callback — it logs them and
|
|
46
|
+
# keeps running — UNLESS the handler sets raise_error=True. Without this, a
|
|
47
|
+
# BudgetExceeded (or ApprovalDenied) raised in a hook below would be silently
|
|
48
|
+
# dropped and the chain would keep spending past its budget. This single flag
|
|
49
|
+
# is what makes the deterministic halt actually stop the LangChain run.
|
|
50
|
+
raise_error = True
|
|
51
|
+
|
|
52
|
+
def __init__(self, run: Run, gate_tools: bool = False,
|
|
53
|
+
tool_side_effect: str = "tool"):
|
|
54
|
+
self.run = run
|
|
55
|
+
self.gate_tools = gate_tools
|
|
56
|
+
self.tool_side_effect = tool_side_effect
|
|
57
|
+
self._gate = ApprovalGate(run)
|
|
58
|
+
|
|
59
|
+
# One LLM call == one governed step. Raises BudgetExceeded when spent.
|
|
60
|
+
def on_llm_start(self, serialized: Any, prompts: Any, **kwargs: Any) -> None:
|
|
61
|
+
self.run.step()
|
|
62
|
+
|
|
63
|
+
def on_chat_model_start(self, serialized: Any, messages: Any, **kwargs: Any) -> None:
|
|
64
|
+
self.run.step()
|
|
65
|
+
|
|
66
|
+
def on_tool_start(self, serialized: Any, input_str: Any, **kwargs: Any) -> None:
|
|
67
|
+
if not self.gate_tools:
|
|
68
|
+
return
|
|
69
|
+
name = ""
|
|
70
|
+
if isinstance(serialized, dict):
|
|
71
|
+
name = serialized.get("name", "")
|
|
72
|
+
self._gate.require(name or "tool", side_effect=self.tool_side_effect,
|
|
73
|
+
arguments={"input": _stringify(input_str)})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _stringify(v: Any) -> Any:
|
|
77
|
+
try:
|
|
78
|
+
import json
|
|
79
|
+
json.dumps(v)
|
|
80
|
+
return v
|
|
81
|
+
except Exception:
|
|
82
|
+
return repr(v)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""OpenAI Agents SDK adapter: lifecycle hooks that tick a governed step per agent
|
|
2
|
+
turn and gate tools through the approval gate.
|
|
3
|
+
|
|
4
|
+
from riskkernel.adapters.openai_agents import RiskKernelRunHooks
|
|
5
|
+
hooks = RiskKernelRunHooks(run, gate_tools=True)
|
|
6
|
+
await Runner.run(agent, input, hooks=hooks)
|
|
7
|
+
|
|
8
|
+
The OpenAI Agents SDK calls ``on_agent_start``/``on_tool_start`` (async). We tick a
|
|
9
|
+
step on each agent start (loop/time enforcement) and, when ``gate_tools`` is set,
|
|
10
|
+
await approval before a tool runs — raising ApprovalDenied to block it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from ..approval import ApprovalGate
|
|
18
|
+
from ..runtime import Run
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _base_hooks():
|
|
22
|
+
try:
|
|
23
|
+
from agents import RunHooks # type: ignore (openai-agents)
|
|
24
|
+
return RunHooks
|
|
25
|
+
except Exception:
|
|
26
|
+
return object
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RiskKernelRunHooks(_base_hooks()): # type: ignore[misc]
|
|
30
|
+
"""RunHooks that bind a governed run to an OpenAI Agents run."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, run: Run, gate_tools: bool = False,
|
|
33
|
+
tool_side_effect: str = "tool", timeout: Optional[float] = None):
|
|
34
|
+
self.run = run
|
|
35
|
+
self.gate_tools = gate_tools
|
|
36
|
+
self.tool_side_effect = tool_side_effect
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self._gate = ApprovalGate(run)
|
|
39
|
+
|
|
40
|
+
async def on_agent_start(self, context: Any = None, agent: Any = None, **kwargs: Any) -> None:
|
|
41
|
+
# One agent turn == one governed step (enforces loop/time budgets).
|
|
42
|
+
self.run.step()
|
|
43
|
+
|
|
44
|
+
async def on_tool_start(self, context: Any = None, agent: Any = None,
|
|
45
|
+
tool: Any = None, **kwargs: Any) -> None:
|
|
46
|
+
if not self.gate_tools:
|
|
47
|
+
return
|
|
48
|
+
name = getattr(tool, "name", None) or str(tool)
|
|
49
|
+
self._gate.require(name, side_effect=self.tool_side_effect, timeout=self.timeout)
|
riskkernel/approval.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Human-in-the-loop approval helpers for the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
from .errors import ApprovalDenied, RiskKernelError
|
|
9
|
+
from .runtime import Decision, Run, current_run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApprovalGate:
|
|
13
|
+
"""Gates side-effecting actions on human approval. Wraps a Run and asks the
|
|
14
|
+
daemon (deterministic policy) whether a call needs approval, then blocks until
|
|
15
|
+
a human resolves it.
|
|
16
|
+
|
|
17
|
+
gate = ApprovalGate(run)
|
|
18
|
+
if gate.allow("mcp://shell", side_effect="exec", arguments={"cmd": cmd}):
|
|
19
|
+
run_shell(cmd)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, run: Optional[Run] = None):
|
|
23
|
+
self._run = run
|
|
24
|
+
|
|
25
|
+
def _resolve_run(self) -> Run:
|
|
26
|
+
run = self._run or current_run()
|
|
27
|
+
if run is None:
|
|
28
|
+
raise RiskKernelError("ApprovalGate used outside a governed run")
|
|
29
|
+
return run
|
|
30
|
+
|
|
31
|
+
def decide(self, tool: str, side_effect: str = "", arguments: Optional[dict] = None,
|
|
32
|
+
step_index: int = 0, timeout: Optional[float] = None) -> Decision:
|
|
33
|
+
"""Return the Decision (blocking until resolved). Does not raise on denial."""
|
|
34
|
+
return self._resolve_run().approve(
|
|
35
|
+
tool, side_effect=side_effect, arguments=arguments,
|
|
36
|
+
step_index=step_index, timeout=timeout,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def allow(self, tool: str, side_effect: str = "", arguments: Optional[dict] = None,
|
|
40
|
+
step_index: int = 0, timeout: Optional[float] = None) -> bool:
|
|
41
|
+
"""Convenience boolean: True if approved."""
|
|
42
|
+
return self.decide(tool, side_effect, arguments, step_index, timeout).approved
|
|
43
|
+
|
|
44
|
+
def require(self, tool: str, side_effect: str = "", arguments: Optional[dict] = None,
|
|
45
|
+
step_index: int = 0, timeout: Optional[float] = None) -> None:
|
|
46
|
+
"""Raise ApprovalDenied if not approved (use to guard before a side effect)."""
|
|
47
|
+
d = self.decide(tool, side_effect, arguments, step_index, timeout)
|
|
48
|
+
if not d.approved:
|
|
49
|
+
raise ApprovalDenied(tool, d.reason)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def governed_tool(_fn: Optional[Callable] = None, *, tool: Optional[str] = None,
|
|
53
|
+
side_effect: str = "write", timeout: Optional[float] = None):
|
|
54
|
+
"""Decorator for a side-effecting tool function: before it runs, ask the
|
|
55
|
+
approval gate (under the current governed run). Raises ApprovalDenied if the
|
|
56
|
+
human says no.
|
|
57
|
+
|
|
58
|
+
@governed_tool(side_effect="write")
|
|
59
|
+
def write_file(path, content): ...
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def decorate(fn: Callable) -> Callable:
|
|
63
|
+
tool_name = tool or fn.__name__
|
|
64
|
+
|
|
65
|
+
@functools.wraps(fn)
|
|
66
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
67
|
+
ApprovalGate().require(
|
|
68
|
+
tool_name, side_effect=side_effect,
|
|
69
|
+
arguments={"args": _safe(args), "kwargs": _safe(kwargs)},
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
)
|
|
72
|
+
return fn(*args, **kwargs)
|
|
73
|
+
|
|
74
|
+
return wrapper
|
|
75
|
+
|
|
76
|
+
return decorate(_fn) if _fn is not None else decorate
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe(obj: Any) -> Any:
|
|
80
|
+
"""Best-effort JSON-able rendering of call arguments for the approver to read."""
|
|
81
|
+
try:
|
|
82
|
+
import json
|
|
83
|
+
json.dumps(obj)
|
|
84
|
+
return obj
|
|
85
|
+
except Exception:
|
|
86
|
+
return repr(obj)
|
riskkernel/client.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Thin HTTP client for the RiskKernel daemon's /v1 API.
|
|
2
|
+
|
|
3
|
+
Stdlib only (urllib) — no third-party dependencies, so ``pip install riskkernel``
|
|
4
|
+
stays light and auditable. This client carries NO governance logic: the Go daemon
|
|
5
|
+
makes every deterministic decision. The client just relays calls and surfaces the
|
|
6
|
+
daemon's verdicts (e.g. a 402 becomes ``BudgetExceeded``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
from urllib.parse import quote as _q
|
|
16
|
+
|
|
17
|
+
from .errors import APIError, BudgetExceeded
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RiskKernel:
|
|
21
|
+
"""Client for a running RiskKernel daemon."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str = "http://localhost:7070",
|
|
26
|
+
token: Optional[str] = None,
|
|
27
|
+
timeout: float = 30.0,
|
|
28
|
+
):
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.token = token
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
# --- low-level ---
|
|
34
|
+
|
|
35
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
|
36
|
+
url = self.base_url + path
|
|
37
|
+
data = None
|
|
38
|
+
headers = {"Accept": "application/json"}
|
|
39
|
+
if body is not None:
|
|
40
|
+
data = json.dumps(body).encode("utf-8")
|
|
41
|
+
headers["Content-Type"] = "application/json"
|
|
42
|
+
if self.token:
|
|
43
|
+
headers["Authorization"] = "Bearer " + self.token
|
|
44
|
+
|
|
45
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
48
|
+
raw = resp.read()
|
|
49
|
+
return json.loads(raw) if raw else {}
|
|
50
|
+
except urllib.error.HTTPError as e:
|
|
51
|
+
raw = e.read()
|
|
52
|
+
payload = {}
|
|
53
|
+
try:
|
|
54
|
+
payload = json.loads(raw)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
code = payload.get("code", "")
|
|
58
|
+
message = payload.get("message", e.reason or "")
|
|
59
|
+
# 402 == the governor halted the run on a budget.
|
|
60
|
+
if e.code == 402:
|
|
61
|
+
raise BudgetExceeded(code, message) from None
|
|
62
|
+
raise APIError(e.code, code, message) from None
|
|
63
|
+
except urllib.error.URLError as e:
|
|
64
|
+
raise APIError(0, "connection_error",
|
|
65
|
+
f"cannot reach daemon at {self.base_url}: {e.reason}") from None
|
|
66
|
+
|
|
67
|
+
# --- runs ---
|
|
68
|
+
|
|
69
|
+
def create_run(self, name: Optional[str] = None, budget: Optional[dict] = None,
|
|
70
|
+
metadata: Optional[dict] = None) -> dict:
|
|
71
|
+
body: dict = {}
|
|
72
|
+
if name:
|
|
73
|
+
body["name"] = name
|
|
74
|
+
if budget:
|
|
75
|
+
body["budget"] = budget
|
|
76
|
+
if metadata:
|
|
77
|
+
body["metadata"] = metadata
|
|
78
|
+
return self._request("POST", "/v1/runs", body)
|
|
79
|
+
|
|
80
|
+
def get_run(self, run_id: str) -> dict:
|
|
81
|
+
return self._request("GET", f"/v1/runs/{run_id}")
|
|
82
|
+
|
|
83
|
+
def begin_step(self, run_id: str) -> int:
|
|
84
|
+
"""Register a loop iteration. Raises BudgetExceeded (402) if the loop or
|
|
85
|
+
time budget is spent."""
|
|
86
|
+
out = self._request("POST", f"/v1/runs/{run_id}/steps", {})
|
|
87
|
+
return int(out.get("stepIndex", 0))
|
|
88
|
+
|
|
89
|
+
def checkpoint(self, run_id: str, name: str = "", payload: Optional[dict] = None) -> None:
|
|
90
|
+
self._request("POST", f"/v1/runs/{run_id}/checkpoints",
|
|
91
|
+
{"name": name, "payload": payload or {}})
|
|
92
|
+
|
|
93
|
+
def latest_checkpoint(self, run_id: str) -> Optional[dict]:
|
|
94
|
+
try:
|
|
95
|
+
return self._request("GET", f"/v1/checkpoints/{run_id}")
|
|
96
|
+
except APIError as e:
|
|
97
|
+
if e.status == 404:
|
|
98
|
+
return None
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
def cancel(self, run_id: str, reason: str = "") -> dict:
|
|
102
|
+
return self._request("POST", f"/v1/runs/{run_id}/cancel", {"reason": reason})
|
|
103
|
+
|
|
104
|
+
# --- approvals ---
|
|
105
|
+
|
|
106
|
+
def request_approval(self, run_id: str, tool: str, side_effect: str = "",
|
|
107
|
+
arguments: Optional[dict] = None, step_index: int = 0) -> dict:
|
|
108
|
+
"""Request approval for a (possibly side-effecting) tool call. Returns a
|
|
109
|
+
dict with ``status``: ``approved`` (allowed by policy) or ``pending``
|
|
110
|
+
(a human must decide; poll ``get_approval``)."""
|
|
111
|
+
return self._request("POST", f"/v1/runs/{run_id}/approvals", {
|
|
112
|
+
"tool": tool, "sideEffect": side_effect,
|
|
113
|
+
"arguments": arguments or {}, "stepIndex": step_index,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
def get_approval(self, approval_id: str) -> dict:
|
|
117
|
+
return self._request("GET", f"/v1/approvals/{approval_id}")
|
|
118
|
+
|
|
119
|
+
def resolve_approval(self, run_id: str, approval_id: str, approve: bool,
|
|
120
|
+
reason: str = "", decided_by: str = "sdk") -> dict:
|
|
121
|
+
return self._request("POST", f"/v1/runs/{run_id}/approve", {
|
|
122
|
+
"approvalId": approval_id,
|
|
123
|
+
"decision": "approve" if approve else "deny",
|
|
124
|
+
"reason": reason, "decidedBy": decided_by,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
# --- git-native memory ---
|
|
128
|
+
|
|
129
|
+
def list_memory(self, namespace: str = "", query: str = "") -> list:
|
|
130
|
+
"""List (or keyword-search) the user-owned markdown/YAML memory entries."""
|
|
131
|
+
q = []
|
|
132
|
+
if namespace:
|
|
133
|
+
q.append("namespace=" + _q(namespace))
|
|
134
|
+
if query:
|
|
135
|
+
q.append("q=" + _q(query))
|
|
136
|
+
path = "/v1/memory" + ("?" + "&".join(q) if q else "")
|
|
137
|
+
return self._request("GET", path)
|
|
138
|
+
|
|
139
|
+
def read_memory(self, name: str, namespace: str = "") -> dict:
|
|
140
|
+
"""Read one memory file's content + metadata."""
|
|
141
|
+
path = f"/v1/memory/entry?name={_q(name)}"
|
|
142
|
+
if namespace:
|
|
143
|
+
path += "&namespace=" + _q(namespace)
|
|
144
|
+
return self._request("GET", path)
|
|
145
|
+
|
|
146
|
+
def list_facts(self, namespace: str = "") -> list:
|
|
147
|
+
path = "/v1/memory/facts" + ("?namespace=" + _q(namespace) if namespace else "")
|
|
148
|
+
return self._request("GET", path)
|
|
149
|
+
|
|
150
|
+
def put_fact(self, namespace: str, key: str, value: str, run_id: str = "") -> dict:
|
|
151
|
+
return self._request("PUT", "/v1/memory/facts", {
|
|
152
|
+
"namespace": namespace, "key": key, "value": value, "runId": run_id,
|
|
153
|
+
})
|
riskkernel/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Exceptions raised by the RiskKernel SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RiskKernelError(Exception):
|
|
7
|
+
"""Base class for all SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(RiskKernelError):
|
|
11
|
+
"""The daemon returned an unexpected (non-2xx) response."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status: int, code: str = "", message: str = ""):
|
|
14
|
+
self.status = status
|
|
15
|
+
self.code = code
|
|
16
|
+
self.message = message
|
|
17
|
+
super().__init__(f"riskkernel API error {status} {code}: {message}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BudgetExceeded(RiskKernelError):
|
|
21
|
+
"""A governed run hit one of its hard budgets (the deterministic governor
|
|
22
|
+
halted it). ``reason`` is the machine-readable HaltReason, e.g.
|
|
23
|
+
``token_budget_exceeded`` or ``loop_budget_exceeded``."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, reason: str, message: str = ""):
|
|
26
|
+
self.reason = reason
|
|
27
|
+
super().__init__(message or f"run halted: {reason}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApprovalDenied(RiskKernelError):
|
|
31
|
+
"""A human denied a side-effecting tool call gated by the approval gate."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, tool: str, reason: str = ""):
|
|
34
|
+
self.tool = tool
|
|
35
|
+
self.reason = reason
|
|
36
|
+
super().__init__(f"approval denied for {tool}" + (f": {reason}" if reason else ""))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApprovalTimeout(RiskKernelError):
|
|
40
|
+
"""No human resolved a pending approval within the configured timeout."""
|
riskkernel/runtime.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""High-level governed-run ergonomics over the thin client.
|
|
2
|
+
|
|
3
|
+
The runtime is still thin: budgets, loop/time enforcement, checkpoints, and
|
|
4
|
+
approval decisions all happen in the Go daemon. This module just makes them
|
|
5
|
+
pleasant to use from Python (context managers, decorators, a current-run var).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextvars
|
|
11
|
+
import functools
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from .client import RiskKernel
|
|
19
|
+
from .errors import ApprovalTimeout
|
|
20
|
+
|
|
21
|
+
# The run currently in scope (set by governed_run), so @governed_tool and
|
|
22
|
+
# checkpoint() can find it without threading it through every call.
|
|
23
|
+
_current_run: contextvars.ContextVar[Optional["Run"]] = contextvars.ContextVar(
|
|
24
|
+
"riskkernel_current_run", default=None
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def current_run() -> Optional["Run"]:
|
|
29
|
+
"""Return the governed run currently in scope, or None."""
|
|
30
|
+
return _current_run.get()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Budget:
|
|
35
|
+
"""Hard per-run limits. Any field left None is unlimited for that dimension."""
|
|
36
|
+
|
|
37
|
+
tokens: Optional[int] = None
|
|
38
|
+
dollars: Optional[float] = None
|
|
39
|
+
loops: Optional[int] = None
|
|
40
|
+
seconds: Optional[int] = None
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
out: dict = {}
|
|
44
|
+
if self.tokens is not None:
|
|
45
|
+
out["tokens"] = self.tokens
|
|
46
|
+
if self.dollars is not None:
|
|
47
|
+
out["dollars"] = self.dollars
|
|
48
|
+
if self.loops is not None:
|
|
49
|
+
out["loops"] = self.loops
|
|
50
|
+
if self.seconds is not None:
|
|
51
|
+
out["seconds"] = self.seconds
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Decision:
|
|
57
|
+
approved: bool
|
|
58
|
+
required: bool = True
|
|
59
|
+
reason: str = ""
|
|
60
|
+
by: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Run:
|
|
64
|
+
"""A governed run bound to a daemon run id."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, client: RiskKernel, data: dict,
|
|
67
|
+
poll_interval: float = 2.0, timeout: Optional[float] = None):
|
|
68
|
+
self._client = client
|
|
69
|
+
self._poll = poll_interval
|
|
70
|
+
self._timeout = timeout
|
|
71
|
+
self.id: str = data["id"]
|
|
72
|
+
self.data = data
|
|
73
|
+
|
|
74
|
+
def step(self) -> int:
|
|
75
|
+
"""Register a loop iteration; raises BudgetExceeded if loop/time budget is spent."""
|
|
76
|
+
return self._client.begin_step(self.id)
|
|
77
|
+
|
|
78
|
+
def checkpoint(self, name: str = "", payload: Optional[dict] = None) -> None:
|
|
79
|
+
self._client.checkpoint(self.id, name, payload)
|
|
80
|
+
|
|
81
|
+
def latest_checkpoint(self) -> Optional[dict]:
|
|
82
|
+
return self._client.latest_checkpoint(self.id)
|
|
83
|
+
|
|
84
|
+
def cancel(self, reason: str = "") -> dict:
|
|
85
|
+
return self._client.cancel(self.id, reason)
|
|
86
|
+
|
|
87
|
+
def status(self) -> dict:
|
|
88
|
+
return self._client.get_run(self.id)
|
|
89
|
+
|
|
90
|
+
def proxy_config(self) -> dict:
|
|
91
|
+
"""Config for routing this run's model calls through the governing proxy.
|
|
92
|
+
Point your LLM client's base URL here and send the header so every call is
|
|
93
|
+
metered, priced, and budget-enforced under this run."""
|
|
94
|
+
return {
|
|
95
|
+
"base_url": self._client.base_url + "/v1",
|
|
96
|
+
"headers": {"X-RiskKernel-Run-Id": self.id},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def approve(self, tool: str, side_effect: str = "", arguments: Optional[dict] = None,
|
|
100
|
+
step_index: int = 0, poll_interval: Optional[float] = None,
|
|
101
|
+
timeout: Optional[float] = None) -> Decision:
|
|
102
|
+
"""Request approval for a tool call, blocking (polling) until a human
|
|
103
|
+
resolves it. Returns a Decision; raises ApprovalTimeout if none arrives."""
|
|
104
|
+
res = self._client.request_approval(self.id, tool, side_effect, arguments, step_index)
|
|
105
|
+
if res.get("status") == "approved":
|
|
106
|
+
return Decision(True, bool(res.get("required", True)))
|
|
107
|
+
approval_id = res["id"]
|
|
108
|
+
interval = poll_interval if poll_interval is not None else self._poll
|
|
109
|
+
limit = timeout if timeout is not None else self._timeout
|
|
110
|
+
deadline = (time.monotonic() + limit) if limit is not None else None
|
|
111
|
+
while True:
|
|
112
|
+
a = self._client.get_approval(approval_id)
|
|
113
|
+
st = a.get("status")
|
|
114
|
+
if st == "approved":
|
|
115
|
+
return Decision(True, True, a.get("reason", ""), a.get("decidedBy", ""))
|
|
116
|
+
if st == "denied":
|
|
117
|
+
return Decision(False, True, a.get("reason", ""), a.get("decidedBy", ""))
|
|
118
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
119
|
+
raise ApprovalTimeout(f"no decision for approval {approval_id} within {limit}s")
|
|
120
|
+
time.sleep(interval)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Runtime:
|
|
124
|
+
"""Entry point: holds a client and default approval-polling settings."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, client: Optional[RiskKernel] = None,
|
|
127
|
+
base_url: str = "http://localhost:7070", token: Optional[str] = None,
|
|
128
|
+
approval_poll_interval: float = 2.0,
|
|
129
|
+
approval_timeout: Optional[float] = None):
|
|
130
|
+
self.client = client or RiskKernel(base_url, token)
|
|
131
|
+
self._poll = approval_poll_interval
|
|
132
|
+
self._timeout = approval_timeout
|
|
133
|
+
|
|
134
|
+
def budget(self, tokens: Optional[int] = None, dollars: Optional[float] = None,
|
|
135
|
+
loops: Optional[int] = None, seconds: Optional[int] = None) -> Budget:
|
|
136
|
+
return Budget(tokens, dollars, loops, seconds)
|
|
137
|
+
|
|
138
|
+
@contextmanager
|
|
139
|
+
def governed_run(self, name: Optional[str] = None,
|
|
140
|
+
budget: Optional[Budget | dict] = None,
|
|
141
|
+
metadata: Optional[dict] = None, cancel_on_error: bool = True):
|
|
142
|
+
"""Context manager that opens a governed run, sets it as the current run,
|
|
143
|
+
and cancels it if the body raises (unless cancel_on_error=False)."""
|
|
144
|
+
b = budget.to_dict() if isinstance(budget, Budget) else budget
|
|
145
|
+
data = self.client.create_run(name=name, budget=b, metadata=metadata)
|
|
146
|
+
run = Run(self.client, data, self._poll, self._timeout)
|
|
147
|
+
token = _current_run.set(run)
|
|
148
|
+
try:
|
|
149
|
+
yield run
|
|
150
|
+
except Exception:
|
|
151
|
+
if cancel_on_error:
|
|
152
|
+
try:
|
|
153
|
+
run.cancel("error")
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
raise
|
|
157
|
+
finally:
|
|
158
|
+
_current_run.reset(token)
|
|
159
|
+
|
|
160
|
+
@contextmanager
|
|
161
|
+
def resume_run(self, run_id: str):
|
|
162
|
+
"""Attach to an existing governed run by id — the resume path after a crash.
|
|
163
|
+
|
|
164
|
+
Unlike governed_run, this neither creates nor cancels the run: the daemon
|
|
165
|
+
reloads non-terminal runs on restart with the budget and usage they had
|
|
166
|
+
already spent, so enforcement continues without re-spending. Fetch
|
|
167
|
+
``run.latest_checkpoint()`` to pick your work back up where it left off::
|
|
168
|
+
|
|
169
|
+
with rt.resume_run(run_id) as run:
|
|
170
|
+
cp = run.latest_checkpoint()
|
|
171
|
+
start = cp["payload"]["cursor"] if cp else 0
|
|
172
|
+
for i in range(start, total):
|
|
173
|
+
run.step() # counts against the SAME budget
|
|
174
|
+
...
|
|
175
|
+
run.checkpoint("step", {"cursor": i + 1})
|
|
176
|
+
|
|
177
|
+
Raises APIError(404) if the run id is unknown.
|
|
178
|
+
"""
|
|
179
|
+
data = self.client.get_run(run_id)
|
|
180
|
+
run = Run(self.client, data, self._poll, self._timeout)
|
|
181
|
+
token = _current_run.set(run)
|
|
182
|
+
try:
|
|
183
|
+
yield run
|
|
184
|
+
finally:
|
|
185
|
+
_current_run.reset(token)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Module-level default runtime, configured from the environment, for the
|
|
189
|
+
# decorator/convenience API.
|
|
190
|
+
_default_runtime: Optional[Runtime] = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def default_runtime() -> Runtime:
|
|
194
|
+
global _default_runtime
|
|
195
|
+
if _default_runtime is None:
|
|
196
|
+
_default_runtime = Runtime(
|
|
197
|
+
base_url=os.environ.get("RISKKERNEL_BASE_URL", "http://localhost:7070"),
|
|
198
|
+
token=os.environ.get("RISKKERNEL_API_TOKEN"),
|
|
199
|
+
)
|
|
200
|
+
return _default_runtime
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def configure(runtime: Runtime) -> None:
|
|
204
|
+
"""Override the module-level default runtime (used by the decorators)."""
|
|
205
|
+
global _default_runtime
|
|
206
|
+
_default_runtime = runtime
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def governed_run(_fn: Optional[Callable] = None, *, name: Optional[str] = None,
|
|
210
|
+
budget: Optional[Budget | dict] = None, runtime: Optional[Runtime] = None):
|
|
211
|
+
"""Decorator: run the wrapped function inside a governed run. The run is
|
|
212
|
+
available via current_run() (and passed as the ``run`` kwarg if the function
|
|
213
|
+
declares one)."""
|
|
214
|
+
|
|
215
|
+
def decorate(fn: Callable) -> Callable:
|
|
216
|
+
@functools.wraps(fn)
|
|
217
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
218
|
+
rt = runtime or default_runtime()
|
|
219
|
+
run_name = name or fn.__name__
|
|
220
|
+
with rt.governed_run(name=run_name, budget=budget) as run:
|
|
221
|
+
if "run" in fn.__code__.co_varnames and "run" not in kwargs:
|
|
222
|
+
kwargs["run"] = run
|
|
223
|
+
return fn(*args, **kwargs)
|
|
224
|
+
return wrapper
|
|
225
|
+
|
|
226
|
+
return decorate(_fn) if _fn is not None else decorate
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: riskkernel
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Thin Python client for the RiskKernel reliability runtime (Surface 2).
|
|
5
|
+
Project-URL: Homepage, https://github.com/prashar32/riskkernel
|
|
6
|
+
Project-URL: Source, https://github.com/prashar32/riskkernel
|
|
7
|
+
Author: Adarsh Prashar
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: agents,budget,governance,guardrails,llm,reliability
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
18
|
+
Provides-Extra: langchain
|
|
19
|
+
Requires-Dist: langchain-core; extra == 'langchain'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# riskkernel (Python SDK)
|
|
23
|
+
|
|
24
|
+
The Python SDK for [RiskKernel](https://github.com/prashar32/riskkernel) — **Surface 2**, deep control over a governed agent run.
|
|
25
|
+
|
|
26
|
+
It is a **thin client** over the self-hosted RiskKernel daemon. Every deterministic
|
|
27
|
+
decision — budgets, loop/time halts, approval policy — happens in the Go core. The
|
|
28
|
+
SDK just makes governed runs ergonomic from Python. **Core install is stdlib-only**
|
|
29
|
+
(no third-party dependencies).
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install riskkernel
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import riskkernel as rk
|
|
39
|
+
|
|
40
|
+
rt = rk.Runtime(base_url="http://localhost:7070") # your daemon
|
|
41
|
+
|
|
42
|
+
with rt.governed_run(name="research",
|
|
43
|
+
budget=rt.budget(dollars=1.00, loops=20, seconds=300)) as run:
|
|
44
|
+
# Route your LLM client through the governing proxy so every model call is
|
|
45
|
+
# metered, priced, and budget-enforced under this run:
|
|
46
|
+
cfg = run.proxy_config()
|
|
47
|
+
# cfg["base_url"] -> http://localhost:7070/v1
|
|
48
|
+
# cfg["headers"] -> {"X-RiskKernel-Run-Id": "<run id>"}
|
|
49
|
+
|
|
50
|
+
for _ in range(100):
|
|
51
|
+
run.step() # raises rk.BudgetExceeded when loops/time run out
|
|
52
|
+
# ... your agent reasoning + tool calls ...
|
|
53
|
+
run.checkpoint("after-step", {"messages": messages})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
When the governor halts the run (token / dollar / loop / time budget), the next
|
|
57
|
+
`run.step()` — or a proxied model call — raises `rk.BudgetExceeded`.
|
|
58
|
+
|
|
59
|
+
## Resume after a crash
|
|
60
|
+
|
|
61
|
+
The daemon reloads non-terminal runs on restart with the budget and usage they had
|
|
62
|
+
already spent, so a `SIGKILL`'d run keeps enforcing without re-spending. Reattach to
|
|
63
|
+
it by id with `resume_run` and pick your work back up from the last checkpoint:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
with rt.resume_run(run_id) as run: # attaches; never creates or cancels
|
|
67
|
+
cp = run.latest_checkpoint() # the state you saved before the crash
|
|
68
|
+
start = cp["payload"]["cursor"] if cp else 0
|
|
69
|
+
for i in range(start, total): # skip the steps you already paid for
|
|
70
|
+
run.step() # counts against the SAME budget
|
|
71
|
+
# ... your work ...
|
|
72
|
+
run.checkpoint("step", {"cursor": i + 1})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The run resumes against whatever budget it had left, so it can't overspend by
|
|
76
|
+
restarting — `run.step()` still raises `rk.BudgetExceeded` at the original ceiling.
|
|
77
|
+
|
|
78
|
+
## Human-in-the-loop tools
|
|
79
|
+
|
|
80
|
+
Gate side-effecting tools on human approval (the daemon's policy decides what needs
|
|
81
|
+
it; the call blocks until a human resolves it via CLI / web / webhook):
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from riskkernel import governed_tool, ApprovalGate
|
|
85
|
+
|
|
86
|
+
@governed_tool(side_effect="write")
|
|
87
|
+
def write_file(path, content):
|
|
88
|
+
... # only runs if approved; else rk.ApprovalDenied
|
|
89
|
+
|
|
90
|
+
# or explicitly:
|
|
91
|
+
gate = ApprovalGate(run)
|
|
92
|
+
if gate.allow("mcp://shell", side_effect="exec", arguments={"cmd": cmd}):
|
|
93
|
+
run_shell(cmd)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Framework adapters
|
|
97
|
+
|
|
98
|
+
Lazy-imported, so you only pay for what you use:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
# LangChain / LangGraph — enforces loop/time budgets per LLM call
|
|
102
|
+
from riskkernel.adapters.langchain import RiskKernelCallbackHandler
|
|
103
|
+
llm.invoke(prompt, config={"callbacks": [RiskKernelCallbackHandler(run)]})
|
|
104
|
+
|
|
105
|
+
# Claude Agent SDK — PreToolUse approval hook
|
|
106
|
+
from riskkernel.adapters.claude_agent import make_pre_tool_use_hook
|
|
107
|
+
hook = make_pre_tool_use_hook(run, side_effect_for={"Bash": "exec", "Write": "write"})
|
|
108
|
+
|
|
109
|
+
# OpenAI Agents SDK — RunHooks (steps + tool approval)
|
|
110
|
+
from riskkernel.adapters.openai_agents import RiskKernelRunHooks
|
|
111
|
+
hooks = RiskKernelRunHooks(run, gate_tools=True)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
`Runtime(base_url=..., token=...)`, or the env vars `RISKKERNEL_BASE_URL` and
|
|
117
|
+
`RISKKERNEL_API_TOKEN` (used by the decorator/convenience API and `default_runtime()`).
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
Apache-2.0.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
riskkernel/__init__.py,sha256=Kco_JE7sEnJHttf5p88N2QncDmZ6q4WlJki6yZwFu2k,1466
|
|
2
|
+
riskkernel/approval.py,sha256=yN9_hgIgJ5D_23h73kOXffegKELadbsTle4cDcJewJs,3203
|
|
3
|
+
riskkernel/client.py,sha256=T5F2yblkdjwG4sTfp0F-6ilNOlIy_QOhzuMtYMW4Ye4,6006
|
|
4
|
+
riskkernel/errors.py,sha256=8SLENoCO4we60t1-cw_zJKdSmSCQCAPwZuF0d0jCRKk,1331
|
|
5
|
+
riskkernel/runtime.py,sha256=tcqsFlX6SatMAd0yKswQ1iWLgjup3kkAzEiVEODHPDI,8781
|
|
6
|
+
riskkernel/adapters/__init__.py,sha256=AMTzTVzuPfeKTJe9iqL4bvU7Ox_UfLaIXsI6ZKYRcoM,469
|
|
7
|
+
riskkernel/adapters/claude_agent.py,sha256=LbMeI1TEVC8I9p3dPW1lT8GiQZohwnIb4lAJsR89NVQ,2844
|
|
8
|
+
riskkernel/adapters/langchain.py,sha256=9gnQgKHL9yP5tw7YUB1m5aJ-nyeTCMYgunF2M1sP3Q8,3119
|
|
9
|
+
riskkernel/adapters/openai_agents.py,sha256=xUkbJO7cQkAjs0wnUWLkBVZPIvoXmzel90yTzj_OXcc,1843
|
|
10
|
+
riskkernel-0.3.0.dist-info/METADATA,sha256=iH-iK4jyXc9u4blA5yMcHBjkbt2ccKuhcjO_RV1GFkE,4554
|
|
11
|
+
riskkernel-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
riskkernel-0.3.0.dist-info/RECORD,,
|