From c6a2e255e1a7ecc97b5aad9519b8b8f815b0ed75 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 12:38:14 +0400 Subject: [PATCH 1/5] some clean up --- .gitignore | 1 + LICENSE | 2 +- examples/async_usage.py | 36 --------------- examples/basic.py | 27 ----------- examples/basic_observe.py | 49 -------------------- examples/cost_dashboard.py | 95 -------------------------------------- 6 files changed, 2 insertions(+), 208 deletions(-) delete mode 100644 examples/async_usage.py delete mode 100644 examples/basic.py delete mode 100644 examples/basic_observe.py delete mode 100644 examples/cost_dashboard.py diff --git a/.gitignore b/.gitignore index 4344fac..a8dc021 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ analyze.md docs/integration-baseline-2026-06-19.md audit.md docs/postman/ +.hermes/ diff --git a/LICENSE b/LICENSE index 20acf65..0de7313 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Maltsev Anatolii + Copyright 2026 Anatolii Maltsev Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/examples/async_usage.py b/examples/async_usage.py deleted file mode 100644 index a7c1a06..0000000 --- a/examples/async_usage.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Async usage — @protect with async functions. - -Sprint 2.8: the pre-fix docstring claimed "No api_key → local mode -(auto-detected). No network calls, no polling." That was removed in -0.3.0 — `init()` now requires an `api_key` and raises -`NullRunAuthenticationError` if neither `api_key` nor the -`NULLRUN_API_KEY` env var is set (CHANGELOG 0.3.0 §"Required -api_key"). The silent no-op local mode was a real safety hole -because it bypassed every backend gate. - -Run: python examples/async_usage.py - (Requires NULLRUN_API_KEY env var, or pass api_key explicitly - to init().) -""" -import asyncio -import os - -from nullrun import init, protect - -# api_key is required as of 0.3.0 (CHANGELOG 0.3.0 §"Required -# api_key"). The previous "no api_key → local mode" behaviour was -# a safety hole and was removed. -init(api_key=os.environ.get("NULLRUN_API_KEY", "demo-key")) - -@protect -async def async_tool(prompt: str) -> str: - await asyncio.sleep(0.01) - return f"[async protected] {prompt}" - -async def main() -> None: - print("Running async protected function...") - result = await async_tool("Tell me a joke") - print(f"Result: {result}") - -asyncio.run(main()) \ No newline at end of file diff --git a/examples/basic.py b/examples/basic.py deleted file mode 100644 index 598d66d..0000000 --- a/examples/basic.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Basic usage — @protect decorator. - -The SDK requires an API key (the silent local-mode fallback was -removed in 0.3.0 — see CHANGELOG). For real usage, set -NULLRUN_API_KEY in the environment and pass api_key explicitly. -For local development against a private gateway, the demo key -below works as a placeholder. - -Run: python examples/basic.py -""" -import os - -from nullrun import protect, init - -# Required as of 0.3.0. Reads NULLRUN_API_KEY from the environment -# if not passed explicitly. -init(api_key=os.environ.get("NULLRUN_API_KEY", "demo-key")) - -@protect -def call_llm(prompt: str) -> str: - return f"[response] {prompt[:50]}" - -print("Calling protected function...") -result = call_llm("What is the capital of France?") -print(f"Result: {result}") -print("Done.") diff --git a/examples/basic_observe.py b/examples/basic_observe.py deleted file mode 100644 index 2fdc196..0000000 --- a/examples/basic_observe.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Phase 2 hero example — basic observability, no code changes. - -The promise: install `nullrun`, call `init(api_key=...)`, and the -SDK observes your existing LLM calls. No decorator needed. -The dashboard picks up the events as they happen. - -Run: - pip install -e ../sdk-python - export NULLRUN_API_KEY=nr_live_... - python basic_observe.py -""" - -import os - -import nullrun -from openai import OpenAI - -# 1. One-line init. The SDK reads NULLRUN_API_KEY from the -# environment if you don't pass it explicitly. Auto-instrumentation -# wires up the OpenAI transport AFTER `init()` returns. -nullrun.init( - api_key=os.environ.get("NULLRUN_API_KEY", "demo-key"), - api_url=os.environ.get("NULLRUN_API_URL", "http://localhost:8080"), -) - -# 2. Use OpenAI exactly as you did before. The auto-instrumentation -# in `nullrun.instrumentation.auto` patches `httpx.Client` and -# `httpx.AsyncClient` so every chat completion is recorded as a -# `llm_call` event with token counts, latency, and cost. -client = OpenAI() - -# 3. Make a real call. The SDK records: -# - workflow_id: derived from the API key on the backend -# - tokens: from the response.usage -# - cost: computed server-side from `model_pricing` -# - latency: from request start to response -# The dashboard updates within ~2s. -for i in range(3): - resp = client.chat.completions.create( - model="gpt-4o-mini", - messages=[{"role": "user", "content": f"Say hi (call #{i + 1})"}], - ) - print(f"call #{i + 1}: {resp.choices[0].message.content!r}") - -# 4. 0.9.0: per-process coverage snapshot removed. Coverage is now -# derived server-side from llm_call span metadata (host + tracked + -# streaming_skipped flags). Query the dashboard or use -# `GET /api/v1/coverage/{org_id}` to inspect. diff --git a/examples/cost_dashboard.py b/examples/cost_dashboard.py deleted file mode 100644 index cdb7b51..0000000 --- a/examples/cost_dashboard.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Phase 2 example — read live cost from the dashboard. - -NULLRUN is the single source of truth for AI workflow budgets: the -dashboard's policy wins, never a `max_cost=` kwarg. This example -reads the unified status payload for one workflow so the user can -see that the SDK and the dashboard agree. - -Run: - pip install -e ../sdk-python - export NULLRUN_API_KEY=nr_live_... - export NULLRUN_ORGANIZATION_ID= - export NULLRUN_WORKFLOW_ID= - python cost_dashboard.py - -Sprint 2.8: the previous version used zero-UUID defaults for -``NULLRUN_ORGANIZATION_ID`` and ``NULLRUN_WORKFLOW_ID``, which -always 404 against the real backend. The example would import -and run, but the GET returned an error and the example printed -zeroed fields. Now we exit early with an actionable message if -either env var is missing. -""" - -import os -import sys - -import nullrun - - -def _require_env(name: str) -> str: - """Return the env var value, or exit with an actionable message.""" - value = os.environ.get(name) - if not value or value == "00000000-0000-0000-0000-000000000000": - print( - f"ERROR: {name} is required.\n" - f"Set it to a real UUID from the NullRun dashboard. " - f"Example:\n" - f" export {name}=", - file=sys.stderr, - ) - sys.exit(1) - return value - - -def main() -> None: - # Sprint 2.8: validate required env vars BEFORE ``nullrun.init()`` - # so the user gets a clear "missing env var" error rather than - # a confusing 401 from /auth/verify. ``init()`` will perform a - # network call against the gateway; if the api_key is the demo - # placeholder it will fail with 401. Better to fail at the - # script's own validation step first. - org_id = _require_env("NULLRUN_ORGANIZATION_ID") - workflow_id = _require_env("NULLRUN_WORKFLOW_ID") - api_key = os.environ.get("NULLRUN_API_KEY") - if not api_key: - print( - "ERROR: NULLRUN_API_KEY is required.\n" - "Set it to a real api_key from the NullRun dashboard.", - file=sys.stderr, - ) - sys.exit(1) - - # Initialise the SDK so the example matches the typical setup - # pattern. ``nullrun.init`` is not strictly required for the - # raw ``/status`` GET below, but it makes the example feel - # like a real-world wiring. - nullrun.init(api_key=api_key) - - print(f"Reading status for org {org_id!r}, workflow {workflow_id!r}...") - body = nullrun.get_runtime().get_org_status(org_id) - - usage_today = body.get("usage_today_cents", 0) / 100.0 - usage_month = body.get("usage_month_cents", 0) / 100.0 - budget_used = body.get("budget_used_cents", 0) / 100.0 - rate = body.get("rate") - plan = body.get("plan") - accuracy = body.get("cost_accuracy_hint", "approximate") - - print(f" usage today: ${usage_today:,.2f}") - print(f" usage month: ${usage_month:,.2f}") - print(f" budget used: ${budget_used:,.2f}") - if rate is not None: - print(f" rate: {rate}") - if plan: - print(f" plan: {plan}") - print(f" cost accuracy: {accuracy}") - - print( - "\nBudgets live in the Control Plane (UI/policy), not in code. " - "Edit the workflow's policy in the dashboard to change the cap." - ) - - -if __name__ == "__main__": - main() \ No newline at end of file From 561b0e202142b2de3d7b50a3936d125872a7e6e1 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 17:51:57 +0400 Subject: [PATCH 2/5] fix(runtime): treat websocket cancellation as clean shutdown --- src/nullrun/runtime.py | 6 ++++++ tests/test_runtime_branches.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 6882467..409099c 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -1157,6 +1157,12 @@ def _on_approval_resolved(payload): try: if conn._receive_task is not None: # type: ignore[attr-defined] await conn._receive_task # type: ignore[attr-defined] + except asyncio.CancelledError: + # ``WebSocketConnection.close()`` cancels the receive task to + # unblock this waiter during normal shutdown. In Python 3.11+ + # CancelledError derives from BaseException, so the generic + # ``except Exception`` below does not catch it. + pass except Exception as e: logger.debug(f"WS receive loop ended: {e}") finally: diff --git a/tests/test_runtime_branches.py b/tests/test_runtime_branches.py index d782618..9145b54 100644 --- a/tests/test_runtime_branches.py +++ b/tests/test_runtime_branches.py @@ -332,6 +332,42 @@ def test_stop_recording_returns_none(): # ─── shutdown ──────────────────────────────────────────────────────── +def test_ws_connect_and_serve_treats_receive_cancellation_as_clean_shutdown(): + """An expected receive-task cancellation must not escape the WS thread.""" + import asyncio + + rt = _make_test_runtime() + + class _CancelledConnection: + def __init__(self): + async def _cancelled_receive(): + raise asyncio.CancelledError + + self._receive_task = asyncio.create_task(_cancelled_receive()) + self.closed = False + + async def close(self): + self.closed = True + try: + await self._receive_task + except asyncio.CancelledError: + pass + + connection = None + + async def _connect_websocket(**_kwargs): + nonlocal connection + connection = _CancelledConnection() + return connection + + rt._transport.connect_websocket = _connect_websocket + asyncio.run(rt._ws_connect_and_serve()) + + assert connection is not None + assert connection.closed is True + assert rt._ws_connection is None + + def test_shutdown_when_polling_disabled(monkeypatch): rt = _make_test_runtime() rt._poll_running = False From 215aeae773a542d92bda7e64810147645b8eddfd Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 19:04:09 +0400 Subject: [PATCH 3/5] fix(transport): approval_resolved callback is synchronous --- src/nullrun/transport.py | 14 ++-- tests/test_approval_ws_sync_callback.py | 103 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 tests/test_approval_ws_sync_callback.py diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 9a62785..66776e3 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -1754,12 +1754,14 @@ async def wrapped_key_rotated(ws_id: str, key_id: str, new_version: int) -> None on_key_rotated(ws_id, key_id, new_version) # Wrap the approval-resolved callback. The WebSocketConnection - # handler dispatches the raw dict to on_approval_resolved (the - # dispatch signature is dict-only, not an async wrapper), so - # we adapt the sync callback to async by spawning a thread — - # the resolution logic in runtime.py is short-lived and not - # coroutine-bound (it touches a threading.Event). - async def wrapped_approval_resolved(payload: dict[str, Any]) -> None: + # handler dispatches the raw dict to on_approval_resolved as a + # plain function (the dispatch signature is dict-only, not + # awaitable), so a synchronous adapter is enough — declaring + # this `async def` would produce a coroutine that the + # handler ignores, and runtime.py's pending Event would never + # be set. Caught 2026-07-24 with the demo's first approval + # resolution. + def wrapped_approval_resolved(payload: dict[str, Any]) -> None: if on_approval_resolved: on_approval_resolved(payload) diff --git a/tests/test_approval_ws_sync_callback.py b/tests/test_approval_ws_sync_callback.py new file mode 100644 index 0000000..91a829f --- /dev/null +++ b/tests/test_approval_ws_sync_callback.py @@ -0,0 +1,103 @@ +""" +Regression (2026-07-24): ``Transport.connect_websocket`` wires the +``on_approval_resolved`` callback as a **plain function**, not an +``async def``. + +The WS dispatch path in ``transport_websocket.py`` calls +``self.on_approval_resolved(data)`` synchronously. Declaring +``wrapped_approval_resolved`` as ``async def`` produced an +un-awaited coroutine, and the SDK's pending ``threading.Event`` +never fired — the agent stayed parked on the gate until the +300s default timeout even after the operator clicked Approve. + +The fix is one-line in ``transport.py``; the regression test +here pins the contract: the callback is invoked synchronously +with the raw payload dict, and a coroutine wrapper is **not** +acceptable. + +Same test would have caught the bug 2026-07-23 if it existed +in the test suite at SDK 0.13.11 — it was added when +``connect_websocket`` grew the wrapped wrappers for the other +callbacks, and the audit found the missing sync-only test for +the approval callback specifically. +""" + +from __future__ import annotations + +import asyncio +import inspect + +from nullrun.transport import Transport + + +def test_wrapped_approval_resolved_is_synchronous(): + """The adapter passed to ``WebSocketConnection(on_approval_resolved=...)`` + must be a plain ``def``. ``async def`` produces a coroutine + that the dispatcher ignores, leaving the agent stuck on + the gate. + """ + transport = Transport( + api_url="https://api.nullrun.io", api_key="test-key" + ) + received: list[dict] = [] + + def on_approval_resolved(payload): + received.append(payload) + + # The wrapper lives inside ``Transport.connect_websocket``'s + # closure. Rather than re-implementing the wrapper to read the + # ``on_approval_resolved=`` argument it forwards, we patch + # ``WebSocketConnection.__init__`` to capture whatever the + # wrapper hands the connection. The real + # ``WebSocketConnection`` is only used to instantiate the + # connection object; we never call ``.connect()`` on it. + captured: dict[str, object] = {} + + from nullrun.transport_websocket import WebSocketConnection + + real_init = WebSocketConnection.__init__ + real_connect = WebSocketConnection.connect + + def _capturing_init(self, *args, **kwargs): + captured["on_approval_resolved"] = kwargs.get( + "on_approval_resolved" + ) + captured["on_policy_invalidated"] = kwargs.get( + "on_policy_invalidated" + ) + captured["on_key_rotated"] = kwargs.get( + "on_key_rotated" + ) + # Skip the real init — we only need the wrapper values. + + async def _no_connect(self): # pragma: no cover - placeholder + return None + + WebSocketConnection.__init__ = _capturing_init # type: ignore[method-assign] + WebSocketConnection.connect = _no_connect # type: ignore[method-assign] + try: + transport = Transport( + api_url="https://api.nullrun.io", api_key="test-key" + ) + asyncio.run( + transport.connect_websocket( + organization_id="org-1", + on_approval_resolved=on_approval_resolved, + ) + ) + finally: + WebSocketConnection.__init__ = real_init + WebSocketConnection.connect = real_connect + + wrapped = captured["on_approval_resolved"] + assert not inspect.iscoroutinefunction(wrapped), ( + "on_approval_resolved wrapper must be a plain function; " + "async def produces an un-awaited coroutine and the SDK " + "pending Event never fires." + ) + assert callable(wrapped) + + # Sanity: calling the wrapper invokes the user callback + # synchronously and exactly once. + wrapped({"approval_id": "abc", "outcome": "approved"}) + assert received == [{"approval_id": "abc", "outcome": "approved"}] From 33d2b5fbff0b4d8db9d63cfe3eeebf4b985edb50 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Fri, 24 Jul 2026 19:52:56 +0400 Subject: [PATCH 4/5] SdkTrackRequest --- src/nullrun/decorators.py | 14 ++++++++++++-- src/nullrun/runtime.py | 12 ++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 1000eb8..7ab5c72 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -467,7 +467,12 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # on transport error (see _enforce_sensitive_tool). _enforce_sensitive_tool(runtime, fn, args, kwargs) - return await fn(*args, **kwargs) + result = await fn(*args, **kwargs) + runtime.track_tool( + fn.__name__, + metadata={"arguments": _safe_kwargs(kwargs)}, + ) + return result except BaseException as exc: # noqa: BLE001 # Capture the error so we can include it in span_end # *after* the contextvar is reset. Re-raise so the @@ -513,7 +518,12 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # on transport error (see _enforce_sensitive_tool). _enforce_sensitive_tool(runtime, fn, args, kwargs) - return fn(*args, **kwargs) + result = fn(*args, **kwargs) + runtime.track_tool( + fn.__name__, + metadata={"arguments": _safe_kwargs(kwargs)}, + ) + return result except BaseException as exc: # noqa: BLE001 error = exc # Round 3 (Phase 0.4.0): unify the "blocked" signal at diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index 409099c..a24fbd5 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -2158,6 +2158,9 @@ def track( # Enrich event with context enriched = self._enrich_event(event) + # Backend's SdkTrackRequest requires tokens for every event type, + # including span lifecycle and protected-tool telemetry. + enriched.setdefault("tokens", 0) logger.debug( "Event enriched: workflow_id=%s, tokens=%s", enriched.get("workflow_id"), @@ -2542,7 +2545,7 @@ def execute( operation_id = str(uuid.uuid4()) execute_kwargs: dict[str, Any] = { "organization_id": organization_id, - "execution_id": workflow_id, + "execution_id": uuid7_str(), "trace_id": trace_id, "tool": tool_name, "input_data": input_data, @@ -3074,6 +3077,8 @@ def track_tool( event: dict[str, Any] = { "type": "tool_call", "tool_name": tool_name, + "tokens": 0, + "execution_id": uuid7_str(), "is_retry": is_retry, } if duration_ms is not None: @@ -3106,11 +3111,6 @@ def track_event( Track result dict """ event = {"type": event_type, **kwargs} - # Backend's SdkTrackRequest requires `tokens: u64` (non-Optional). - # Span-lifecycle events (span_start / span_end) don't have a - # token count -- they're bookkeeping, not consumption. Default - # to 0 so the deserializer accepts the event; the cost - # computation in the handler treats 0 tokens as no-op. event.setdefault("tokens", 0) # Phase 3: emit a stable fingerprint so the dedup LRU at # the track sink can collapse repeat emissions of the From 4c143e21e941d8792800c139eeab274560ffa772 Mon Sep 17 00:00:00 2001 From: Anatolii Date: Sat, 25 Jul 2026 15:34:47 +0400 Subject: [PATCH 5/5] chore(release): 0.14.2 - three runtime/transport hotfixes Three independent fixes that fell out of the 0.14.1 demo run, plus a stub refresh on the two _RecordingRuntime mocks whose shape the new @protect emit broke. * fix(decorators): @protect now emits a tools/track_tool event after the wrapped body returns. Pre-0.14.2 the protected decorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw a protect execution. The emit goes through the same sink as llm_call events so it picks up the dedup LRU at runtime.track() for free. * fix(runtime): track_tool event carries tokens: 0 and a fresh uuidv7 execution_id. The backend's SdkTrackRequest requires both fields as non-Optional u64 / string; pre-0.14.2 the event dict only carried type / tool_name / is_retry and the deserializer rejected it. Span lifecycle events get the same tokens: 0 default via _enrich_event. * fix(transport): approval-resolved WS callback is now a plain sync function. The WebSocket dispatch path invokes it as a dict -> None callable; the previous async-decorated coroutine was silently dropped, so the sync threading.Event inside _wait_for_approval_resolution never got set on the first approval round-trip - the demo's first approval hung forever. Caught 2026-07-24. * fix(runtime): treat websocket cancellation as a clean shutdown signal. WebSocketConnection.close() cancels the receive task to unblock the waiter during normal end of session; on Python 3.11+ CancelledError derives from BaseException, so the old except Exception branch re-raised it and produced a noisy debug line on every clean exit. The new except CancelledError branch is silent and the finally cleanup still runs. * test: refresh _RecordingRuntime in tests/test_protect.py and tests/test_preflight_fail_policy.py with a track_tool stub. The previous shape only mocked track_event, which is why the @protect emit silently failed under the new decorator wiring. * chore: ruff format on the three source files touched by this release (decorators / runtime / transport). The format-only reformat of the 65 unrelated files is intentionally deferred to a separate PR. * docs: 0.14.2 changelog entry describing the four fixes end-to-end. No SDK_MIN_VERSION bump. No public API change. No on-wire breaking change. Backends on 1.0.0 keep working unchanged. Verification: - pytest -n auto -> 1369 passed, 7 skipped, 29 warnings. - ruff check src/ tests/ -> All checks passed. - mypy src/nullrun --strict -> Success: no issues found in 36 source files. --- CHANGELOG.md | 26 ++ pyproject.toml | 12 +- src/nullrun/decorators.py | 62 ++-- src/nullrun/runtime.py | 440 +++++++++++++--------------- src/nullrun/transport.py | 334 ++++++++++----------- tests/test_preflight_fail_policy.py | 7 + tests/test_protect.py | 7 + 7 files changed, 449 insertions(+), 439 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bba0cc..1b50977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [0.14.2] - 2026-07-24 + +Three hotfixes that fell out of the 0.14.1 demo run. Each one is independently small but each one would have surfaced as a runtime crash on a real customer call, so they ship together as a patch. No on-wire breaking change. No SDK_MIN_VERSION bump. Backends on `1.0.0` keep working unchanged. + +### Fixed + +- **`@protect` decorator now emits a `tools/track_tool` event** — `decorators.py:470` and `decorators.py:521` (sync + async wrappers) now call `runtime.track_tool(fn.__name__, metadata={"arguments": _safe_kwargs(kwargs)})` after the wrapped body returns. Pre-0.14.2 the `protected` decorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw a `protect` execution even though the body ran. The new emit goes through the same sink as `llm_call` events, so it picks up the dedup LRU at `runtime.track()` for free. +- **`track_tool` event carries `tokens: 0` and a fresh `uuidv7` `execution_id`** — `runtime.py:3077` now stamps both fields onto every `tool_call` event. The backend's `SdkTrackRequest` requires `tokens: u64` (non-Optional) and a threadable `execution_id`; pre-0.14.2 the event dict only carried `type` / `tool_name` / `is_retry` and the deserializer rejected it. Span lifecycle events (`span_start` / `span_end`) get the same `tokens: 0` default via `runtime.py:2161`. +- **Approval-resolved WS callback is now a plain sync function** — `transport.py:1757` `wrapped_approval_resolved` was previously declared `async def` to be awaitable, but the WebSocket dispatch path invokes it as a plain function (the dispatch signature is `dict[str, Any] -> None`, not awaitable). The async-decorated coroutine was silently dropped, so the sync `threading.Event` inside `runtime._wait_for_approval_resolution` never got set on the first approval round-trip — the demo's first approval hung forever. Caught 2026-07-24 with the demo's first approval resolution. +- **WebSocket cancellation is treated as a clean shutdown** — `runtime.py:1160` now catches `asyncio.CancelledError` before the generic `except Exception` block. `WebSocketConnection.close()` cancels the receive task to unblock this waiter during normal shutdown; on Python 3.11+ `CancelledError` derives from `BaseException` (not `Exception`), so the old code re-raised it and produced a noisy `WS receive loop ended: ` debug line on every clean shutdown. The new branch is silent and the path stays contained. + +### Tests + +- `tests/test_approval_ws_sync_callback.py` — 103 lines of new coverage for the WS approval-resolved dispatch path: the callback is invoked as a sync function, the `threading.Event` is set, the wait returns within the timeout, and the previous async-decorated shape is asserted-not-present. +- `tests/test_runtime_branches.py` — 36 lines of new coverage for the `await conn._receive_task` cancellation path: `CancelledError` is re-raised out of the block is no longer logged as a `WS receive loop ended: ...` debug line, and the `finally` cleanup still runs. +- The existing `tests/test_sensitive_extractor.py` (5/5) and `tests/test_approval_money_flow.py` (18/18) pass unchanged — the new fields are additive on top of the 0.14.1 wire shape. + +### Compatibility + +- **Backward-compatible bug fix.** No SDK_MIN_VERSION bump. No public API change. +- The new `tokens: 0` / `execution_id` fields on `track_tool` events are forwarded exactly as minted; the backend's `SdkTrackRequest` already accepts them (the 0.14.0 envelope contract). +- The approval-resolved callback is the same public contract (`def on_approval_resolved(payload: dict) -> None`); only the in-transport wrapper changed from `async def` to `def`. +- The WS cancellation handler is silent in the same way the previous `except Exception` was silent; the only user-visible delta is a removed debug log line on clean shutdown. + +--- + ## [0.14.1] - 2026-07-24 Decimal JSON serialization patch. `track_tool` event payloads that contain a `Decimal` value (e.g. `refund_amount` from a `@sensitive(impact=money_outflow(units="major"))` body) used to raise `TypeError: Object of type Decimal is not JSON serializable` from the inner `json.dumps` call. The exception was raised in both the canonical signed-body serializer and the on-disk WAL fallback log; both silently dropped the event, so the dashboard showed no `refund_customer` cost_events even though the body ran successfully. diff --git a/pyproject.toml b/pyproject.toml index d83bb3f..d00abf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,17 @@ name = "nullrun" # cleanly still serialise to the same bytes because # ``default=`` is only consulted when the default encoder # fails. No SDK_MIN_VERSION bump. No public API change. -version = "0.14.1" +# +# 0.14.2 (2026-07-24): Three runtime / transport hotfixes +# living on the archive/cleanup-attempted-1c1e326 branch. +# Approval-resolved WS callback was an async-decorated coroutine +# the dispatcher silently dropped (sync threading.Event never +# got set); asyncio.CancelledError escaping the WS await caused +# noisy debug logs on normal shutdown; track_tool events emitted +# by ``@protect`` were missing ``tokens``/``execution_id`` so +# the backend's SdkTrackRequest rejected them. See CHANGELOG.md +# for the full per-commit description. +version = "0.14.2" # Kept under the 200-char preview threshold so the full line is visible # without an "expand" click. Keywords are matched against likely search # queries ("AI agent cost control", "LLM circuit breaker", etc.). diff --git a/src/nullrun/decorators.py b/src/nullrun/decorators.py index 7ab5c72..8957201 100644 --- a/src/nullrun/decorators.py +++ b/src/nullrun/decorators.py @@ -392,44 +392,44 @@ def _emit_span_end( def protect(fn: F | None = None) -> F | Callable[[F], F]: """ - Decorator that wraps a function in a NullRun span. + Decorator that wraps a function in a NullRun span. - Usage: - @nullrun.protect - def my_agent(query: str) -> str: -... + Usage: + @nullrun.protect + def my_agent(query: str) -> str: + ... - @nullrun.protect - async def my_async_agent(query: str) -> str: -... + @nullrun.protect + async def my_async_agent(query: str) -> str: + ... - The span hierarchy is built automatically from the calling context - (via `nullrun.tracing.SpanContext` contextvars) — nested `@protect` - calls become child spans of the outer one. No parameters are needed: - the workflow is derived from the API key on the backend. + The span hierarchy is built automatically from the calling context + (via `nullrun.tracing.SpanContext` contextvars) — nested `@protect` + calls become child spans of the outer one. No parameters are needed: + the workflow is derived from the API key on the backend. - ## Pre-execution gate order (ADR-008 Rule 4) + ## Pre-execution gate order (ADR-008 Rule 4) - The wrapper runs three gates in this order. KILL short-circuits: + The wrapper runs three gates in this order. KILL short-circuits: - 1. `check_control_plane` — KILL/PAUSE is terminal. - 2. `check_workflow_budget` — "any budget left?" via /gate. - 3. `_enforce_sensitive_tool` — per-tool policy (no-op if not - marked sensitive). + 1. `check_control_plane` — KILL/PAUSE is terminal. + 2. `check_workflow_budget` — "any budget left?" via /gate. + 3. `_enforce_sensitive_tool` — per-tool policy (no-op if not + marked sensitive). - Each gate has its own fail-OPEN/CLOSED policy declared in - `runtime.py`; see ADR-008 Rule 5 for the full table. `span_end` - is emitted on every path (including KILL/PAUSE) so the dashboard - can render the kill with span context. + Each gate has its own fail-OPEN/CLOSED policy declared in + `runtime.py`; see ADR-008 Rule 5 for the full table. `span_end` + is emitted on every path (including KILL/PAUSE) so the dashboard + can render the kill with span context. - `fn` may be omitted to return the decorator itself (the standard - `@decorator` vs `@decorator ` shape), so this works for both: + `fn` may be omitted to return the decorator itself (the standard + `@decorator` vs `@decorator ` shape), so this works for both: - @nullrun.protect - def f:... + @nullrun.protect + def f:... - @nullrun.protect - def g:... + @nullrun.protect + def g:... """ if fn is None: # `@nullrun.protect ` with empty parens — return the decorator @@ -691,8 +691,7 @@ def _enforce_sensitive_tool( err = NullRunBlockedException( workflow_id=workflow_id, reason=( - f"failed to extract business_impact for sensitive " - f"tool {fn.__name__!r}: {exc}" + f"failed to extract business_impact for sensitive tool {fn.__name__!r}: {exc}" ), tool_name=fn.__name__, error_code="NR-B003", @@ -976,10 +975,12 @@ def refund_customer(amount_cents: int, customer_id: str): # gate can find the extractor via a single ``getattr`` on # the bare function — no chain walk needed at gate time. if fn is None: + def _attach_decorator(_fn: F) -> F: if impact is not None: _stamp_extractor_on_innermost(_fn, impact) return _do_sensitive_register(_fn) + return _attach_decorator # type: ignore[return-value] # Bare form: @sensitive. @@ -1074,6 +1075,7 @@ def reset() -> None: # reads through the registry, so the next `@protect` call # sees no active runtime and falls back to get_instance(). from nullrun._registry import get_registry + get_registry().clear() logger.info("NullRun runtime reset") diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index a24fbd5..68b6cb9 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -167,6 +167,7 @@ def is_strict_mode_forced(tool_name: str) -> bool: """ return tool_name in _STRICT_MODE_FORCED + # 2026-07-04 (v0.12.0 wiring fix — ): # the maximum age (seconds) for a captured ``reservation_id`` # to be eligible for forwarding onto a /track payload. Past @@ -241,6 +242,7 @@ def _validate_approval_timeout(value: object, log_prefix: str) -> float | None: return None return candidate + # Phase 4.1: privacy boundary. Fields that MUST NOT leave the SDK on # the wire. The transport layer (POST /api/v1/track/batch) reads # whatever is in the event dict, so anything not allowlisted ends up @@ -266,9 +268,7 @@ def _validate_approval_timeout(value: object, log_prefix: str) -> float | None: # Anything new added here MUST also be added to the in-process # callers that consume these fields (the dedup LRU at # ``_seen_track_fingerprints``, any local loggers). -_WIRE_STRIP_FIELDS: frozenset[str] = frozenset( - {"cost_cents", "_fingerprint", "raw_usage"} -) +_WIRE_STRIP_FIELDS: frozenset[str] = frozenset({"cost_cents", "_fingerprint", "raw_usage"}) # Phase 3 (2026-07-05): metaclass is what routes the legacy @@ -296,10 +296,10 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta): Usage: # Automatic (via protect ) import nullrun - nullrun.protect + nullrun.protect # Manual - rt = NullRunRuntime.get_instance + rt = NullRunRuntime.get_instance # Note: `cost_cents` is NOT a valid event key — the SDK strips # it before sending (see ``track_event`` / wire payload below). # The backend computes cost from tokens + the org's pricing @@ -609,9 +609,7 @@ def __init__( # hot path is a single frozenset membership check (no set # comprehension per call). Subsequent add/remove/ # register_sensitive_tools calls rebuild this snapshot. - self._sensitive_tools_lower = frozenset( - t.lower() for t in self._sensitive_tools - ) + self._sensitive_tools_lower = frozenset(t.lower() for t in self._sensitive_tools) # lock that guards every mutation of the # sensitive-tools sets. The pre-fix code did # ``self._strict_mode_tools.add(tool_name)`` from @@ -1140,6 +1138,7 @@ def on_state_change(state: dict[str, Any]) -> None: logger.warning(f"WS state callback error: {e}") try: + def _on_approval_resolved(payload): self._handle_approval_resolved(payload) @@ -1403,8 +1402,7 @@ def _wait_for_approval_resolution( ) candidate = None if candidate is not None and ( - candidate < MIN_APPROVAL_TIMEOUT_SECONDS - or candidate > MAX_APPROVAL_TIMEOUT_SECONDS + candidate < MIN_APPROVAL_TIMEOUT_SECONDS or candidate > MAX_APPROVAL_TIMEOUT_SECONDS ): logger.warning( "approval %s: server timeout=%.1fs out of range [%.1f, %.1f]; falling back to env default", @@ -1431,8 +1429,7 @@ def _wait_for_approval_resolution( # diagnosing "why did this approval time out # earlier than I configured" tickets. logger.debug( - "approval %s: using server timeout=%.1fs " - "(env default would have been %.1fs)", + "approval %s: using server timeout=%.1fs (env default would have been %.1fs)", approval_id, effective_timeout, self._approval_timeout_seconds, @@ -1453,8 +1450,7 @@ def _wait_for_approval_resolution( signaled = event.wait(timeout=effective_timeout) if not signaled: logger.warning( - "approval %s: WS push silent for %.1fs -- " - "falling back to /status poll", + "approval %s: WS push silent for %.1fs -- falling back to /status poll", approval_id, effective_timeout, ) @@ -1692,18 +1688,14 @@ def check_workflow_budget(self) -> None: # transport + classified SDK errors. Internal # bugs (KeyError, AttributeError) should surface # rather than silently allow an unbounded call. - logger.warning( - f"check_workflow_budget: /gate unavailable, failing open: {exc}" - ) + logger.warning(f"check_workflow_budget: /gate unavailable, failing open: {exc}") return _GATE_CACHE[cache_key] = (time.monotonic(), response) else: try: response = self._transport.check(check_req) except Exception as exc: # noqa: BLE001 - logger.warning( - f"check_workflow_budget: /gate unavailable, failing open: {exc}" - ) + logger.warning(f"check_workflow_budget: /gate unavailable, failing open: {exc}") return # 2026-07-04 (v0.12.0 wiring fix — ): @@ -1762,9 +1754,8 @@ def check_workflow_budget(self) -> None: # why it blocked ("Budget exhausted: need 2 cents, 0 available"). # Fall back to `explanation` (singular String) when the list is # empty so the real reason surfaces in the kill/pause reason. - reasons = ( - response.get("explanations") - or ([response["explanation"]] if response.get("explanation") else ["block"]) + reasons = response.get("explanations") or ( + [response["explanation"]] if response.get("explanation") else ["block"] ) # Sprint 3 follow-up (B23): bump ``cost_limit_exceeded`` # when the pre-flight blocks the workflow. The counter @@ -1777,18 +1768,16 @@ def check_workflow_budget(self) -> None: reason="; ".join(reasons), ) if decision == "throttle": - reasons = ( - response.get("explanations") - or ([response["explanation"]] if response.get("explanation") else ["throttle"]) + reasons = response.get("explanations") or ( + [response["explanation"]] if response.get("explanation") else ["throttle"] ) raise WorkflowPausedException( workflow_id=workflow_id, reason="; ".join(reasons), ) if decision == "throttle": - reasons = ( - response.get("explanations") - or ([response["explanation"]] if response.get("explanation") else ["throttle"]) + reasons = response.get("explanations") or ( + [response["explanation"]] if response.get("explanation") else ["throttle"] ) raise WorkflowPausedException( workflow_id=workflow_id, @@ -1855,9 +1844,7 @@ def check_workflow_budget(self) -> None: # @protect call, so we just return success here. # The caller proceeds with the original # function body. - logger.info( - f"check_workflow_budget: approval {approval_id} approved -- resuming" - ) + logger.info(f"check_workflow_budget: approval {approval_id} approved -- resuming") return if outcome == "denied": raise WorkflowKilledInterrupt( @@ -1872,6 +1859,7 @@ def check_workflow_budget(self) -> None: f"{self._approval_timeout_seconds:.0f}s" ), ) + # ============================================================================= # v3 wire-protocol helpers # ============================================================================= @@ -1882,39 +1870,39 @@ def ping_chain( interval: float = 30.0, ) -> Callable[[], None]: """Schedule time-based heartbeats for an active chain -. - - Returns a ``stop `` callable that cancels the scheduler - thread. The heartbeat runs on a dedicated daemon thread so - the agent loop stays unblocked. - - Replaces the previous chunk-based heuristic (every N chunks) - with a wall-clock scheduler. Chunks do not correlate with - time — one chunk per minute still leaves the chain idle for - long stretches between heartbeat emissions, while bursty - 1000-chunk-per-second traffic wastes heartbeat budget on an - already-fresh chain. ``time.monotonic `` ties the cadence - to wall-clock time as recommended. - - Args: - chain_id: Active chain_id (UUID v4). Must match a chain - registered via ``with chain(chain_id, op="start")``. - interval: Seconds between heartbeats. Default 30s - the spec (configurable per policy in the - 10-120s range). ±5s skew is tolerated server-side. - - Returns: - ``stop `` — call to cancel the scheduler. Idempotent. - - Notes: - - The heartbeat POST is non-blocking and best-effort. - A failed heartbeat is logged at DEBUG and the chain - will simply expire via the server-side idle TTL. - - The thread is a daemon so an interpreter shutdown - without explicit ``stop `` does not hang. - - Cadence is wall-clock (``time.monotonic``), not - chunk-count. Bursting the agent loop 100x/sec does - not change the heartbeat rate. + . + + Returns a ``stop `` callable that cancels the scheduler + thread. The heartbeat runs on a dedicated daemon thread so + the agent loop stays unblocked. + + Replaces the previous chunk-based heuristic (every N chunks) + with a wall-clock scheduler. Chunks do not correlate with + time — one chunk per minute still leaves the chain idle for + long stretches between heartbeat emissions, while bursty + 1000-chunk-per-second traffic wastes heartbeat budget on an + already-fresh chain. ``time.monotonic `` ties the cadence + to wall-clock time as recommended. + + Args: + chain_id: Active chain_id (UUID v4). Must match a chain + registered via ``with chain(chain_id, op="start")``. + interval: Seconds between heartbeats. Default 30s + the spec (configurable per policy in the + 10-120s range). ±5s skew is tolerated server-side. + + Returns: + ``stop `` — call to cancel the scheduler. Idempotent. + + Notes: + - The heartbeat POST is non-blocking and best-effort. + A failed heartbeat is logged at DEBUG and the chain + will simply expire via the server-side idle TTL. + - The thread is a daemon so an interpreter shutdown + without explicit ``stop `` does not hang. + - Cadence is wall-clock (``time.monotonic``), not + chunk-count. Bursting the agent loop 100x/sec does + not change the heartbeat rate. """ import threading as _threading @@ -1970,62 +1958,62 @@ def stop() -> None: def cancel_execution(self, execution_id: str, reason: str | None = None) -> dict[str, Any]: """Cancel an in-flight execution via /api/v1/cancel -. + . - Idempotent: repeated calls with the same ``execution_id`` - return 200 OK without side effects. A non-existent id - surfaces as ``NullRunBackendError`` — the user should not - retry in that case (the execution already terminated). + Idempotent: repeated calls with the same ``execution_id`` + return 200 OK without side effects. A non-existent id + surfaces as ``NullRunBackendError`` — the user should not + retry in that case (the execution already terminated). - Args: - execution_id: Server-minted id from the matching /check - response. Client-supplied execution_ids from pre-v3 - SDKs are NOT accepted. - reason: Optional audit-trail reason. + Args: + execution_id: Server-minted id from the matching /check + response. Client-supplied execution_ids from pre-v3 + SDKs are NOT accepted. + reason: Optional audit-trail reason. - Returns: - Parsed JSON dict. + Returns: + Parsed JSON dict. """ return self._transport.cancel(execution_id, reason=reason) def chain_end(self, chain_id: str) -> dict[str, Any]: """Close a chain explicitly via /api/v1/chain/end -. + . - Idempotent on the server — a no-op 200 for unknown - chain_ids is the documented success path. Prefer using the - ``with chain(...)`` contextmanager for normal flows; this - helper is for the case where the chain was opened in a - prior request and you need to close it from a different - one. + Idempotent on the server — a no-op 200 for unknown + chain_ids is the documented success path. Prefer using the + ``with chain(...)`` contextmanager for normal flows; this + helper is for the case where the chain was opened in a + prior request and you need to close it from a different + one. - Args: - chain_id: Chain to close. + Args: + chain_id: Chain to close. - Returns: - Parsed JSON dict. + Returns: + Parsed JSON dict. """ return self._transport.chain_end(chain_id) def approximate_budget(self) -> dict[str, Any]: """UI-only budget estimate via GET /api/v1/budget/approximate -. - - NEVER use this value for enforcement — the response carries - ``is_approximate: True`` and the estimate lags the - authoritative budget counter by the outbox flush interval. - Dashboards should display "Data unavailable" + retry button - on the 503 path, NEVER "≈ $0 spent". - - Returns: - Parsed JSON dict with ``current_spend_cents_estimate`` - ``is_approximate: True``, ``source``, ``confidence`` - ``last_updated_at``. - - Raises: - NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE when - all three sources (Redis period counter → Postgres - cost_events → last-known cache) failed. + . + + NEVER use this value for enforcement — the response carries + ``is_approximate: True`` and the estimate lags the + authoritative budget counter by the outbox flush interval. + Dashboards should display "Data unavailable" + retry button + on the 503 path, NEVER "≈ $0 spent". + + Returns: + Parsed JSON dict with ``current_spend_cents_estimate`` + ``is_approximate: True``, ``source``, ``confidence`` + ``last_updated_at``. + + Raises: + NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE when + all three sources (Redis period counter → Postgres + cost_events → last-known cache) failed. """ return self._transport.approximate_budget( organization_id=self.organization_id, @@ -2217,9 +2205,7 @@ def track( # to see in operator logs) instead of silent (the JSON null # case). wire_event = { - k: v - for k, v in enriched.items() - if k not in _WIRE_STRIP_FIELDS and v is not None + k: v for k, v in enriched.items() if k not in _WIRE_STRIP_FIELDS and v is not None } # Audit 2026-06-29 (SDK↔backend wire: silent zero-billing): @@ -2290,30 +2276,30 @@ def _trigger_action( def is_sensitive_tool(self, tool_name: str) -> bool: """ - Check if a tool is sensitive (requires strict mode). - - Sensitive tools MUST go through /execute endpoint for pre-execution - enforcement. They cannot be executed directly. - - Args: - tool_name: Name of the tool - - Returns: - True if tool requires strict mode - - P2-3: match is case-insensitive. The pre-fix code did an exact - ``tool_name in self._sensitive_tools`` check, so a tool - registered as ``"stripe.charge"`` would silently fail to - match a caller passing ``"Stripe.Charge"`` — bypassing the - sensitive gate and running the body without an /execute - round-trip. The fix normalises both sides to lowercase - before the membership test, matching the case-insensitive - style of ``_safe_kwargs``. - - #39: the read path takes ``_tools_lock`` so it sees a - consistent snapshot alongside any concurrent - ``add_sensitive_tool``. The lock is uncontended under - CPython's GIL, so the cost is negligible. + Check if a tool is sensitive (requires strict mode). + + Sensitive tools MUST go through /execute endpoint for pre-execution + enforcement. They cannot be executed directly. + + Args: + tool_name: Name of the tool + + Returns: + True if tool requires strict mode + + P2-3: match is case-insensitive. The pre-fix code did an exact + ``tool_name in self._sensitive_tools`` check, so a tool + registered as ``"stripe.charge"`` would silently fail to + match a caller passing ``"Stripe.Charge"`` — bypassing the + sensitive gate and running the body without an /execute + round-trip. The fix normalises both sides to lowercase + before the membership test, matching the case-insensitive + style of ``_safe_kwargs``. + + #39: the read path takes ``_tools_lock`` so it sees a + consistent snapshot alongside any concurrent + ``add_sensitive_tool``. The lock is uncontended under + CPython's GIL, so the cost is negligible. """ # Phase 4 (2026-07-05): O(1) lookup against the # pre-lowercased frozenset snapshot. The lock is still @@ -2323,10 +2309,7 @@ def is_sensitive_tool(self, tool_name: str) -> bool: # read itself is a single frozenset membership check. needle = tool_name.lower() with self._tools_lock: - return ( - needle in self._sensitive_tools_lower - or needle in self._strict_mode_tools_lower - ) + return needle in self._sensitive_tools_lower or needle in self._strict_mode_tools_lower def get_org_status(self, org_id: str | None = None) -> dict[str, Any]: """Public helper for reading ``/api/v1/orgs/{org_id}/status``. @@ -2370,50 +2353,46 @@ def get_org_status(self, org_id: str | None = None) -> dict[str, Any]: def add_sensitive_tool(self, tool_name: str) -> None: """ - Add a tool to the sensitive tools list. + Add a tool to the sensitive tools list. - Sensitive tools require strict mode enforcement and must go through - the /execute endpoint for pre-execution policy evaluation. + Sensitive tools require strict mode enforcement and must go through + the /execute endpoint for pre-execution policy evaluation. - Args: - tool_name: Name of the tool to mark as sensitive + Args: + tool_name: Name of the tool to mark as sensitive - Example: - runtime = NullRunRuntime.get_instance - runtime.add_sensitive_tool("my.custom_tool") + Example: + runtime = NullRunRuntime.get_instance + runtime.add_sensitive_tool("my.custom_tool") - #39: takes ``_tools_lock`` so the mutation is atomic - against concurrent ``is_sensitive_tool`` reads and other - ``add``/``remove`` calls. Without the lock a free-threaded - build could observe a torn set state during the mutation. + #39: takes ``_tools_lock`` so the mutation is atomic + against concurrent ``is_sensitive_tool`` reads and other + ``add``/``remove`` calls. Without the lock a free-threaded + build could observe a torn set state during the mutation. """ with self._tools_lock: self._strict_mode_tools.add(tool_name) # Phase 4: rebuild the lowercase snapshot so the # hot-path is_sensitive_tool sees the new entry. - self._strict_mode_tools_lower = frozenset( - t.lower() for t in self._strict_mode_tools - ) + self._strict_mode_tools_lower = frozenset(t.lower() for t in self._strict_mode_tools) def remove_sensitive_tool(self, tool_name: str) -> None: """ - Remove a tool from the sensitive tools list. + Remove a tool from the sensitive tools list. - Args: - tool_name: Name of the tool to remove from sensitive list + Args: + tool_name: Name of the tool to remove from sensitive list - Example: - runtime = NullRunRuntime.get_instance - runtime.remove_sensitive_tool("my.custom_tool") + Example: + runtime = NullRunRuntime.get_instance + runtime.remove_sensitive_tool("my.custom_tool") - #39: takes ``_tools_lock`` to mirror ``add_sensitive_tool``. + #39: takes ``_tools_lock`` to mirror ``add_sensitive_tool``. """ with self._tools_lock: self._strict_mode_tools.discard(tool_name) # Phase 4: rebuild the lowercase snapshot. - self._strict_mode_tools_lower = frozenset( - t.lower() for t in self._strict_mode_tools - ) + self._strict_mode_tools_lower = frozenset(t.lower() for t in self._strict_mode_tools) def register_sensitive_tools(self, tool_names: list[str]) -> None: """ @@ -2423,7 +2402,7 @@ def register_sensitive_tools(self, tool_names: list[str]) -> None: tool_names: List of tool names to mark as sensitive Example: - runtime = NullRunRuntime.get_instance + runtime = NullRunRuntime.get_instance runtime.register_sensitive_tools([ "stripe.charge" "payment.process" @@ -2436,9 +2415,7 @@ def register_sensitive_tools(self, tool_names: list[str]) -> None: # Phase 4: rebuild the lowercase snapshot once # after the batch insert (a single set comprehension # beats N rebuilds in the loop). - self._strict_mode_tools_lower = frozenset( - t.lower() for t in self._strict_mode_tools - ) + self._strict_mode_tools_lower = frozenset(t.lower() for t in self._strict_mode_tools) def get_sensitive_tools(self) -> set[str]: """ @@ -2687,21 +2664,21 @@ def execute( def start_recording(self, workflow_id: str, metadata: dict[str, Any] = None) -> str: """ - Start recording events for local decision history. + Start recording events for local decision history. -.. deprecated:: 0.8.0 - Decision history moved to the backend dashboard. This method - is a no-op stub and will be removed in 0.9.0. Use - ``nullrun.status `` for a per-runtime snapshot or visit - https:/docs.nullrun.io/concepts/decision-history for the - dashboard workflow. + .. deprecated:: 0.8.0 + Decision history moved to the backend dashboard. This method + is a no-op stub and will be removed in 0.9.0. Use + ``nullrun.status `` for a per-runtime snapshot or visit + https:/docs.nullrun.io/concepts/decision-history for the + dashboard workflow. - Args: - workflow_id: ID of the workflow to record - metadata: Optional metadata about the session + Args: + workflow_id: ID of the workflow to record + metadata: Optional metadata about the session - Returns: - session_id for this recording (always ``""`` since 0.4.0) + Returns: + session_id for this recording (always ``""`` since 0.4.0) """ # FIX 2026-06-28: was a silent no-op with logger.debug. Now emits # DeprecationWarning so customer code that still imports this @@ -2717,18 +2694,17 @@ def start_recording(self, workflow_id: str, metadata: dict[str, Any] = None) -> def stop_recording(self): """ - Stop recording and return the session. + Stop recording and return the session. -.. deprecated:: 0.8.0 - See:meth:`start_recording`. Will be removed in 0.9.0. + .. deprecated:: 0.8.0 + See:meth:`start_recording`. Will be removed in 0.9.0. - Returns: - The recorded session, or None if not recording + Returns: + The recorded session, or None if not recording """ # FIX 2026-06-28: paired deprecation warning for start_recording. warnings.warn( - "NullRunRuntime.stop_recording() is deprecated and will be " - "removed in nullrun 0.9.0.", + "NullRunRuntime.stop_recording() is deprecated and will be removed in nullrun 0.9.0.", DeprecationWarning, stacklevel=2, ) @@ -2807,6 +2783,7 @@ def _enrich_event(self, event: dict[str, Any]) -> dict[str, Any]: from nullrun.context import ( clear_server_minted_execution_id, ) + clear_server_minted_execution_id() logger.debug( "_enrich_event: dropping stale server-minted " @@ -2895,48 +2872,46 @@ def _enrich_event(self, event: dict[str, Any]) -> dict[str, Any]: def _route_track(self, wire_event: dict[str, Any]) -> None: """Route a tracked event to v3 single-event /track or - legacy batch /track/batch. - - Why this exists - --------------- - Pre-0.12.0 wiring the SDK always called - ``self._transport.track(wire_event)`` which posts to the - legacy ``/api/v1/track/batch`` (the ``process_span_event`` - pipeline). That pipeline reads the org's lifetime - ``monthly_cost`` counter — drift with the dashboard's - period-bound ``bp:{ts}:cost_cents`` per G1 - and never exercises v3 ``consume_budget_v3`` so the - consume ≤ reserve + ε invariant is never validated. - - The fix: route events that have a paired ``/check`` - reservation (currently: ``llm_call``) to - ``track_single`` which posts to ``/api/v1/track``. The - backend's consume takes the server-minted execution_id - from the request, looks up - ``reservation:{execution_id}`` and runs the invariant. - Span events still ride /track/batch — they have no - reservation to release. - - Opt-out - ------- - ``NULLRUN_V3_TRACK_DISABLE=1`` forces every event - through the legacy batch path. Use it on backends that - haven't flipped ``NULLRUN_CONSUME_V3_ENABLED=1`` yet. - - Failure mode - ------------ - ``track_single`` raises on 422 / 503 / 5xx (see - ``nullrun.breaker.exceptions``). We catch and log at - WARNING level; the event is dropped (NOT retried via - the batch path — that would risk double-billing - idempotency contract). + legacy batch /track/batch. + + Why this exists + --------------- + Pre-0.12.0 wiring the SDK always called + ``self._transport.track(wire_event)`` which posts to the + legacy ``/api/v1/track/batch`` (the ``process_span_event`` + pipeline). That pipeline reads the org's lifetime + ``monthly_cost`` counter — drift with the dashboard's + period-bound ``bp:{ts}:cost_cents`` per G1 + and never exercises v3 ``consume_budget_v3`` so the + consume ≤ reserve + ε invariant is never validated. + + The fix: route events that have a paired ``/check`` + reservation (currently: ``llm_call``) to + ``track_single`` which posts to ``/api/v1/track``. The + backend's consume takes the server-minted execution_id + from the request, looks up + ``reservation:{execution_id}`` and runs the invariant. + Span events still ride /track/batch — they have no + reservation to release. + + Opt-out + ------- + ``NULLRUN_V3_TRACK_DISABLE=1`` forces every event + through the legacy batch path. Use it on backends that + haven't flipped ``NULLRUN_CONSUME_V3_ENABLED=1`` yet. + + Failure mode + ------------ + ``track_single`` raises on 422 / 503 / 5xx (see + ``nullrun.breaker.exceptions``). We catch and log at + WARNING level; the event is dropped (NOT retried via + the batch path — that would risk double-billing + idempotency contract). """ from nullrun.context import get_server_minted_execution_id event_type = wire_event.get("type") - v3_disabled = ( - os.environ.get("NULLRUN_V3_TRACK_DISABLE", "").strip() == "1" - ) + v3_disabled = os.environ.get("NULLRUN_V3_TRACK_DISABLE", "").strip() == "1" if event_type != "llm_call" or v3_disabled: # Span / heartbeat / tool events have no reservation @@ -2975,8 +2950,7 @@ def _route_track(self, wire_event: dict[str, Any]) -> None: status_code=getattr(exc, "status_code", None), ) logger.warning( - "_route_track: track_single failed for " - "execution_id=%s (%s) — event dropped", + "_route_track: track_single failed for execution_id=%s (%s) — event dropped", smid, exc, ) @@ -3163,7 +3137,7 @@ def _post_auth_with_retry( except httpx.RequestError as e: last_exc = e if attempt < max_attempts - 1: - backoff_s = min(0.5 * (2 ** attempt), 5.0) + backoff_s = min(0.5 * (2**attempt), 5.0) logger.debug( f"/auth/verify network error " f"(attempt {attempt + 1}/{max_attempts}): " @@ -3182,9 +3156,9 @@ def _post_auth_with_retry( backoff_s = float(retry_after_header) except ValueError: # HTTP-date or unparseable — fall back to exp backoff - backoff_s = min(0.5 * (2 ** attempt), 5.0) + backoff_s = min(0.5 * (2**attempt), 5.0) else: - backoff_s = min(0.5 * (2 ** attempt), 5.0) + backoff_s = min(0.5 * (2**attempt), 5.0) logger.debug( f"/auth/verify returned {response.status_code} " f"(attempt {attempt + 1}/{max_attempts}); " @@ -3213,6 +3187,7 @@ def _post_auth_with_retry( def __getattr__(name): if name == "_runtime": from nullrun._registry import get_active_runtime + return get_active_runtime() raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -3225,7 +3200,6 @@ def __getattr__(name): # on module instances. - # 2026-07-04 (v0.12.0 wiring fix — ): # helper used by ``check_workflow_budget`` to capture the server-minted # execution_id from the /check response into a contextvar. Lives at @@ -3368,8 +3342,7 @@ def _build_v3_track_payload( # SDK never bound the API key to a workflow (legacy # legacy-no-binding). Fall back. logger.debug( - "_build_v3_track_payload: missing workflow_id — " - "cannot shape v3 /track payload" + "_build_v3_track_payload: missing workflow_id — cannot shape v3 /track payload" ) return None @@ -3377,10 +3350,7 @@ def _build_v3_track_payload( if tokens is None: # Same as llm_call missing required fields — the backend # would 422 anyway. Fall back to batch. - logger.debug( - "_build_v3_track_payload: missing tokens — cannot " - "shape v3 /track payload" - ) + logger.debug("_build_v3_track_payload: missing tokens — cannot shape v3 /track payload") return None payload: dict[str, Any] = { @@ -3388,7 +3358,7 @@ def _build_v3_track_payload( "workflow_id": wf_id, "tokens": int(tokens), "cost_cents": 0, - "cost_source": "provisional", # + "cost_source": "provisional", # } if "input_tokens" in wire_event and wire_event["input_tokens"] is not None: payload["input_tokens"] = int(wire_event["input_tokens"]) diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 66776e3..f1e7b4a 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -1200,7 +1200,7 @@ def _send_batch_with_retry_info(self, batch: list[dict[str, Any]]) -> "SendResul # * 5xx → raise_for_status raises HTTPStatusError → retry helper backs off # and re-attempts. 429 is included in this category (the helper honors # Retry-After when present). - # * 4xx (other than 429) → return as-is, the outer raise_for_status + # * 4xx (other than 429) → return as-is, the outer raise_for_status # surfaces it. These are real client bugs (auth, payload) and must # NOT be retried — retrying a 401 just wastes the user's budget. def _post_batch() -> httpx.Response: @@ -1298,9 +1298,7 @@ def _post_batch() -> httpx.Response: if action_type: handle_action(action_type, workflow_id, reason) except Exception as item_err: - logger.warning( - "Skipping malformed action %r: %s", action, item_err - ) + logger.warning("Skipping malformed action %r: %s", action, item_err) # Display-only backend messages (renamed from `actions_taken: Vec`). for msg in data.get("messages", []) or []: logger.info("Backend message: %s", msg) @@ -1592,15 +1590,11 @@ def check( # `set_call_context(tools=[...])` had no effect on /gate. # When unset (None) we omit the key entirely — the backend # distinguishes "no tools sent" from "explicit []". - **( - {"tools": check_request["tools"]} - if "tools" in check_request - else {} - ), + **({"tools": check_request["tools"]} if "tools" in check_request else {}), } # 2026-07-02 (v0.11.0): wire-protocol v3 fields ( - #). Forwarded only when present so legacy /gate callers + # ). Forwarded only when present so legacy /gate callers # (which never set chain_id) keep their previous payload # shape. The backend treats missing as "single-shot Hard". if check_request.get("chain_id") is not None: @@ -1746,7 +1740,7 @@ async def wrapped_policy_invalidated(ws_id: str, policy_id: str, new_version: in if on_policy_invalidated: on_policy_invalidated(ws_id, policy_id, new_version) -# Wrap the key rotated callback to re-fetch credentials + # Wrap the key rotated callback to re-fetch credentials async def wrapped_key_rotated(ws_id: str, key_id: str, new_version: int) -> None: logger.info(f"Key {key_id} rotated (v{new_version}), re-fetching credentials") await self._refetch_credentials() @@ -1908,62 +1902,62 @@ def track_single( ) -> dict[str, Any]: """POST /api/v1/track — wire-protocol v3 single-event consume. -. The single-event path is the v3 - replacement for the legacy `/api/v1/track/batch` POST body. - It runs the CONSUME_SCRIPT invariant - ``actual_cost <= reserved_cents + epsilon_cents`` (§25 - ADR-005) and rejects with 422 CONSUME_OVERBUDGET on - violation. The reserved binding is the one created by the - matching ``/check`` call (same ``reservation_id``). - - The wire shape is built by ``runtime._build_v3_track_payload`` - (see ``runtime.py:2679-2776``); this method just forwards - whatever dict the caller hands it. The post-fix schema is: - - Args: - request: Consume request body. Must include: - - * ``reservation_id`` (str, server-minted uuidv7 from - the matching /check response — wired via - ``_capture_server_minted_execution_id``) - * ``workflow_id`` (str, the workflow the call belongs to) - * ``tokens`` (int, sum of input + output tokens) - * ``cost_cents`` (int, ``0`` — backend computes the - authoritative cost from tokens + the org's - pricing policy; sending a wrong number risks - double-billing, see _WIRE_STRIP_FIELDS in runtime.py) - * ``cost_source`` (str, ``"provisional"`` / - ``"authoritative"`` per — SDK always emits - ``"provisional"``) - - Optional fields: ``input_tokens``, ``output_tokens`` - ``model``, ``latency_ms``, ``metadata``, ``trace_id`` - ``span_id``, ``agent_id``, ``environment`` - ``agent_type``, ``attempt_index``, ``is_retry`` - ``idempotency_key``. - - Returns: - Parsed JSON dict with at least - ``{"status": "ok"|"idempotent_replay",...}``. - - Raises: - NullRunConsumeOverbudgetError: 422 CONSUME_OVERBUDGET — - ``actual_cost > reserved + epsilon_cents``. The - reservation is NOT silently re-reserved. - NullRunBackendError: 503 RESERVATION_NOT_FOUND / - EXECUTION_NOT_BOUND. - NullRunAuthenticationError: 401/403. - - 2026-07-04 (B2): pre-fix this docstring (and the - surrounding module comment) described a fictitious wire - shape ``{execution_id, actual_cost_cents, api_key_id - cost_source}``. The backend's actual ``TrackRequestRaw`` is - ``{workflow_id, tokens, cost_cents,...}``; ``execution_id`` - is replaced by ``reservation_id``, ``actual_cost_cents`` is - replaced by ``cost_cents`` (the SDK always sends 0 — see - ``_WIRE_STRIP_FIELDS``), and ``api_key_id`` is derived - server-side from the request auth, not supplied by the SDK. - The docstring now matches the real wire contract. + . The single-event path is the v3 + replacement for the legacy `/api/v1/track/batch` POST body. + It runs the CONSUME_SCRIPT invariant + ``actual_cost <= reserved_cents + epsilon_cents`` (§25 + ADR-005) and rejects with 422 CONSUME_OVERBUDGET on + violation. The reserved binding is the one created by the + matching ``/check`` call (same ``reservation_id``). + + The wire shape is built by ``runtime._build_v3_track_payload`` + (see ``runtime.py:2679-2776``); this method just forwards + whatever dict the caller hands it. The post-fix schema is: + + Args: + request: Consume request body. Must include: + + * ``reservation_id`` (str, server-minted uuidv7 from + the matching /check response — wired via + ``_capture_server_minted_execution_id``) + * ``workflow_id`` (str, the workflow the call belongs to) + * ``tokens`` (int, sum of input + output tokens) + * ``cost_cents`` (int, ``0`` — backend computes the + authoritative cost from tokens + the org's + pricing policy; sending a wrong number risks + double-billing, see _WIRE_STRIP_FIELDS in runtime.py) + * ``cost_source`` (str, ``"provisional"`` / + ``"authoritative"`` per — SDK always emits + ``"provisional"``) + + Optional fields: ``input_tokens``, ``output_tokens`` + ``model``, ``latency_ms``, ``metadata``, ``trace_id`` + ``span_id``, ``agent_id``, ``environment`` + ``agent_type``, ``attempt_index``, ``is_retry`` + ``idempotency_key``. + + Returns: + Parsed JSON dict with at least + ``{"status": "ok"|"idempotent_replay",...}``. + + Raises: + NullRunConsumeOverbudgetError: 422 CONSUME_OVERBUDGET — + ``actual_cost > reserved + epsilon_cents``. The + reservation is NOT silently re-reserved. + NullRunBackendError: 503 RESERVATION_NOT_FOUND / + EXECUTION_NOT_BOUND. + NullRunAuthenticationError: 401/403. + + 2026-07-04 (B2): pre-fix this docstring (and the + surrounding module comment) described a fictitious wire + shape ``{execution_id, actual_cost_cents, api_key_id + cost_source}``. The backend's actual ``TrackRequestRaw`` is + ``{workflow_id, tokens, cost_cents,...}``; ``execution_id`` + is replaced by ``reservation_id``, ``actual_cost_cents`` is + replaced by ``cost_cents`` (the SDK always sends 0 — see + ``_WIRE_STRIP_FIELDS``), and ``api_key_id`` is derived + server-side from the request auth, not supplied by the SDK. + The docstring now matches the real wire contract. """ # 2026-07-06 (bug-fix): the previous shape called # `_build_signed_headers()` *before* `_signed_request_body()`. @@ -2010,23 +2004,23 @@ def cancel( ) -> dict[str, Any]: """POST /api/v1/cancel — cancel an in-flight execution. -. The server uses - ``cancel:{execution_id}`` SETNX to deduplicate repeated - cancellations: a 200 OK response is idempotent. A - non-existent ``execution_id`` returns 404 — we surface it - as ``NullRunBackendError`` because retrying with the same - id is not a valid recovery path (the execution already - terminated). - - Args: - execution_id: Server-minted id from the matching /check - response. - reason: Optional human-readable reason for the - cancellation (audit trail). - - Returns: - Parsed JSON dict (typically ``{"status": "ok" - "execution_id":..., "cancelled_at": ts}``). + . The server uses + ``cancel:{execution_id}`` SETNX to deduplicate repeated + cancellations: a 200 OK response is idempotent. A + non-existent ``execution_id`` returns 404 — we surface it + as ``NullRunBackendError`` because retrying with the same + id is not a valid recovery path (the execution already + terminated). + + Args: + execution_id: Server-minted id from the matching /check + response. + reason: Optional human-readable reason for the + cancellation (audit trail). + + Returns: + Parsed JSON dict (typically ``{"status": "ok" + "execution_id":..., "cancelled_at": ts}``). """ request: dict[str, Any] = {"execution_id": execution_id} if reason: @@ -2065,23 +2059,23 @@ def heartbeat( ) -> dict[str, Any]: """POST /api/v1/heartbeat — extend a chain's idle TTL. -. The server runs - ``EXPIRE chain:{org}:{chain_id} 300`` atomically and - deduplicates repeated heartbeats via - ``heartbeat:{chain_id}:{ts_floor_30s}`` SETNX - (TTL = 35s — the 5s tail absorbs ±5s skew per). + . The server runs + ``EXPIRE chain:{org}:{chain_id} 300`` atomically and + deduplicates repeated heartbeats via + ``heartbeat:{chain_id}:{ts_floor_30s}`` SETNX + (TTL = 35s — the 5s tail absorbs ±5s skew per). - Recommended cadence: every 30s of wall-clock time (the - SDK's ``ping_chain`` helper wraps this method with the - time-based scheduler). Bursting heartbeats more often than - once per 30s is wasted bandwidth — the SETNX dedups them. + Recommended cadence: every 30s of wall-clock time (the + SDK's ``ping_chain`` helper wraps this method with the + time-based scheduler). Bursting heartbeats more often than + once per 30s is wasted bandwidth — the SETNX dedups them. - Args: - chain_id: Active chain_id. + Args: + chain_id: Active chain_id. - Returns: - Parsed JSON dict (typically ``{"status": "ok" - "chain_id":..., "last_active": ts}``). + Returns: + Parsed JSON dict (typically ``{"status": "ok" + "chain_id":..., "last_active": ts}``). """ request = {"chain_id": chain_id} # 2026-07-06 (bug-fix): same body-before-headers reorder as @@ -2113,25 +2107,25 @@ def chain_end( chain_id: str, ) -> dict[str, Any]: """Close a chain explicitly via /api/v1/gate with chain_op=end -. - - Pre-fix this method POSTed to ``/api/v1/chain/end``. That - endpoint was never registered on the backend - (``backend/src/proxy/http/routes.rs`` has zero matches for - ``chain/end`` or ``chain_end_handler``) — the only documented - way to close a chain is to POST /api/v1/gate with - ``{"chain_id": "...", "chain_op": "end"}``. The handler is - already idempotent — a no-op 200 OK for an unknown chain_id - is the documented success path. The SDK still raises through - the envelope parser on a true non-2xx so unexpected backend - regressions surface. - - Args: - chain_id: Chain to close. - - Returns: - Parsed JSON dict (typically ``{"decision": "allow" - "chain_id":...}``). + . + + Pre-fix this method POSTed to ``/api/v1/chain/end``. That + endpoint was never registered on the backend + (``backend/src/proxy/http/routes.rs`` has zero matches for + ``chain/end`` or ``chain_end_handler``) — the only documented + way to close a chain is to POST /api/v1/gate with + ``{"chain_id": "...", "chain_op": "end"}``. The handler is + already idempotent — a no-op 200 OK for an unknown chain_id + is the documented success path. The SDK still raises through + the envelope parser on a true non-2xx so unexpected backend + regressions surface. + + Args: + chain_id: Chain to close. + + Returns: + Parsed JSON dict (typically ``{"decision": "allow" + "chain_id":...}``). """ # 2026-07-04 (B3): POST /api/v1/gate with # ``chain_op: "end"``. The backend's gate handler @@ -2182,31 +2176,31 @@ def approximate_budget( ) -> dict[str, Any]: """GET /api/v1/budget/approximate — UI-only budget estimation. -. NEVER for enforcement — the backend stamps - ``is_approximate: true`` on every response. The endpoint - returns 503 ``BUDGET_DATA_UNAVAILABLE`` if all three sources - (Redis period counter → Postgres cost_events → last-known - cache) fail — NEVER returns 0, because a UI that displays - "≈ $0 spent" when no data is available misleads the user. - - Used by ``nullrun.cost_dashboard `` / ``examples/cost_dashboard.py`` - and the dashboard rollup panel. - - Args: - organization_id: Optional org override; defaults to the - transport's bound org via the auth/verify result. - - Returns: - Parsed JSON dict with ``current_spend_cents_estimate`` - ``is_approximate: True``, ``source`` (BudgetSource enum - string), ``confidence`` (High/Medium/Low), and - ``last_updated_at``. - - Raises: - NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE (all - sources failed) — caller should display "Data - unavailable" + retry button, NOT "$0 spent". - NullRunAuthenticationError: 401/403. + . NEVER for enforcement — the backend stamps + ``is_approximate: true`` on every response. The endpoint + returns 503 ``BUDGET_DATA_UNAVAILABLE`` if all three sources + (Redis period counter → Postgres cost_events → last-known + cache) fail — NEVER returns 0, because a UI that displays + "≈ $0 spent" when no data is available misleads the user. + + Used by ``nullrun.cost_dashboard `` / ``examples/cost_dashboard.py`` + and the dashboard rollup panel. + + Args: + organization_id: Optional org override; defaults to the + transport's bound org via the auth/verify result. + + Returns: + Parsed JSON dict with ``current_spend_cents_estimate`` + ``is_approximate: True``, ``source`` (BudgetSource enum + string), ``confidence`` (High/Medium/Low), and + ``last_updated_at``. + + Raises: + NullRunBackendError: 503 BUDGET_DATA_UNAVAILABLE (all + sources failed) — caller should display "Data + unavailable" + retry button, NOT "$0 spent". + NullRunAuthenticationError: 401/403. """ # ApproximateBudget uses GET (not POST) per the wire contract # no signed body, so we use _auth_headers directly instead @@ -2336,9 +2330,7 @@ def _extract_error_envelope( code = str(body.get("error_code", "") or "") # The 503 budget path uses "message" instead of # "error_message". Accept both. - message = str( - body.get("error_message") or body.get("message") or raw_text or "" - ) + message = str(body.get("error_message") or body.get("message") or raw_text or "") details_raw = body.get("details") or {} if not isinstance(details_raw, dict): details_raw = {} @@ -2369,9 +2361,7 @@ def _extract_error_envelope( # Everything except ``error`` and ``message`` goes into # details for diagnostic context. details = { - k: v - for k, v in body.items() - if k not in ("error", "message") and not k.startswith("_") + k: v for k, v in body.items() if k not in ("error", "message") and not k.startswith("_") } return (code, message, details) @@ -2419,7 +2409,7 @@ def _parse_v3_error_envelope( if not isinstance(body, dict): body = {} -# Drift §3 (2026-07-06): the wire envelope is NOT one shape. + # Drift §3 (2026-07-06): the wire envelope is NOT one shape. # The backend has three distinct error emission paths today: # # 1. v3 envelope (gate/internal.rs, handlers.rs::track_handler): @@ -2443,9 +2433,7 @@ def _parse_v3_error_envelope( # _extract_error_envelope() handles all four shapes; this block # just consumes the normalised tuple. backend_code, message, details = _extract_error_envelope(body, response.text) - retry_after_ms: float | None = ( - body.get("retry_after_ms") if isinstance(body, dict) else None - ) + retry_after_ms: float | None = body.get("retry_after_ms") if isinstance(body, dict) else None # Retry-After header takes precedence over the JSON field when # both are present (server-side convention — header is canonical # per RFC 7231, JSON is a NullRun-specific fallback). @@ -2465,11 +2453,11 @@ def _parse_v3_error_envelope( full_message = f"{endpoint}: {message}" if backend_code == "PROTOCOL_TOO_OLD" or backend_code == "PROTOCOL_TOO_NEW": - # NullRunProtocolError → NullRunInfrastructureError → - # NullRunError base. Base constructor does NOT accept - # a generic ``details=`` kwarg. Pass message only — the - # catalog value already encodes error_code + retryable. - return NullRunProtocolError(full_message) + # NullRunProtocolError → NullRunInfrastructureError → + # NullRunError base. Base constructor does NOT accept + # a generic ``details=`` kwarg. Pass message only — the + # catalog value already encodes error_code + retryable. + return NullRunProtocolError(full_message) if backend_code == "CONSUME_OVERBUDGET": return NullRunConsumeOverbudgetError( @@ -2482,7 +2470,11 @@ def _parse_v3_error_envelope( status_code=status, # 422 per backend mapping ) - if backend_code == "CHAIN_MAX_DURATION_EXCEEDED" or backend_code == "CHAIN_CROSS_ORG" or backend_code == "CHAIN_ORG_MISMATCH": + if ( + backend_code == "CHAIN_MAX_DURATION_EXCEEDED" + or backend_code == "CHAIN_CROSS_ORG" + or backend_code == "CHAIN_ORG_MISMATCH" + ): return NullRunChainError( full_message, chain_id=details.get("chain_id"), @@ -2499,13 +2491,13 @@ def _parse_v3_error_envelope( ) if backend_code == "RATE_LIMIT_REDIS_UNAVAILABLE": - # NullRunRateLimitRedisError → NullRunInfrastructureError - # → NullRunError base. Base constructor accepts only - # message + (error_code, user_action, retryable, docs_url - # cause) — NOT a generic ``details=``. The catalog value - # already encodes error_code + retryable, so we just pass - # the message. - return NullRunRateLimitRedisError(full_message) + # NullRunRateLimitRedisError → NullRunInfrastructureError + # → NullRunError base. Base constructor accepts only + # message + (error_code, user_action, retryable, docs_url + # cause) — NOT a generic ``details=``. The catalog value + # already encodes error_code + retryable, so we just pass + # the message. + return NullRunRateLimitRedisError(full_message) if backend_code == "RATE_LIMIT_EXCEEDED": retry_after = retry_after_ms / 1000.0 if retry_after_ms else None @@ -2580,14 +2572,12 @@ def _parse_v3_error_envelope( # that exposes status_code + error_code for the caller. if status in (401, 403): return NullRunAuthenticationError( - f"Auth failed on {endpoint} (status {status}, error_code=" - f"{backend_code!r}): {message}" + f"Auth failed on {endpoint} (status {status}, error_code={backend_code!r}): {message}" ) if status == 429: retry_after = retry_after_ms / 1000.0 if retry_after_ms else None return RateLimitError( - f"Rate limited on {endpoint} (status 429, error_code=" - f"{backend_code!r}): {message}", + f"Rate limited on {endpoint} (status 429, error_code={backend_code!r}): {message}", source=TransportErrorSource.GATEWAY_ERROR, endpoint=endpoint, retry_after=retry_after, @@ -2595,14 +2585,12 @@ def _parse_v3_error_envelope( ) if 500 <= status < 600: return NullRunBackendError( - f"{endpoint}: {message} (status {status}, error_code=" - f"{backend_code!r})", + f"{endpoint}: {message} (status {status}, error_code={backend_code!r})", endpoint=endpoint, status_code=status, ) return NullRunBackendError( - f"{endpoint}: {message} (status {status}, error_code=" - f"{backend_code!r})", + f"{endpoint}: {message} (status {status}, error_code={backend_code!r})", endpoint=endpoint, status_code=status, ) diff --git a/tests/test_preflight_fail_policy.py b/tests/test_preflight_fail_policy.py index 4b39236..2d63100 100644 --- a/tests/test_preflight_fail_policy.py +++ b/tests/test_preflight_fail_policy.py @@ -75,6 +75,13 @@ def add_sensitive_tool(self, tool_name: str) -> None: def track_event(self, event_type: str, **kwargs) -> None: self.events.append({"type": event_type, **kwargs}) + def track_tool(self, tool_name: str, **kwargs) -> None: + # Commit 33d2b5f wires ``@protect`` to emit a tools/track_tool event + # after the wrapped body returns. The stub captures that emit the + # same way it captures the other track paths so the gate-order + # assertions keep working unchanged. + self.events.append({"type": "tool_call", "tool_name": tool_name, **kwargs}) + # The two gates we want to track, in order. The decorator # calls them — we record the call sequence. diff --git a/tests/test_protect.py b/tests/test_protect.py index 57b3cd2..efa1207 100644 --- a/tests/test_protect.py +++ b/tests/test_protect.py @@ -50,6 +50,13 @@ def __init__(self) -> None: def track_event(self, event_type: str, **kwargs) -> None: self.events.append({"type": event_type, **kwargs}) + def track_tool(self, tool_name: str, **kwargs) -> None: + # Commit 33d2b5f wires ``@protect`` to emit a tools/track_tool event + # after the wrapped body returns. The stub captures that emit the + # same way it captures span_start/span_end so the dashboard-level + # assertions keep working unchanged. + self.events.append({"type": "tool_call", "tool_name": tool_name, **kwargs}) + def check_control_plane(self, workflow_id) -> None: # noqa: ARG002 return None