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 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