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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any