runbound 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.
- runbound/__init__.py +89 -0
- runbound/_coverage.py +612 -0
- runbound/alerts.py +149 -0
- runbound/api.py +3016 -0
- runbound/autowrap.py +159 -0
- runbound/circuit.py +464 -0
- runbound/config.py +927 -0
- runbound/detectors.py +1118 -0
- runbound/engine.py +1657 -0
- runbound/events.py +152 -0
- runbound/exceptions.py +67 -0
- runbound/export.py +456 -0
- runbound/integrations/__init__.py +6 -0
- runbound/integrations/langchain.py +237 -0
- runbound/ladder.py +242 -0
- runbound/plane.py +329 -0
- runbound/plane_types.py +582 -0
- runbound/policy.py +684 -0
- runbound/pricing.py +425 -0
- runbound/quota.py +392 -0
- runbound/responses.py +381 -0
- runbound/shared.py +1370 -0
- runbound/state.py +383 -0
- runbound/wrappers/__init__.py +1385 -0
- runbound/wrappers/anthropic_wrapper.py +458 -0
- runbound/wrappers/openai_wrapper.py +690 -0
- runbound-0.3.0.dist-info/METADATA +198 -0
- runbound-0.3.0.dist-info/RECORD +31 -0
- runbound-0.3.0.dist-info/WHEEL +5 -0
- runbound-0.3.0.dist-info/licenses/LICENSE +21 -0
- runbound-0.3.0.dist-info/top_level.txt +1 -0
runbound/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""runbound — deterministic, LLM-free runaway detection for AI agents.
|
|
2
|
+
|
|
3
|
+
Three lines to guard an agent::
|
|
4
|
+
|
|
5
|
+
import runbound
|
|
6
|
+
|
|
7
|
+
runbound.init(budget_usd=5.0, max_steps=50, on_anomaly="raise")
|
|
8
|
+
client = runbound.wrap(client) # OpenAI- or Anthropic-shaped
|
|
9
|
+
|
|
10
|
+
@runbound.tool # every tool call is recorded
|
|
11
|
+
def search(query): ...
|
|
12
|
+
|
|
13
|
+
Fail-open by design: runbound's own bugs are logged and swallowed, and the
|
|
14
|
+
only exception it raises on purpose is :class:`GuardrailTripped`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .alerts import verify_webhook_signature
|
|
18
|
+
from .api import (
|
|
19
|
+
active_sessions,
|
|
20
|
+
assert_guarded,
|
|
21
|
+
circuit_state,
|
|
22
|
+
clear,
|
|
23
|
+
coverage,
|
|
24
|
+
current_session,
|
|
25
|
+
fleet_status,
|
|
26
|
+
inflight_calls,
|
|
27
|
+
init,
|
|
28
|
+
is_tripped,
|
|
29
|
+
key_hash,
|
|
30
|
+
llm,
|
|
31
|
+
plane_status,
|
|
32
|
+
record_call,
|
|
33
|
+
reset,
|
|
34
|
+
session,
|
|
35
|
+
session_status,
|
|
36
|
+
tool,
|
|
37
|
+
tool_calls,
|
|
38
|
+
tools,
|
|
39
|
+
unpatch,
|
|
40
|
+
wrap,
|
|
41
|
+
)
|
|
42
|
+
from .config import GuardrailConfig
|
|
43
|
+
from .events import Anomaly, Event
|
|
44
|
+
from .exceptions import CircuitOpen, GuardrailTripped, PolicyViolation
|
|
45
|
+
from .plane_types import PlaneStatus
|
|
46
|
+
from .policy import ToolCall, ToolPolicy, Violation
|
|
47
|
+
from .responses import Refusal
|
|
48
|
+
from .state import SessionState
|
|
49
|
+
|
|
50
|
+
__version__ = "0.3.0"
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"Anomaly",
|
|
54
|
+
"CircuitOpen",
|
|
55
|
+
"Event",
|
|
56
|
+
"GuardrailConfig",
|
|
57
|
+
"GuardrailTripped",
|
|
58
|
+
"PlaneStatus",
|
|
59
|
+
"PolicyViolation",
|
|
60
|
+
"Refusal",
|
|
61
|
+
"SessionState",
|
|
62
|
+
"ToolCall",
|
|
63
|
+
"ToolPolicy",
|
|
64
|
+
"Violation",
|
|
65
|
+
"__version__",
|
|
66
|
+
"active_sessions",
|
|
67
|
+
"assert_guarded",
|
|
68
|
+
"circuit_state",
|
|
69
|
+
"clear",
|
|
70
|
+
"coverage",
|
|
71
|
+
"current_session",
|
|
72
|
+
"fleet_status",
|
|
73
|
+
"inflight_calls",
|
|
74
|
+
"init",
|
|
75
|
+
"is_tripped",
|
|
76
|
+
"key_hash",
|
|
77
|
+
"llm",
|
|
78
|
+
"plane_status",
|
|
79
|
+
"record_call",
|
|
80
|
+
"reset",
|
|
81
|
+
"session",
|
|
82
|
+
"session_status",
|
|
83
|
+
"tool",
|
|
84
|
+
"tool_calls",
|
|
85
|
+
"tools",
|
|
86
|
+
"unpatch",
|
|
87
|
+
"verify_webhook_signature",
|
|
88
|
+
"wrap",
|
|
89
|
+
]
|
runbound/_coverage.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""What runbound can actually see, counted.
|
|
2
|
+
|
|
3
|
+
``init()`` configures detectors; it does not observe anything. The sensors are
|
|
4
|
+
:func:`runbound.wrap` (and :mod:`runbound.autowrap`), ``@runbound.tool``,
|
|
5
|
+
:func:`runbound.session` and :func:`runbound.record_call` — and a process
|
|
6
|
+
that installed runbound but wired up none of them is *blind while reporting
|
|
7
|
+
green*. This module is the honest answer to "is anything actually being
|
|
8
|
+
watched?": a handful of counters, one snapshot, and one warning that fires when
|
|
9
|
+
a provider SDK is imported and yet no guarded call has ever been seen.
|
|
10
|
+
|
|
11
|
+
It also builds the **tool report** — which tools this process has, taken from
|
|
12
|
+
the ``@runbound.tool`` decorators at import time and from the names the model
|
|
13
|
+
asks for, sent on the heartbeat and shown by :func:`runbound.tools`.
|
|
14
|
+
|
|
15
|
+
Private on purpose — the public surface is ``runbound.coverage()``, a
|
|
16
|
+
function. A submodule named ``coverage`` would be bound onto the package by any
|
|
17
|
+
``import runbound.coverage`` and would silently replace that function.
|
|
18
|
+
|
|
19
|
+
Everything here is best-effort: a counter that cannot be bumped costs a number
|
|
20
|
+
in a report, never a call. Counters are process-lifetime — ``init()`` and
|
|
21
|
+
``reset()`` leave them alone, because "has this process ever seen traffic?" is
|
|
22
|
+
not a question a new session re-asks.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import inspect
|
|
27
|
+
import types
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any, Callable, Sequence
|
|
34
|
+
|
|
35
|
+
_LOG = logging.getLogger("runbound")
|
|
36
|
+
|
|
37
|
+
#: Modules whose presence in ``sys.modules`` means this process talks to an LLM.
|
|
38
|
+
#: The first two runbound can guard; the rest it cannot, which is exactly why
|
|
39
|
+
#: they are listed — an imported ``boto3`` is an honest blind spot, not a gap in
|
|
40
|
+
#: the report.
|
|
41
|
+
PROVIDER_MODULES = (
|
|
42
|
+
"openai",
|
|
43
|
+
"anthropic",
|
|
44
|
+
"google.generativeai",
|
|
45
|
+
"google.genai",
|
|
46
|
+
"boto3",
|
|
47
|
+
"mistralai",
|
|
48
|
+
"cohere",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: Provider module -> the wrapper shape whose guarded calls cover it. A module
|
|
52
|
+
#: absent from this map has no wrapper at all, so importing it is always a
|
|
53
|
+
#: blind spot however much other traffic is guarded.
|
|
54
|
+
_GUARDABLE = {"openai": "openai", "anthropic": "anthropic"}
|
|
55
|
+
|
|
56
|
+
#: The name every silence timer runs under, so a leak is countable.
|
|
57
|
+
TIMER_NAME = "runbound-coverage"
|
|
58
|
+
|
|
59
|
+
#: How long the warning quotes when nobody configured a window.
|
|
60
|
+
DEFAULT_CHECK_SECONDS = 60.0
|
|
61
|
+
|
|
62
|
+
#: How many distinct decorated-tool names :func:`snapshot` ever reports. A
|
|
63
|
+
#: fleet dashboard wants real names to tell its story, not an unbounded list
|
|
64
|
+
#: growing with every dynamically-named tool a long-lived process ever saw.
|
|
65
|
+
DECORATED_TOOL_NAMES_MAX = 32
|
|
66
|
+
|
|
67
|
+
#: How many tools a report ever carries. Beyond this the report is truncated
|
|
68
|
+
#: (sorted by name) and a debug line is logged once.
|
|
69
|
+
TOOL_REPORT_MAX = 500
|
|
70
|
+
|
|
71
|
+
_LOCK = threading.Lock()
|
|
72
|
+
|
|
73
|
+
_WRAPPED_CLIENTS = 0
|
|
74
|
+
_DECORATED_TOOLS = 0
|
|
75
|
+
_TOOL_CALLS = 0
|
|
76
|
+
_KEYED_SESSIONS = 0
|
|
77
|
+
_GUARDED_CALLS = 0
|
|
78
|
+
_LAST_GUARDED_AT: float | None = None
|
|
79
|
+
_SHAPES_SEEN: set[str] = set()
|
|
80
|
+
_DECORATED_TOOL_NAMES: set[str] = set()
|
|
81
|
+
_TIMER: threading.Timer | None = None
|
|
82
|
+
|
|
83
|
+
#: name -> report entry. A second, richer store beside ``_DECORATED_TOOL_NAMES``
|
|
84
|
+
#: — that one answers "how many, and which names" for the coverage snapshot and
|
|
85
|
+
#: keeps its own small cap; this one is what the console draws a tool inventory
|
|
86
|
+
#: from, so it carries the signature too.
|
|
87
|
+
_TOOLS: dict[str, dict] = {}
|
|
88
|
+
_TRUNCATION_LOGGED = False
|
|
89
|
+
|
|
90
|
+
#: name -> the :class:`runbound.policy.ToolRules` its ``@runbound.tool`` stated,
|
|
91
|
+
#: for every decorated tool including the ones that state nothing. The engine
|
|
92
|
+
#: folds this into the policy it enforces (``Engine._local_policy``), so unlike
|
|
93
|
+
#: everything else in this module it holds the customer's own callables — and
|
|
94
|
+
#: for that reason it is *not* the store the report is built from: the report
|
|
95
|
+
#: reads ``_TOOLS``, where the same rules live rendered as strings.
|
|
96
|
+
#:
|
|
97
|
+
#: Same lifetime as :data:`_DECORATED_TOOL_NAMES`: written at import, read for
|
|
98
|
+
#: the life of the process, emptied only by :func:`reset_for_tests`. Decorators
|
|
99
|
+
#: run long after ``init()`` in a normal app, so the engine reads it live and
|
|
100
|
+
#: caches on :data:`_TOOL_RULES_VERSION`, which every write below bumps.
|
|
101
|
+
_TOOL_RULES: dict = {}
|
|
102
|
+
_TOOL_RULES_VERSION = 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def client_wrapped() -> None:
|
|
106
|
+
"""One more client guarded by :func:`runbound.wrap`."""
|
|
107
|
+
global _WRAPPED_CLIENTS
|
|
108
|
+
try:
|
|
109
|
+
with _LOCK:
|
|
110
|
+
_WRAPPED_CLIENTS += 1
|
|
111
|
+
except Exception: # pragma: no cover - a counter never costs a call
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def tool_decorated(name: str | None = None) -> None:
|
|
116
|
+
"""One more function guarded by ``@runbound.tool``.
|
|
117
|
+
|
|
118
|
+
``name`` is optional and additive: existing callers that pass none still
|
|
119
|
+
bump the counter exactly as before. When given, it is remembered (up to
|
|
120
|
+
:data:`DECORATED_TOOL_NAMES_MAX` distinct names) so a fleet dashboard can
|
|
121
|
+
show which tools a service actually has, not just how many.
|
|
122
|
+
"""
|
|
123
|
+
global _DECORATED_TOOLS
|
|
124
|
+
try:
|
|
125
|
+
with _LOCK:
|
|
126
|
+
_DECORATED_TOOLS += 1
|
|
127
|
+
if isinstance(name, str) and name and len(_DECORATED_TOOL_NAMES) < DECORATED_TOOL_NAMES_MAX:
|
|
128
|
+
_DECORATED_TOOL_NAMES.add(name)
|
|
129
|
+
except Exception: # pragma: no cover
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def tool_called() -> None:
|
|
134
|
+
"""One more decorated tool call attempted."""
|
|
135
|
+
global _TOOL_CALLS
|
|
136
|
+
try:
|
|
137
|
+
with _LOCK:
|
|
138
|
+
_TOOL_CALLS += 1
|
|
139
|
+
except Exception: # pragma: no cover
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def session_started() -> None:
|
|
144
|
+
"""One more key registered for the first time.
|
|
145
|
+
|
|
146
|
+
A count, not a set: keeping the keys would mean a second, unbounded copy of
|
|
147
|
+
caller identifiers outliving the registry that evicts them. The trade is
|
|
148
|
+
stated rather than hidden — a key whose session was evicted and then
|
|
149
|
+
re-entered counts again.
|
|
150
|
+
"""
|
|
151
|
+
global _KEYED_SESSIONS
|
|
152
|
+
try:
|
|
153
|
+
with _LOCK:
|
|
154
|
+
_KEYED_SESSIONS += 1
|
|
155
|
+
except Exception: # pragma: no cover
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def guarded_call() -> None:
|
|
160
|
+
"""One more model call runbound saw, however it turned out.
|
|
161
|
+
|
|
162
|
+
A failed call counts: the question this answers is whether the sensors are
|
|
163
|
+
wired, and a call that reached the provider and came back an error is proof
|
|
164
|
+
that they are.
|
|
165
|
+
"""
|
|
166
|
+
global _GUARDED_CALLS, _LAST_GUARDED_AT
|
|
167
|
+
try:
|
|
168
|
+
with _LOCK:
|
|
169
|
+
_GUARDED_CALLS += 1
|
|
170
|
+
_LAST_GUARDED_AT = time.monotonic()
|
|
171
|
+
except Exception: # pragma: no cover
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def provider_seen(provider: str) -> None:
|
|
176
|
+
"""Note the shape of an endpoint a guarded call reached.
|
|
177
|
+
|
|
178
|
+
``provider`` is an endpoint label — ``"openai@localhost:11434"`` — and only
|
|
179
|
+
the shape before the ``@`` says which SDK was covered.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
shape = str(provider).split("@", 1)[0].strip()
|
|
183
|
+
if shape:
|
|
184
|
+
with _LOCK:
|
|
185
|
+
_SHAPES_SEEN.add(shape)
|
|
186
|
+
except Exception: # pragma: no cover
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def providers_imported() -> list[str]:
|
|
191
|
+
"""Every known provider SDK this process has imported, in listed order."""
|
|
192
|
+
try:
|
|
193
|
+
return [name for name in PROVIDER_MODULES if name in sys.modules]
|
|
194
|
+
except Exception: # pragma: no cover
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def providers_unguarded(imported: Sequence[str] | None = None) -> list[str]:
|
|
199
|
+
"""Imported provider SDKs that no guarded call has ever covered.
|
|
200
|
+
|
|
201
|
+
A provider runbound has no wrapper for — Gemini, Bedrock, Mistral, Cohere
|
|
202
|
+
— is listed the moment it is imported and never leaves the list, because
|
|
203
|
+
importing it is the blind spot. ``openai`` and ``anthropic`` leave it as
|
|
204
|
+
soon as one guarded call reaches an endpoint of that shape.
|
|
205
|
+
"""
|
|
206
|
+
names = providers_imported() if imported is None else list(imported)
|
|
207
|
+
with _LOCK:
|
|
208
|
+
seen = set(_SHAPES_SEEN)
|
|
209
|
+
return [name for name in names if _GUARDABLE.get(name) not in seen]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- the tool report --------------------------------------------------------
|
|
213
|
+
#
|
|
214
|
+
# What tools this process has, built from the code that declares them rather
|
|
215
|
+
# than from a file anyone writes, so it cannot drift from the code. Names,
|
|
216
|
+
# parameter names, annotations rendered as strings and one line of docstring
|
|
217
|
+
# leave the process; an argument value, a default value or a return value never
|
|
218
|
+
# does.
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def tool_declared(name: str, func: Callable | None = None, rules=None) -> None:
|
|
222
|
+
"""Remember a tool the code declares, with its signature. Never raises.
|
|
223
|
+
|
|
224
|
+
``rules`` is the :class:`runbound.policy.ToolRules` the decorator stated.
|
|
225
|
+
It is kept twice on purpose: rendered as strings in the report entry, which
|
|
226
|
+
goes over the wire, and as itself in :data:`_TOOL_RULES`, which never does
|
|
227
|
+
and is what the engine folds into the policy it enforces.
|
|
228
|
+
"""
|
|
229
|
+
global _TOOL_RULES_VERSION
|
|
230
|
+
try:
|
|
231
|
+
if not isinstance(name, str) or not name:
|
|
232
|
+
return
|
|
233
|
+
entry = _tool_entry(name, func, rules)
|
|
234
|
+
with _LOCK:
|
|
235
|
+
_TOOLS[name] = entry
|
|
236
|
+
if rules is not None:
|
|
237
|
+
_TOOL_RULES[name] = rules
|
|
238
|
+
_TOOL_RULES_VERSION += 1
|
|
239
|
+
except Exception: # pragma: no cover - a report never costs a call
|
|
240
|
+
_LOG.debug("runbound: could not record the tool %r", name, exc_info=True)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def tool_rules_version() -> int:
|
|
244
|
+
"""How many times the rule registry has changed, ever.
|
|
245
|
+
|
|
246
|
+
A plain int read, no lock: the engine checks it before every tool call to
|
|
247
|
+
decide whether the policy it cached is still the policy the decorators
|
|
248
|
+
describe, and taking a lock for that would put one on the call path.
|
|
249
|
+
"""
|
|
250
|
+
return _TOOL_RULES_VERSION
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def tool_rules() -> dict:
|
|
254
|
+
"""Every decorated tool's stated rules, name -> ``ToolRules``. A fresh copy."""
|
|
255
|
+
with _LOCK:
|
|
256
|
+
return dict(_TOOL_RULES)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def unruled_tools() -> list[str]:
|
|
260
|
+
"""Decorated tools that state no rule at all, sorted — what ``require_rules`` names."""
|
|
261
|
+
for_check = tool_rules()
|
|
262
|
+
return sorted(name for name, rule in for_check.items() if not rule.stated())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def tool_requested(name: str) -> None:
|
|
266
|
+
"""Remember a tool name the *model* asked for. Never raises.
|
|
267
|
+
|
|
268
|
+
A name that no ``@runbound.tool`` declared stays ``decorated: False`` --
|
|
269
|
+
the model can ask for it and nothing guards it, which the console shows
|
|
270
|
+
in red. A name already declared is left exactly as it is.
|
|
271
|
+
|
|
272
|
+
Bounded by :data:`TOOL_REPORT_MAX`: these names come from a model, not from
|
|
273
|
+
the code, so a long-lived process asked for endlessly invented tools must
|
|
274
|
+
not grow a dict forever. Past the cap a new undecorated name is dropped,
|
|
275
|
+
which is what truncating the report would have done to it anyway.
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
if not isinstance(name, str) or not name:
|
|
279
|
+
return
|
|
280
|
+
with _LOCK:
|
|
281
|
+
if name in _TOOLS or len(_TOOLS) >= TOOL_REPORT_MAX:
|
|
282
|
+
return
|
|
283
|
+
_TOOLS[name] = {
|
|
284
|
+
"name": name,
|
|
285
|
+
"decorated": False,
|
|
286
|
+
"params": [],
|
|
287
|
+
"doc": None,
|
|
288
|
+
"module": None,
|
|
289
|
+
"rules": {},
|
|
290
|
+
}
|
|
291
|
+
except Exception: # pragma: no cover
|
|
292
|
+
_LOG.debug("runbound: could not record the request for %r", name, exc_info=True)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def tool_report() -> list[dict]:
|
|
296
|
+
"""Every tool this process knows, sorted by name, capped at TOOL_REPORT_MAX.
|
|
297
|
+
|
|
298
|
+
A fresh copy each call, so a caller that edits what it got back — a test, a
|
|
299
|
+
REPL, a payload builder — cannot edit what the next heartbeat sends. Fails
|
|
300
|
+
open to ``[]``: a report that cannot be built is worth a debug line, never
|
|
301
|
+
an exception in a customer's process.
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
with _LOCK:
|
|
305
|
+
entries = sorted(_TOOLS.values(), key=lambda entry: entry["name"])
|
|
306
|
+
if len(entries) > TOOL_REPORT_MAX:
|
|
307
|
+
_log_truncation(len(entries))
|
|
308
|
+
entries = entries[:TOOL_REPORT_MAX]
|
|
309
|
+
return [_copy_entry(entry) for entry in entries]
|
|
310
|
+
except Exception: # pragma: no cover
|
|
311
|
+
_LOG.debug("runbound: could not build the tool report", exc_info=True)
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def tool_report_hash(report: list[dict] | None = None) -> str:
|
|
316
|
+
"""A stable short digest of a report: sha256 of the canonical JSON, 16 hex chars.
|
|
317
|
+
|
|
318
|
+
Stable across processes and across runs — it is what tells the plane
|
|
319
|
+
"nothing about my tools changed", so it must not depend on dict ordering or
|
|
320
|
+
on anything with an address in it. Raises only for a report that is not
|
|
321
|
+
JSON-serialisable, which is nothing :func:`tool_report` ever returns.
|
|
322
|
+
"""
|
|
323
|
+
entries = tool_report() if report is None else report
|
|
324
|
+
canonical = json.dumps(entries, sort_keys=True, separators=(",", ":"), default=str)
|
|
325
|
+
return hashlib.sha256(canonical.encode("utf-8", "replace")).hexdigest()[:16]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _copy_entry(entry: dict) -> dict:
|
|
329
|
+
"""One entry, deep enough that nothing shared stays shared."""
|
|
330
|
+
return {
|
|
331
|
+
**entry,
|
|
332
|
+
"params": [dict(param) for param in entry["params"]],
|
|
333
|
+
"rules": dict(entry.get("rules") or {}),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _log_truncation(total: int) -> None:
|
|
338
|
+
"""Say once that a report is being cut short. Repeating it every 5s helps nobody."""
|
|
339
|
+
global _TRUNCATION_LOGGED
|
|
340
|
+
with _LOCK:
|
|
341
|
+
if _TRUNCATION_LOGGED:
|
|
342
|
+
return
|
|
343
|
+
_TRUNCATION_LOGGED = True
|
|
344
|
+
_LOG.debug(
|
|
345
|
+
"runbound knows %d tools and reports the first %d by name",
|
|
346
|
+
total,
|
|
347
|
+
TOOL_REPORT_MAX,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _tool_entry(name: str, func: Callable | None, rules=None) -> dict:
|
|
352
|
+
"""The six keys the plane reads, in the order it reads them.
|
|
353
|
+
|
|
354
|
+
``rules`` is what the decorator stated, rendered by
|
|
355
|
+
:meth:`runbound.policy.ToolRules.as_report` — a predicate as its
|
|
356
|
+
``"module:qualname"``, never the predicate. A tool with no rule reports an
|
|
357
|
+
empty dict, so the console can tell "no rule" from "not reported".
|
|
358
|
+
"""
|
|
359
|
+
return {
|
|
360
|
+
"name": name,
|
|
361
|
+
"decorated": True,
|
|
362
|
+
"params": _params(func),
|
|
363
|
+
"doc": _first_doc_line(func),
|
|
364
|
+
"module": _module(func),
|
|
365
|
+
"rules": _rules_report(rules),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _rules_report(rules) -> dict:
|
|
370
|
+
"""``rules.as_report()``, or ``{}`` for anything that will not render itself."""
|
|
371
|
+
if rules is None:
|
|
372
|
+
return {}
|
|
373
|
+
try:
|
|
374
|
+
return dict(rules.as_report())
|
|
375
|
+
except Exception: # pragma: no cover - a report never costs a call
|
|
376
|
+
_LOG.debug("runbound: could not render a tool's rules", exc_info=True)
|
|
377
|
+
return {}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _params(func: Callable | None) -> list[dict]:
|
|
381
|
+
"""A tool's parameters, or ``[]`` when the callable does not describe itself.
|
|
382
|
+
|
|
383
|
+
Builtins, C functions and exotic wrappers raise from
|
|
384
|
+
:func:`inspect.signature`; they are still tools this process has, so they
|
|
385
|
+
are reported with no parameters rather than not reported at all.
|
|
386
|
+
"""
|
|
387
|
+
if func is None:
|
|
388
|
+
return []
|
|
389
|
+
try:
|
|
390
|
+
parameters = list(inspect.signature(func).parameters.values())
|
|
391
|
+
except Exception:
|
|
392
|
+
_LOG.debug("runbound: %r does not describe its signature", func, exc_info=True)
|
|
393
|
+
return []
|
|
394
|
+
if parameters and parameters[0].name in ("self", "cls"):
|
|
395
|
+
parameters = parameters[1:]
|
|
396
|
+
return [_param_entry(parameter) for parameter in parameters]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _param_entry(parameter: inspect.Parameter) -> dict:
|
|
400
|
+
"""One parameter: its name as written, its annotation, whether it must be given.
|
|
401
|
+
|
|
402
|
+
``required`` is "has no default" — never the default *value*, which is the
|
|
403
|
+
customer's data and stays in the customer's process.
|
|
404
|
+
"""
|
|
405
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
406
|
+
name, required = "*" + parameter.name, False
|
|
407
|
+
elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
408
|
+
name, required = "**" + parameter.name, False
|
|
409
|
+
else:
|
|
410
|
+
name = parameter.name
|
|
411
|
+
required = parameter.default is inspect.Parameter.empty
|
|
412
|
+
return {
|
|
413
|
+
"name": name,
|
|
414
|
+
"annotation": _annotation(parameter.annotation),
|
|
415
|
+
"required": required,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _annotation(annotation: Any) -> str | None:
|
|
420
|
+
"""An annotation as the source that wrote it, or ``None`` when there is none.
|
|
421
|
+
|
|
422
|
+
A class renders as its bare name (``str``, ``float``), everything else as
|
|
423
|
+
its own text (``list[int]``, ``str | None``) — the same rendering
|
|
424
|
+
:class:`inspect.Signature` uses when it prints itself. Anything whose text
|
|
425
|
+
carries a memory address is dropped instead: an entry whose hash changed on
|
|
426
|
+
every restart would resend the whole report on every deploy of every
|
|
427
|
+
worker, and tell the console nothing it could use.
|
|
428
|
+
"""
|
|
429
|
+
if annotation is inspect.Parameter.empty:
|
|
430
|
+
return None
|
|
431
|
+
if isinstance(annotation, str):
|
|
432
|
+
text = annotation
|
|
433
|
+
elif isinstance(annotation, type) and not isinstance(annotation, types.GenericAlias):
|
|
434
|
+
# On Python 3.10 ``isinstance(list[int], type)`` is True (3.11+ says
|
|
435
|
+
# False), and taking ``__qualname__`` there prints ``list`` for
|
|
436
|
+
# ``list[int]`` -- found by the public repo's 3.10 CI leg, 2026-09-15.
|
|
437
|
+
text = annotation.__qualname__
|
|
438
|
+
else:
|
|
439
|
+
text = str(annotation)
|
|
440
|
+
return None if not text or " at 0x" in text else text
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _first_doc_line(func: Callable | None) -> str | None:
|
|
444
|
+
"""The docstring's first non-empty line, stripped, or ``None``.
|
|
445
|
+
|
|
446
|
+
One line and never more, whatever the docstring holds — a privacy rule, not
|
|
447
|
+
a formatting preference: what follows the summary is where people put
|
|
448
|
+
hostnames, credentials and customer examples.
|
|
449
|
+
"""
|
|
450
|
+
try:
|
|
451
|
+
doc = getattr(func, "__doc__", None)
|
|
452
|
+
except Exception:
|
|
453
|
+
return None
|
|
454
|
+
if not isinstance(doc, str):
|
|
455
|
+
return None
|
|
456
|
+
for line in doc.splitlines():
|
|
457
|
+
stripped = line.strip()
|
|
458
|
+
if stripped:
|
|
459
|
+
return stripped
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _module(func: Callable | None) -> str | None:
|
|
464
|
+
"""Where the tool is defined, or ``None`` when the callable will not say."""
|
|
465
|
+
module = getattr(func, "__module__", None)
|
|
466
|
+
return module if isinstance(module, str) and module else None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def snapshot(
|
|
470
|
+
auto_wrapped: Sequence[str] = (),
|
|
471
|
+
*,
|
|
472
|
+
auto_wrap: bool = True,
|
|
473
|
+
seconds: float = DEFAULT_CHECK_SECONDS,
|
|
474
|
+
) -> dict:
|
|
475
|
+
"""Everything the counters know, in one dict — see ``runbound.coverage``."""
|
|
476
|
+
imported = providers_imported()
|
|
477
|
+
with _LOCK:
|
|
478
|
+
last = _LAST_GUARDED_AT
|
|
479
|
+
report = {
|
|
480
|
+
"auto_wrapped": list(auto_wrapped),
|
|
481
|
+
"wrapped_clients": _WRAPPED_CLIENTS,
|
|
482
|
+
"decorated_tools": _DECORATED_TOOLS,
|
|
483
|
+
"decorated_tool_names": sorted(_DECORATED_TOOL_NAMES),
|
|
484
|
+
"guarded_calls": _GUARDED_CALLS,
|
|
485
|
+
"tool_calls_seen": _TOOL_CALLS,
|
|
486
|
+
"keyed_sessions_seen": _KEYED_SESSIONS,
|
|
487
|
+
"providers_imported": imported,
|
|
488
|
+
}
|
|
489
|
+
report["providers_unguarded"] = providers_unguarded(imported)
|
|
490
|
+
report["last_guarded_call_age_s"] = (
|
|
491
|
+
None if last is None else max(time.monotonic() - last, 0.0)
|
|
492
|
+
)
|
|
493
|
+
warning = silence_warning(auto_wrapped, auto_wrap=auto_wrap, seconds=seconds)
|
|
494
|
+
report["warnings"] = [] if warning is None else [warning]
|
|
495
|
+
return report
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def zeros() -> dict:
|
|
499
|
+
"""The shape of a snapshot with nothing in it. What a failure reports."""
|
|
500
|
+
return {
|
|
501
|
+
"auto_wrapped": [],
|
|
502
|
+
"wrapped_clients": 0,
|
|
503
|
+
"decorated_tools": 0,
|
|
504
|
+
"decorated_tool_names": [],
|
|
505
|
+
"guarded_calls": 0,
|
|
506
|
+
"tool_calls_seen": 0,
|
|
507
|
+
"keyed_sessions_seen": 0,
|
|
508
|
+
"providers_imported": [],
|
|
509
|
+
"providers_unguarded": [],
|
|
510
|
+
"last_guarded_call_age_s": None,
|
|
511
|
+
"warnings": [],
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def silence_warning(
|
|
516
|
+
auto_wrapped: Sequence[str] = (),
|
|
517
|
+
*,
|
|
518
|
+
auto_wrap: bool = True,
|
|
519
|
+
seconds: float = DEFAULT_CHECK_SECONDS,
|
|
520
|
+
) -> str | None:
|
|
521
|
+
"""The "nothing is guarded" text, or ``None`` when something is.
|
|
522
|
+
|
|
523
|
+
One string with three consumers — the timer logs it at WARNING,
|
|
524
|
+
:func:`runbound.assert_guarded` raises it, and
|
|
525
|
+
:func:`runbound.coverage` reports it — so a customer who meets this
|
|
526
|
+
problem in a log, in a test and in a dashboard meets one sentence.
|
|
527
|
+
|
|
528
|
+
``None`` when a guarded call has been seen (the sensors work) or when no
|
|
529
|
+
provider SDK is imported at all (there is no traffic to miss).
|
|
530
|
+
"""
|
|
531
|
+
try:
|
|
532
|
+
with _LOCK:
|
|
533
|
+
calls = _GUARDED_CALLS
|
|
534
|
+
if calls:
|
|
535
|
+
return None
|
|
536
|
+
imported = providers_imported()
|
|
537
|
+
if not imported:
|
|
538
|
+
return None
|
|
539
|
+
names = ", ".join(repr(name) for name in imported)
|
|
540
|
+
verb = "is" if len(imported) == 1 else "are"
|
|
541
|
+
return (
|
|
542
|
+
f"runbound sees no LLM traffic after {seconds:.0f}s although {names} "
|
|
543
|
+
f"{verb} imported — nothing is guarded. Did you call "
|
|
544
|
+
f"runbound.wrap(client)? ({_auto_wrap_state(auto_wrap, auto_wrapped)})"
|
|
545
|
+
)
|
|
546
|
+
except Exception: # pragma: no cover - a report never breaks a host
|
|
547
|
+
_LOG.debug("runbound: could not work out the coverage warning", exc_info=True)
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _auto_wrap_state(auto_wrap: bool, auto_wrapped: Sequence[str]) -> str:
|
|
552
|
+
"""Why auto-instrumentation did not save them, in three honest words."""
|
|
553
|
+
if not auto_wrap:
|
|
554
|
+
return "auto_wrap: off"
|
|
555
|
+
labels = list(auto_wrapped)
|
|
556
|
+
if not labels:
|
|
557
|
+
return "auto_wrap: on, but no provider SDK was patched"
|
|
558
|
+
return "auto_wrap: on, patched " + ", ".join(labels)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def start_silence_timer(seconds: float, warn: Callable[[], None]) -> None:
|
|
562
|
+
"""Arm the one-shot silent-zero check, replacing any timer already armed.
|
|
563
|
+
|
|
564
|
+
A daemon thread: a check that has not fired yet must never be the reason a
|
|
565
|
+
process will not exit.
|
|
566
|
+
"""
|
|
567
|
+
global _TIMER
|
|
568
|
+
cancel_silence_timer()
|
|
569
|
+
try:
|
|
570
|
+
timer = threading.Timer(seconds, warn)
|
|
571
|
+
timer.name = TIMER_NAME
|
|
572
|
+
timer.daemon = True
|
|
573
|
+
with _LOCK:
|
|
574
|
+
_TIMER = timer
|
|
575
|
+
timer.start()
|
|
576
|
+
except Exception:
|
|
577
|
+
_LOG.warning("runbound could not start the coverage check", exc_info=True)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def cancel_silence_timer() -> None:
|
|
581
|
+
"""Stop the armed silent-zero check, if there is one. Never raises."""
|
|
582
|
+
global _TIMER
|
|
583
|
+
try:
|
|
584
|
+
with _LOCK:
|
|
585
|
+
timer, _TIMER = _TIMER, None
|
|
586
|
+
if timer is not None:
|
|
587
|
+
timer.cancel()
|
|
588
|
+
timer.join(timeout=1.0)
|
|
589
|
+
except Exception: # pragma: no cover
|
|
590
|
+
_LOG.debug("runbound: could not cancel the coverage check", exc_info=True)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def reset_for_tests() -> None:
|
|
594
|
+
"""Put every counter back to zero and disarm the timer. Test-only."""
|
|
595
|
+
global _WRAPPED_CLIENTS, _DECORATED_TOOLS, _TOOL_CALLS, _KEYED_SESSIONS
|
|
596
|
+
global _GUARDED_CALLS, _LAST_GUARDED_AT, _TRUNCATION_LOGGED, _TOOL_RULES_VERSION
|
|
597
|
+
cancel_silence_timer()
|
|
598
|
+
with _LOCK:
|
|
599
|
+
_WRAPPED_CLIENTS = 0
|
|
600
|
+
_DECORATED_TOOLS = 0
|
|
601
|
+
_TOOL_CALLS = 0
|
|
602
|
+
_KEYED_SESSIONS = 0
|
|
603
|
+
_GUARDED_CALLS = 0
|
|
604
|
+
_LAST_GUARDED_AT = None
|
|
605
|
+
_SHAPES_SEEN.clear()
|
|
606
|
+
_DECORATED_TOOL_NAMES.clear()
|
|
607
|
+
_TOOLS.clear()
|
|
608
|
+
_TOOL_RULES.clear()
|
|
609
|
+
# Bumped, never zeroed: an engine that outlives this call must not
|
|
610
|
+
# find its cache key matching a registry that has just been emptied.
|
|
611
|
+
_TOOL_RULES_VERSION += 1
|
|
612
|
+
_TRUNCATION_LOGGED = False
|