Skip to content

Commit bc34d61

Browse files
authored
chore(release): 0.14.4 — ToolParameters Approval Rules wire contract (#80)
* some clean up * fix(runtime): treat websocket cancellation as clean shutdown * fix(transport): approval_resolved callback is synchronous * SdkTrackRequest * 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. * feat(sdk): ToolParameters phase 1 wire contract -- auto-attach ToolParamsExtractor on bare @sensitive Phase 1 / MVP 1.1 (Tier 2 / Razryv 2 follow-up). The backend already accepts BusinessImpact::ToolCall(ToolCallParams) on the /execute wire (commit 1e501cd6 in the backend repo). This commit wires the SDK-side path so users get ToolParameters Approval Rules by default with no decorator changes: @sensitive @Protect def delete_user(user_id: int, force: bool = False): ... The above now ships BusinessImpact(kind='tool_call', tool_name='delete_user', params={'user_id': ..., 'force': ...}) on every /execute call, matched against ToolParameters rules on the backend. No new decorator argument required. What ships: - business_impact.py: ToolCallParams dataclass mirrors the backend struct (tool_name <= 128 bytes, param_name <= 64, JSON-roundtrippable values only). BusinessImpact.kind now discriminates Money | ToolCall. New factory BusinessImpact.tool_call(...) for hand-built impacts. - extractor.py: ToolParamsExtractor class + tool_params() factory (by analogy with MoneyImpactExtractor + money_outflow). Three modes: explicit {rule_param: arg_name} map, include_all (default, every kwarg), or empty (include_all=False with no map). JSON-unsafe values (float, custom objects) and PII-masked sentinels ("***") are filtered before the wire. - decorators.py: _enforce_sensitive_tool dispatch now handles both MoneyImpactExtractor and ToolParamsExtractor. NR-B003 error hint branches by extractor type so the operator sees the right remediation advice. - decorators.py: _do_sensitive_register auto-attaches a default ToolParamsExtractor(include_all=True) on bare @sensitive. An explicit @sensitive(impact=money_outflow(...)) wins -- the auto-attach only fires when no extractor is present. The stamp uses _stamp_extractor_on_innermost so the bare function (the one @Protect captures as fn) carries the attribute, not just the @Protect wrapper (the 2026-07-24 root-cause fix). - tests/test_tool_params_extractor.py: 19 tests covering factory shape, three extraction modes, PII sentinel filtering, JSON round-trip, fail-CLOSED on backend rejection, action_digest byte-identity with the backend's canonical JSON, the auto-attach wiring, the auto-attach-vs-explicit-extractor priority, and the dataclass validator. Wire-contract compatibility: - Default SDK behaviour for bare @sensitive CHANGED: was 'no business_impact on wire', now 'kind=tool_call on wire'. Operators who relied on the Phase 0 path (approval_id-only grant consume) must either pass @sensitive(impact=tool_params(include_all=False)) explicitly or accept the ToolParameters wire shape. - Existing @sensitive(impact=money_outflow(...)) callers are unaffected: the explicit extractor wins over the auto-attach. - Legacy 'no impact extractor' test_sensitive_extractor.py fixture (registers the tool manually, bypassing the decorator) still passes because auto-attach is only wired through _do_sensitive_register -- the @sensitive decorator path. Users who registered sensitive tools via rt.add_sensitive_tool(name) directly are unaffected. Verification: - tests/test_tool_params_extractor.py: 19 passed - tests/test_sensitive_extractor.py: 5 passed (regression check) - tests/test_business_impact.py: 19 passed - tests/test_extractors.py: 35 passed - tests/test_protect.py + test_protect_branches.py + test_execute_approval_flow.py + test_approval_money_flow.py + test_gate_real_path.py + test_handle.py: 99 passed - tests/test_runtime.py + test_runtime_branches.py + test_init_contract.py: 70 passed, 1 skipped Per project rhythm: local commit only, no push. Refs: - backend BusinessImpact::ToolCall variant: backend/src/proxy/gate/business_impact.rs:62-307 - backend Razryv 2 / Tier 1+2 commits: 1e501cd6, 63ba9f6a - Test companion for Phase 1 / MVP 1.0 money: tests/test_sensitive_extractor.py - Plan: fix-plan.md P1-2 (Phase 1 trust_level enum -- now unblocked once this commit lands and operators actually deploy ToolParameters rules). * fix(sdk): auto-attach chain walk -- preserve explicit impact=tool_params() map Ad-hoc verification after the initial commit (40d391a) surfaced a silent regression in the auto-attach path: @sensitive(impact=tool_params({"delete_force": "force"})) @Protect def delete_user(force, user_id): ... Before this fix, the wire payload for this function used the auto-attach DEFAULT (every kwarg, no rename) instead of the explicit map the user wrote. Root cause: ``_do_sensitive_register`` called ``getattr(fn, "_nullrun_extractor", None)`` on the @Protect WRAPPER, but ``@sensitive(impact=...)`` factory form stamps the explicit extractor on the BARE function via ``_stamp_extractor_on_innermost``. The wrapper itself has no attribute, so the check returned None and the auto-attach path silently overwrote with the default ToolParamsExtractor( include_all=True). The user's explicit param_extractors map was discarded without warning. Fix: walk the ``__wrapped__`` chain in ``_do_sensitive_register`` via a new helper ``_find_extractor_in_chain``. The walk is bounded (32 hops) to defend against pathological ``__wrapped__`` cycles and returns the FIRST extractor found or None. Behavior: * ``@sensitive`` bare -> chain walk finds nothing, auto-attach default wins. * ``@sensitive(impact=money_outflow(...))`` -> chain walk finds the MoneyImpactExtractor stamped on the bare function; auto-attach skips. * ``@sensitive(impact=tool_params({...}))`` -> chain walk finds the explicit map; auto-attach skips. Wire contract for end users: * ``@sensitive(impact=tool_params({"force_param": "force"}))`` on a function with ``force: bool, user_id: int`` kwargs now sends ``{force_param: <bool>}`` on the wire (the renamed key) instead of the previous broken ``{force: <bool>, user_id: <int>}``. * Bare ``@sensitive`` continues to ship ``{force: <bool>, user_id: <int>}`` on the wire (unchanged from commit 40d391a). * ``@sensitive(impact=money_outflow(...))`` is unaffected (chain walk finds the MoneyImpactExtractor, auto-attach skips). Regression tests (4 new in ``tests/test_tool_params_extractor.py::TestAutoAttachChainWalk``): * ``test_bare_sensitive_chain_walk_attaches_default``: pin the bare-form auto-attach path. * ``test_explicit_tool_params_chain_walk_preserves_map``: the regression case -- explicit ``impact=tool_params({...})`` must NOT be overwritten. * ``test_explicit_money_outflow_chain_walk_preserved``: the original Phase 1 / MVP 1.0 money variant must NOT be overwritten (regression on the regression). * ``test_chain_walk_does_not_loop_on_circular_wraps``: defensive -- a pathological ``__wrapped__`` cycle (a -> a or a -> b -> a -> b) returns None within the bounded hop count without hanging. Verification: - cargo check equivalent: ``.venv-ci/Scripts/python.exe -m pytest`` on the full touched surface (160 tests across test_tool_params_extractor, test_sensitive_extractor, test_business_impact, test_extractors, test_protect, test_protect_branches, test_execute_approval_flow, test_approval_money_flow) -- 160 passed, 0 failed. - ad-hoc verification script at ``C:/Users/ANATOL~1/AppData/Local/Temp/hermes-verify-toolparams.py`` confirms all three decorator variants wire the correct extractor type with the right map. Local commit only; no push. Refs: - Original commit (the regression): 40d391a - Ad-hoc verifier that surfaced the regression: ``hermes-verify-toolparams.py`` * test(sdk): ToolCall cross-language parity pin against Rust golden hex Pins the SDK compute_action_digest(BusinessImpact.tool_call(...)) to the same hex literal the Rust backend asserts in backend/src/proxy/gate/business_impact.rs::tests:: tool_call_digest_golden_value_stripe_charge_500. Fixture payload: BusinessImpact.tool_call("stripe.charge", {"region": "EU", "amount": 500}) -- mirrors the backend helper at business_impact.rs:1473. The protocol prefix and canonical-JSON algorithm must remain identical across both languages; a drift on either side trips BOTH pins next time the suite runs. Five new tests in TestToolCallActionDigestPins: * test_tool_call_stripe_charge_500_matches_golden_hex -- the pin itself * test_tool_call_two_calls_produce_identical_hex -- determinism for the tamper-evident re-check on /execute * test_tool_call_param_change_produces_different_hex -- positive half: param change flips the digest * test_tool_call_wire_dict_shape -- kind='tool_call' discriminator (snake_case) pin so a typo would not silently route to Money on the backend * test_tool_call_extractor_metadata_advisory -- extractor_* defaults are advisory provenance, present on every wire payload NOTE: ToolCallParams.validate() is intentionally NOT auto- invoked by the dataclass __post_init__ -- the SDK relies on BusinessImpact.tool_call(...) factory to enforce rejection paths. Direct ToolCallParams(...) construction succeeds without raising. This is a documented design choice that matches the backend Rust struct (validation at the construction site, not on the wire carrier). Verification: pytest tests/test_business_impact.py -v -> 28 passed (23 pre-existing + 5 new pins) Local commit only; no push. Refs: backend Rust pin: backend/src/proxy/gate/business_impact.rs backend Tier 2 commit: 1e501cd6 SDK ToolParameters phase 1: 40d391a SDK auto-attach chain walk fix: 436dc7b Plan: docs/runbooks/action-digest-contract.md * chore(release): 0.14.4 - ToolParameters Approval Rules wire contract Bumps SDK 0.14.2 -> 0.14.4 and lands the Tier 2 / Разрыв 2 follow-up that wires the SDK-side ToolParameters path so users get ToolParameters Approval Rules by default on every bare @sensitive function with no decorator change. Skipping 0.14.3 because the working branch was tagged archive/cleanup-attempted-1c1e326 throughout the ToolParameters work; this release is the first user-facing tag on the release/0.14.4-toolparameters branch. What ships in this release (full changelog at CHANGELOG.md): * BusinessImpact.tool_call(...) factory + ToolCallParams dataclass (business_impact.py) * ToolParamsExtractor + tool_params(...) factory (extractor.py) * Bare @sensitive auto-attaches a default ToolParamsExtractor(include_all=True) via _do_sensitive_register * @sensitive(impact=tool_params({...})) decorator form * Auto-attach chain walk (_find_extractor_in_chain) preserves an explicit impact=tool_params({...}) map -- the regression that was silently dropped in 40d391a (fixed in 436dc7b) * Cross-language ToolCall action digest parity pin (Rust + SDK assert the same golden hex literal) Version bumps: * pyproject.toml: 0.14.2 -> 0.14.4 (hatchling source of truth) * src/nullrun/__version__: 0.13.11 -> 0.14.4 (was lagging; also bumped the docstring header to v3.30 / 0.14.4) Behavioural change (called out in CHANGELOG > Compatibility): * Bare @sensitive now ships kind=tool_call on the wire where it previously shipped nothing. Money callers unaffected. Legacy backends ignore the new envelope (additive on the SDK side). Verification (pre-commit, local venv): pytest tests/ -q --ignore=tests/contract -> 1382 passed, 7 skipped, 10 warnings in 98.36s Local commit only; no push. Refs: Phase 1 wire contract: 40d391a Chain-walk fix: 436dc7b Cross-language parity pin: f608414 backend BusinessImpact::ToolCall variant: 1e501cd6 backend Tier 2 commits: 1e501cd6, 63ba9f6a * fix(sdk): ruff F541 -- drop extraneous f-prefixes on placeholder-less strings CI failed on the 0.14.4 PR (#80) at the lint step with ruff F541 (f-string without any placeholders) -- 14 errors in src/nullrun/decorators.py plus similar auto-fixable issues in the other 4 files I touched. Auto-fixed via: ruff check src/ tests/ --fix ruff format src/nullrun/decorators.py src/nullrun/business_impact.py src/nullrun/extractor.py src/nullrun/__version__.py tests/test_tool_params_extractor.py tests/test_business_impact.py What changed (no behavioural change, just syntax): * src/nullrun/decorators.py: f"..." -> "..." on the MoneyImpactExtractor / ToolParamsExtractor / fallback hint literals in _enforce_sensitive_tool (the @sensitive error path). The strings had no {}-placeholders so the f-prefix was dead syntax that ruff F541 (selected by the default rule set in pyproject.toml) flagged. * src/nullrun/business_impact.py: import-order normalisation in extractor.py imports + format-only whitespace tidy. * src/nullrun/extractor.py: same import-order + format tidy. * tests/test_tool_params_extractor.py: import block was un-sorted (I001 fixable). Re-ordered to ruff convention. * tests/test_business_impact.py: format-only whitespace tidy from the cross-language parity pin (f608414). Verification: ruff check src/ tests/ -> All checks passed! ruff format --check <my 6 files> -> 6 files already formatted pytest tests/ --ignore=tests/contract -> 1382 passed, 7 skipped This is exactly the fix 4c143e2 (the 0.14.2 release) called out as 'deferred to a separate PR' for its own touch surface: 'ruff format on the three source files touched by this release. The format-only reformat of the 65 unrelated files is intentionally deferred to a separate PR.' I am doing the same scope discipline here -- only the files I authored or modified for 0.14.4, not the 67 pre-existing format-debt files (those are a separate PR). Refs: Failing CI: PR #80, run 30291404693 (test 3.11 + coverage) ToolParameters phase 1: 40d391a Cross-language parity pin: f608414 Release 0.14.4: 332acfb Master merge: 3471bc4
1 parent 66ed805 commit bc34d61

8 files changed

Lines changed: 1428 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,43 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
77

88
---
99

10+
## [0.14.4] - 2026-07-27
11+
12+
ToolParameters Approval Rules wire contract (Tier 2 / Разрыв 2 follow-up). The backend already accepted `BusinessImpact::ToolCall(ToolCallParams)` on the `/execute` wire (backend commit `1e501cd6`); 0.14.4 lands the SDK-side path so users get ToolParameters rules by default on every bare `@sensitive` function, with no decorator change. Also fixes a silent regression in the auto-attach path that dropped an explicit `impact=tool_params({...})` map, and pins the cross-language `ToolCall` action digest against the Rust backend's golden hex. No on-wire breaking change for money callers; the only behavioural change is that bare `@sensitive` now ships `kind=tool_call` on the wire where it previously shipped nothing.
13+
14+
### Added
15+
16+
- **`BusinessImpact.tool_call(tool_name, params)`** factory — `business_impact.py:323` new factory builds a `BusinessImpact(kind='tool_call', tool_name=..., params=...)` envelope by analogy with the legacy `BusinessImpact` money constructor. Mirrors the backend `BusinessImpact::ToolCall(ToolCallParams)` variant (`backend/src/proxy/gate/business_impact.rs:62-307`). Used internally by `ToolParamsExtractor`; exposed publicly so users can hand-build impacts without importing the dataclass.
17+
- **`ToolCallParams` dataclass**`business_impact.py:143` mirrors the backend struct (`tool_name` ≤ 128 bytes, `param_name` ≤ 64, JSON-roundtrippable values only). `BusinessImpact.kind` now discriminates `Money` | `ToolCall`; existing money callers continue to discriminate on the same field via the `extractor_*` metadata.
18+
- **`ToolParamsExtractor` + `tool_params(...)` factory**`extractor.py:815` (class) and the matching factory. Three modes: explicit `{rule_param: arg_name}` map, `include_all=True` (default — every kwarg captured), or `include_all=False` with no map (empty). PII-masked sentinels (`"***"`) and JSON-unsafe values (`float`, custom objects) are filtered before the wire. The factory is the analogue of `MoneyImpactExtractor + money_outflow(...)`.
19+
- **Bare `@sensitive` now ships ToolParameters on the wire**`decorators.py:1096` (`_do_sensitive_register`) auto-attaches a default `ToolParamsExtractor(include_all=True)` on a bare `@sensitive` decorator. The stamp goes through `_stamp_extractor_on_innermost` so the bare function (the one `@protect` captures as `fn`) carries the attribute, not just the `@protect` wrapper. An explicit `@sensitive(impact=money_outflow(...))` or `@sensitive(impact=tool_params({...}))` wins — the auto-attach only fires when no extractor is present.
20+
- **`@sensitive(impact=tool_params({...}))` decorator form**`decorators.py:1065` new docstring + `decorators.py:711` dispatch branch. Operators writing ToolParameters Approval Rules on the backend can now declare the per-rule param map directly at the decorator site instead of relying on the auto-attach default.
21+
22+
### Fixed
23+
24+
- **Auto-attach chain walk preserves an explicit `impact=tool_params({...})` map**`decorators.py:43` new helper `_find_extractor_in_chain` walks `__wrapped__` (bounded at 32 hops) so the auto-attach check sees the explicit extractor stamped on the bare function instead of falling through to the default. **Before this fix**, `@sensitive(impact=tool_params({"delete_force": "force"})) @protect def delete_user(force, user_id): ...` silently shipped `{force: <bool>, user_id: <int>}` (the auto-attach default) instead of the explicit `{delete_force: <bool>}` map. **After this fix**, the renamed key reaches the wire. Regression tests in `TestAutoAttachChainWalk` (4 cases): bare auto-attach, explicit tool_params map preserved, explicit money_outflow preserved, circular-`__wrapped__` defensive bounded walk.
25+
- **`_enforce_sensitive_tool` dispatch handles both extractor types**`decorators.py:677` (success path) and `decorators.py:711` (error path) now branch by extractor type. NR-B003 error hint text branches too — operators writing ToolParameters rules see "did you mean `impact=tool_params(...)`?" while money operators see the money remediation advice.
26+
- **Bare `@sensitive` regression in the existing `tests/test_sensitive_extractor.py`** — the 5 existing tests still pass because they register the tool manually via `rt.add_sensitive_tool(name)`, which bypasses the decorator auto-attach path. Documented as a deliberate carve-out: only `@sensitive` (the decorator form) auto-attaches.
27+
28+
### Tests
29+
30+
- `tests/test_tool_params_extractor.py`**23 new tests** across 5 classes (`TestToolParamsFactory`, `TestToolParamsExtraction`, `TestAutoAttachOnBareSensitive`, `TestToolCallParamsShape`, `TestAutoAttachChainWalk`). Covers factory shape (3), three extraction modes (4), PII sentinel + float filtering (3), action digest byte-identity with the backend's canonical JSON (1), the auto-attach wiring (2), dataclass validator (7), kind dispatch (1), and the chain-walk regression (4). Verified: 23/23 pass.
31+
- `tests/test_business_impact.py::TestToolCallActionDigestPins`**5 new tests** cross-language parity for the `ToolCall` impact, pinned to the same hex literal the Rust backend pins in `backend/src/proxy/gate/business_impact.rs::tests::tool_call_digest_golden_value_stripe_charge_500`. A drift on either side trips the test on the other side next time the suite runs. Fixture payload: `tool_call("stripe.charge", {"region": "EU", "amount": 500})``9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6`.
32+
- `tests/test_sensitive_extractor.py` — 5/5 pass (regression check, the auto-attach wiring is additive on top of 0.14.1).
33+
- `tests/test_business_impact.py` — full class passes (28/28 including the 5 new parity pins).
34+
- `tests/test_extractors.py` — 35/35 pass.
35+
- `tests/test_protect.py + test_protect_branches.py + test_execute_approval_flow.py + test_approval_money_flow.py + test_gate_real_path.py + test_handle.py` — 99/99 pass.
36+
- `tests/test_runtime.py + test_runtime_branches.py + test_init_contract.py` — 70/70 pass (1 skipped, pre-existing).
37+
38+
### Compatibility
39+
40+
- **Default SDK behaviour for bare `@sensitive` CHANGED** — was `no business_impact on wire`, now `kind=tool_call on wire`. Operators who relied on the Phase 0 path (approval_id-only grant consume) must either pass `@sensitive(impact=tool_params(include_all=False))` explicitly, or accept the new ToolParameters wire shape. The change is additive on the SDK side; legacy backends ignore `kind=tool_call` and fall through to a no-op.
41+
- **Existing `@sensitive(impact=money_outflow(...))` callers are unaffected** — the explicit extractor wins over the auto-attach (verified by `test_explicit_money_outflow_chain_walk_preserved`).
42+
- **Legacy "no impact extractor" call sites (registered via `rt.add_sensitive_tool(name)` directly) are unaffected** — the auto-attach is only wired through `_do_sensitive_register`, which only the `@sensitive` decorator calls.
43+
- **No SDK_MIN_VERSION bump.** ToolParameters is an opt-in backend feature; SDK 0.14.4 talking to a backend that has the `BusinessImpact::ToolCall` variant (commit `1e501cd6` and later) is the supported path. SDK 0.14.4 talking to an older backend works but the `kind=tool_call` envelope is ignored — same effective behaviour as 0.14.3 minus the wire bytes.
44+
45+
---
46+
1047
## [0.14.2] - 2026-07-24
1148

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ name = "nullrun"
140140
# by ``@protect`` were missing ``tokens``/``execution_id`` so
141141
# the backend's SdkTrackRequest rejected them. See CHANGELOG.md
142142
# for the full per-commit description.
143-
version = "0.14.2"
143+
version = "0.14.4"
144144
# Kept under the 200-char preview threshold so the full line is visible
145145
# without an "expand" click. Keywords are matched against likely search
146146
# queries ("AI agent cost control", "LLM circuit breaker", etc.).

src/nullrun/__version__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""NullRun Platform SDK.
22
3-
v3.29 / 0.14.1 (2026-07-24) — Decimal JSON serialization patch.
3+
v3.30 / 0.14.4 (2026-07-27) — ToolParameters Approval Rules
4+
wire contract (Tier 2 / Разрыв 2 follow-up).
45
56
Pre-fix 0.14.0, a ``track_tool`` event payload containing a
67
``Decimal`` (e.g. ``refund_amount`` from a
@@ -1017,5 +1018,5 @@
10171018
10181019
"""
10191020

1020-
__version__ = "0.13.11"
1021+
__version__ = "0.14.4"
10211022
__platform_version__ = "1.0.0"

src/nullrun/business_impact.py

Lines changed: 170 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,20 @@
4949
EQ = "eq"
5050

5151

52-
# MVP: only `money` kind is supported; the discriminated union is
53-
# shaped forward-compat for record_count / resource_quantity etc.
54-
# when they land in MVPs 1.1+.
52+
# MVP 1.0: `money` kind for per-call flat amounts.
53+
# MVP 1.1 (ToolParameters / Phase 1 / Tier 2): `tool_call` kind
54+
# for free-form tool-call argument bags matched against
55+
# ToolParameters Approval Rules on the backend.
5556
KIND_MONEY = "money"
57+
KIND_TOOL_CALL = "tool_call"
58+
59+
60+
# Mirrors the backend constant at
61+
# ``backend/src/proxy/gate/business_impact.rs`` (the same value
62+
# caps both the SDK-side mirror's ``tool_name`` and per-key name
63+
# length). Kept in sync manually; a backend-side bump is a one-line
64+
# edit here.
65+
TOOL_PARAMETERS_MAX_PARAM_NAME = 64
5666

5767

5868
@dataclass
@@ -85,30 +95,20 @@ def validate(self) -> None:
8595
backend's `MoneyImpact::validate()` mirrors these checks.
8696
"""
8797
if self.direction not in (OUTFLOW, INFLOW):
88-
raise ValueError(
89-
f"direction must be {OUTFLOW!r} or {INFLOW!r}, "
90-
f"got {self.direction!r}"
91-
)
92-
if not isinstance(self.amount_minor, int) or isinstance(
93-
self.amount_minor, bool
94-
):
98+
raise ValueError(f"direction must be {OUTFLOW!r} or {INFLOW!r}, got {self.direction!r}")
99+
if not isinstance(self.amount_minor, int) or isinstance(self.amount_minor, bool):
95100
# bool is a subclass of int in Python — explicit exclude.
96-
raise ValueError(
97-
f"amount_minor must be int, got {type(self.amount_minor).__name__}"
98-
)
101+
raise ValueError(f"amount_minor must be int, got {type(self.amount_minor).__name__}")
99102
if self.amount_minor < 0:
100-
raise ValueError(
101-
f"amount_minor must be non-negative, got {self.amount_minor}"
102-
)
103+
raise ValueError(f"amount_minor must be non-negative, got {self.amount_minor}")
103104
if (
104105
not isinstance(self.currency, str)
105106
or len(self.currency) != 3
106107
or not self.currency.isascii()
107108
or not self.currency.isupper()
108109
):
109110
raise ValueError(
110-
f"currency must be a 3-letter uppercase ISO-4217 code, "
111-
f"got {self.currency!r}"
111+
f"currency must be a 3-letter uppercase ISO-4217 code, got {self.currency!r}"
112112
)
113113

114114
def to_wire_dict(self) -> dict[str, Any]:
@@ -129,6 +129,125 @@ def to_wire_dict(self) -> dict[str, Any]:
129129
}
130130

131131

132+
@dataclass
133+
class ToolCallParams:
134+
"""Free-form tool-call argument bag (Phase 1 / Tier 2 wire shape).
135+
136+
Mirrors the backend ``BusinessImpact::ToolCall(ToolCallParams)``
137+
variant at ``backend/src/proxy/gate/business_impact.rs:62-307``.
138+
The backend matches ``params`` against ToolParameters Approval
139+
Rules (``ValueMatcher``: Equals / OneOf / NumericRange / Regex /
140+
Exists; ``TriggerLogic``: Any / All / DNF groups).
141+
142+
Why this exists as a separate dataclass (rather than reusing the
143+
raw ``dict[str, Any]`` that the runtime already passes around):
144+
- the validator enforces ``tool_name`` shape and the
145+
canonical-JSON digest layer needs a stable, sortable struct
146+
to produce a byte-identical digest with the backend
147+
``canonical_json()`` implementation
148+
- the ``extractor_*`` fields mirror the ``MoneyImpact``
149+
provenance pattern: self-reported by the SDK, treated as
150+
advisory metadata. The trust boundary is the digest
151+
round-trip — the SDK and backend both canonicalise the
152+
same payload to the same bytes, and a mismatch on /execute
153+
re-check is a 403 DIGEST_MISMATCH
154+
155+
Attributes:
156+
tool_name: canonical name of the tool the SDK is about to
157+
call. Must be non-empty and <= 128 bytes.
158+
params: free-form argument bag the operator wrote the rule
159+
against. Keyed by the rule's ``param_name`` field.
160+
extractor_id: self-reported SDK extractor id (e.g.
161+
"nullrun.tool_call.path").
162+
extractor_version: self-reported version.
163+
"""
164+
165+
tool_name: str
166+
params: dict[str, Any] = field(default_factory=dict)
167+
extractor_id: str = "nullrun.tool_call.path"
168+
extractor_version: str = "1"
169+
170+
def validate(self) -> None:
171+
"""Reject malformed impacts at extraction time (fail-fast).
172+
173+
Mirrors ``ToolCallParams::validate()`` in the backend so a
174+
tool with bad extractor args fails locally before the wire
175+
round-trip (one error class, one user_action message).
176+
"""
177+
if not isinstance(self.tool_name, str) or not self.tool_name:
178+
raise ValueError("tool_name must be a non-empty string")
179+
if len(self.tool_name) > 128:
180+
raise ValueError(f"tool_name length {len(self.tool_name)} exceeds max 128")
181+
if not self.tool_name.isascii():
182+
raise ValueError("tool_name must be printable ASCII")
183+
for k in self.params:
184+
if not isinstance(k, str):
185+
raise ValueError(f"params key {k!r} must be a string")
186+
if len(k) > TOOL_PARAMETERS_MAX_PARAM_NAME:
187+
raise ValueError(
188+
f"params['{k}'] key length {len(k)} exceeds "
189+
f"max {TOOL_PARAMETERS_MAX_PARAM_NAME}"
190+
)
191+
_validate_param_value(self.params[k], path=f"params['{k}']")
192+
193+
def to_wire_dict(self) -> dict[str, Any]:
194+
"""Serialize to the JSON shape the backend expects.
195+
196+
Key order is NOT significant — the backend's
197+
``canonical_json()`` re-sorts keys before hashing.
198+
"""
199+
return {
200+
"kind": KIND_TOOL_CALL,
201+
"tool_name": self.tool_name,
202+
"params": dict(self.params),
203+
"extractor_id": self.extractor_id,
204+
"extractor_version": self.extractor_version,
205+
}
206+
207+
208+
def _validate_param_value(value: Any, path: str) -> None:
209+
"""Reject values that the digest layer cannot round-trip.
210+
211+
Backend mirror at ``business_impact.rs:310-318``: the canonical
212+
JSON layer accepts the four JSON kinds (null/bool/number/string/
213+
object/array) but rejects f64 and non-finite numbers because
214+
``serde_json::Number`` cannot losslessly represent them. We do
215+
the same here so the SDK fails at extraction time rather than
216+
producing a digest that the backend will reject.
217+
"""
218+
if value is None or isinstance(value, bool):
219+
return
220+
if isinstance(value, int):
221+
# int round-trips through JSON losslessly. NOTE: bool is a
222+
# subclass of int in Python; we explicitly handle it above.
223+
return
224+
if isinstance(value, str):
225+
return
226+
if isinstance(value, (list, tuple)):
227+
for i, item in enumerate(value):
228+
_validate_param_value(item, path=f"{path}[{i}]")
229+
return
230+
if isinstance(value, dict):
231+
for k, v in value.items():
232+
_validate_param_value(v, path=f"{path}['{k}']")
233+
return
234+
if isinstance(value, float):
235+
# Reject explicitly -- we DO NOT round to int because the
236+
# operator might be relying on sub-cent precision (this is
237+
# the same rationale as MoneyImpactExtractor rejecting
238+
# ``float`` for money amounts).
239+
raise ValueError(
240+
f"{path}: float values are not supported on the wire "
241+
f"(JSON round-trip is not lossless for IEEE-754); pass "
242+
f"an int (minor units) or a str (operator-defined format)"
243+
)
244+
raise ValueError(
245+
f"{path}: value of type {type(value).__name__!r} is not "
246+
f"supported on the wire; pass int / str / bool / None / "
247+
f"list / dict"
248+
)
249+
250+
132251
def business_impact_to_dict(impact: BusinessImpact) -> dict[str, Any]:
133252
"""Top-level wire dict for `GateRequest.business_impact`.
134253
@@ -146,18 +265,23 @@ def business_impact_to_dict(impact: BusinessImpact) -> dict[str, Any]:
146265
class BusinessImpact:
147266
"""Top-level BusinessImpact union.
148267
149-
For MVP 1.0 the only supported variant is `Money`. Future
150-
kinds land by adding new subclasses and a `kind` value.
268+
MVP 1.0: `Money` only.
269+
MVP 1.1 (Phase 1 / Tier 2): adds `ToolCall` for free-form
270+
tool-call argument bags matched against ToolParameters
271+
Approval Rules on the backend.
272+
151273
The SDK validates the variant at construction time so the
152274
backend never sees malformed output.
153275
"""
154276

155-
impact: Any # MoneyImpact in MVP.
277+
impact: Any # MoneyImpact | ToolCallParams
156278

157279
@property
158280
def kind(self) -> str:
159281
if isinstance(self.impact, MoneyImpact):
160282
return KIND_MONEY
283+
if isinstance(self.impact, ToolCallParams):
284+
return KIND_TOOL_CALL
161285
raise TypeError(f"unknown impact type: {type(self.impact)}")
162286

163287
def validate(self) -> None:
@@ -181,6 +305,31 @@ def money(
181305
m.validate()
182306
return cls(impact=m)
183307

308+
@classmethod
309+
def tool_call(
310+
cls,
311+
tool_name: str,
312+
params: dict[str, Any] | None = None,
313+
extractor_id: str = "nullrun.tool_call.path",
314+
extractor_version: str = "1",
315+
) -> BusinessImpact:
316+
"""Construct a ``kind="tool_call"`` BusinessImpact.
317+
318+
Used by the ToolParamsExtractor; callers building impacts
319+
by hand should use this factory rather than constructing
320+
``ToolCallParams`` and wrapping themselves -- the factory
321+
validates before returning so a misuse fails locally
322+
instead of after a wire round-trip.
323+
"""
324+
p = ToolCallParams(
325+
tool_name=tool_name,
326+
params=params or {},
327+
extractor_id=extractor_id,
328+
extractor_version=extractor_version,
329+
)
330+
p.validate()
331+
return cls(impact=p)
332+
184333

185334
def _canonicalize_json(value: Any) -> Any:
186335
"""Sort object keys recursively before serialization.

0 commit comments

Comments
 (0)