nanny-sdk 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ # Rust build output — never commit this
2
+ /target
3
+
4
+ # macOS filesystem noise
5
+ .DS_Store
6
+ .AppleDouble
7
+ .LSOverride
8
+
9
+ # IDE and editor files
10
+ .idea/
11
+ .vscode/
12
+ *.swp
13
+ *.swo
14
+ *~
15
+
16
+ # Environment / secrets — never commit these
17
+ .env
18
+ .env.local
19
+ .env.*
20
+ !.env.example
21
+
22
+ # Python build output and caches
23
+ __pycache__/
24
+ *.py[cod]
25
+ .venv/
26
+ .mypy_cache/
27
+ .ruff_cache/
28
+ .pytest_cache/
29
+ *.egg-info/
30
+ dist/
31
+ # Note: uv.lock is intentionally committed — reproducible Python builds.
32
+
33
+ # Note: Cargo.lock is intentionally committed.
34
+ # This workspace produces a binary (nanny CLI). Committing Cargo.lock ensures
35
+ # every user gets an identical, reproducible build — a direct requirement of
36
+ # the determinism invariant in the Nanny manifesto.
37
+ .windsurf
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: nanny-sdk
3
+ Version: 0.1.4
4
+ Summary: Python SDK for the Nanny execution boundary — @tool, @rule, @agent decorators
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: httpx>=0.27
@@ -0,0 +1,36 @@
1
+ """Nanny SDK — execution boundary for AI agents.
2
+
3
+ from nanny_sdk import tool, rule, agent
4
+ from nanny_sdk import BudgetExhausted, RuleDenied
5
+
6
+ Run your agent under ``nanny run agent.py``. All decorators are no-ops when
7
+ ``NANNY_BRIDGE_PORT`` is absent — zero friction in direct development.
8
+ """
9
+
10
+ from nanny_sdk._decorators import agent, rule, tool
11
+ from nanny_sdk.exceptions import (
12
+ AgentCompleted,
13
+ AgentNotFound,
14
+ BudgetExhausted,
15
+ MaxStepsReached,
16
+ NannyStop,
17
+ RuleDenied,
18
+ TimeoutExpired,
19
+ ToolDenied,
20
+ )
21
+
22
+ __all__ = [
23
+ # Decorators
24
+ "tool",
25
+ "rule",
26
+ "agent",
27
+ # Exceptions
28
+ "NannyStop",
29
+ "MaxStepsReached",
30
+ "BudgetExhausted",
31
+ "TimeoutExpired",
32
+ "AgentCompleted",
33
+ "AgentNotFound",
34
+ "ToolDenied",
35
+ "RuleDenied",
36
+ ]
@@ -0,0 +1,188 @@
1
+ """Bridge HTTP client.
2
+
3
+ The bridge uses different transports depending on the OS:
4
+
5
+ - **Unix (macOS/Linux):** Unix domain socket at ``/tmp/nanny-<token>.sock``.
6
+ The CLI injects ``NANNY_BRIDGE_SOCKET`` into the child process environment.
7
+ - **Windows:** TCP loopback on a fixed port (47374).
8
+ The CLI injects ``NANNY_BRIDGE_PORT`` into the child process environment.
9
+
10
+ ``NANNY_SESSION_TOKEN`` is always injected on both platforms.
11
+
12
+ All environment variables are read at call time (not import time) so tests can
13
+ set them via ``monkeypatch`` without reloading the module.
14
+
15
+ When neither ``NANNY_BRIDGE_SOCKET`` nor ``NANNY_BRIDGE_PORT`` is set the SDK
16
+ is in passthrough mode — every decorator is a no-op and no network calls are
17
+ made. This is the normal state when running ``python agent.py`` directly
18
+ instead of ``nanny run agent.py``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ from nanny_sdk.exceptions import (
29
+ AgentCompleted,
30
+ AgentNotFound,
31
+ BudgetExhausted,
32
+ MaxStepsReached,
33
+ RuleDenied,
34
+ TimeoutExpired,
35
+ ToolDenied,
36
+ )
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Environment helpers — evaluated lazily so monkeypatch works in tests
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _socket_path() -> str | None:
44
+ """Unix domain socket path set by the CLI on macOS/Linux."""
45
+ return os.environ.get("NANNY_BRIDGE_SOCKET")
46
+
47
+
48
+ def _port() -> str | None:
49
+ """TCP port set by the CLI on Windows."""
50
+ return os.environ.get("NANNY_BRIDGE_PORT")
51
+
52
+
53
+ def _token() -> str:
54
+ return os.environ.get("NANNY_SESSION_TOKEN", "")
55
+
56
+
57
+ def is_passthrough() -> bool:
58
+ """True when the SDK is running outside ``nanny run`` (no bridge present).
59
+
60
+ Checks ``NANNY_BRIDGE_SOCKET`` first (Unix), then ``NANNY_BRIDGE_PORT``
61
+ (Windows). Neither set → passthrough.
62
+ """
63
+ return _socket_path() is None and _port() is None
64
+
65
+
66
+ def _make_client(**kwargs: Any) -> httpx.Client:
67
+ """Return an ``httpx.Client`` connected to the bridge.
68
+
69
+ - Unix socket present → ``HTTPTransport(uds=...)`` with ``base_url=http://localhost``
70
+ - TCP port present → plain TCP with ``base_url=http://127.0.0.1:<port>``
71
+
72
+ Raises ``RuntimeError`` if called in passthrough mode (should never happen
73
+ because decorators check ``is_passthrough()`` first).
74
+ """
75
+ sock = _socket_path()
76
+ if sock is not None:
77
+ transport = httpx.HTTPTransport(uds=sock)
78
+ return httpx.Client(transport=transport, base_url="http://localhost", **kwargs)
79
+ port = _port()
80
+ if port is not None:
81
+ return httpx.Client(base_url=f"http://127.0.0.1:{port}", **kwargs)
82
+ raise RuntimeError( # pragma: no cover
83
+ "nanny: bridge not available "
84
+ "(NANNY_BRIDGE_SOCKET and NANNY_BRIDGE_PORT are both unset)"
85
+ )
86
+
87
+
88
+ def _headers() -> dict[str, str]:
89
+ return {"X-Nanny-Session-Token": _token()}
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Stop-reason dispatch
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _raise_for_stop(reason: str, tool_name: str = "", rule_name: str = "") -> None:
98
+ """Convert a stop-reason string from the bridge into a typed exception.
99
+
100
+ ``tool_name`` and ``rule_name`` carry the optional detail fields that the
101
+ bridge includes in a ``ToolDenied`` or ``RuleDenied`` deny response.
102
+ """
103
+ match reason:
104
+ case "MaxStepsReached":
105
+ raise MaxStepsReached()
106
+ case "BudgetExhausted":
107
+ raise BudgetExhausted()
108
+ case "TimeoutExpired":
109
+ raise TimeoutExpired()
110
+ case "AgentCompleted":
111
+ raise AgentCompleted()
112
+ case "AgentNotFound":
113
+ raise AgentNotFound()
114
+ case "ToolDenied":
115
+ raise ToolDenied(tool_name)
116
+ case "RuleDenied":
117
+ raise RuleDenied(rule_name)
118
+ case _:
119
+ raise RuntimeError(f"nanny: unknown stop reason: {reason!r}")
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Bridge calls
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def health() -> bool:
128
+ """Connectivity check — returns True if bridge responds with state running."""
129
+ with _make_client(timeout=5.0) as c:
130
+ resp = c.get("/health", headers=_headers())
131
+ resp.raise_for_status()
132
+ data: dict[str, str] = resp.json()
133
+ return data.get("state") == "running"
134
+
135
+
136
+ def call_tool(tool_name: str, cost: int, args: dict[str, Any]) -> None:
137
+ """POST /tool/call — raises a NannyStop subclass if denied, returns None if allowed."""
138
+ payload = {"tool": tool_name, "cost": cost, "args": args}
139
+ with _make_client(timeout=10.0) as c:
140
+ resp = c.post("/tool/call", json=payload, headers=_headers())
141
+ resp.raise_for_status()
142
+ data: dict[str, Any] = resp.json()
143
+ if data.get("status") == "denied":
144
+ _raise_for_stop(
145
+ str(data.get("reason", "")),
146
+ tool_name=str(data.get("tool_name") or ""),
147
+ rule_name=str(data.get("rule_name") or ""),
148
+ )
149
+
150
+
151
+ def agent_enter(name: str) -> None:
152
+ """POST /agent/enter — activate a named limit scope.
153
+
154
+ The bridge returns 404 when the named scope is not in nanny.toml —
155
+ raises ``AgentNotFound`` in that case.
156
+ """
157
+ with _make_client(timeout=5.0) as c:
158
+ resp = c.post("/agent/enter", json={"name": name}, headers=_headers())
159
+ if resp.status_code == 404:
160
+ raise AgentNotFound()
161
+ resp.raise_for_status()
162
+
163
+
164
+ def agent_exit(name: str) -> None:
165
+ """POST /agent/exit — deactivate the named limit scope.
166
+
167
+ Silently ignored if the bridge closed the connection after a stop event —
168
+ the bridge already recorded the scope exit when it issued the stop.
169
+ """
170
+ try:
171
+ with _make_client(timeout=5.0) as c:
172
+ c.post("/agent/exit", json={}, headers=_headers())
173
+ except Exception:
174
+ pass
175
+
176
+
177
+ def report_stop(reason: str) -> None:
178
+ """POST /stop — notify the bridge of a stop reason before raising.
179
+
180
+ The bridge records this so the NDJSON log shows the real stop reason
181
+ (e.g. ``RuleDenied``) instead of ``ProcessCrashed`` when the process exits.
182
+ Silently ignored if the bridge is unreachable — best-effort only.
183
+ """
184
+ try:
185
+ with _make_client(timeout=2.0) as c:
186
+ c.post("/stop", json={"reason": reason}, headers=_headers())
187
+ except Exception:
188
+ pass
@@ -0,0 +1,33 @@
1
+ """PolicyContext — mirrors the Rust PolicyContext struct field-for-field.
2
+
3
+ Passed to every ``@rule`` function so it can inspect agent state before
4
+ deciding whether to allow or deny the pending tool call.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass
14
+ class PolicyContext:
15
+ step_count: int = 0
16
+ elapsed_ms: int = 0
17
+ requested_tool: str | None = None
18
+ cost_units_spent: int = 0
19
+ tool_call_counts: dict[str, int] = field(default_factory=dict)
20
+ tool_call_history: list[str] = field(default_factory=list)
21
+ last_tool_args: dict[str, str] = field(default_factory=dict)
22
+
23
+ @classmethod
24
+ def from_dict(cls, data: dict[str, Any]) -> PolicyContext:
25
+ return cls(
26
+ step_count=data.get("step_count", 0),
27
+ elapsed_ms=data.get("elapsed_ms", 0),
28
+ requested_tool=data.get("requested_tool"),
29
+ cost_units_spent=data.get("cost_units_spent", 0),
30
+ tool_call_counts=data.get("tool_call_counts", {}),
31
+ tool_call_history=data.get("tool_call_history", []),
32
+ last_tool_args=data.get("last_tool_args", {}),
33
+ )
@@ -0,0 +1,148 @@
1
+ """``@tool``, ``@rule``, ``@agent`` decorators.
2
+
3
+ Day 1: skeletons that work in passthrough mode.
4
+ Day 2: ``@tool`` bridge integration.
5
+ Day 3: ``@rule`` client-side rule evaluation.
6
+ Day 4: ``@agent`` scope enter/exit.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import inspect
13
+ from collections.abc import Callable
14
+ from typing import Any, TypeVar
15
+
16
+ from nanny_sdk import _client
17
+ from nanny_sdk._context import PolicyContext
18
+ from nanny_sdk.exceptions import RuleDenied
19
+
20
+ F = TypeVar("F", bound=Callable[..., Any])
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Rule registry — populated at decoration time, evaluated before each tool call
24
+ # ---------------------------------------------------------------------------
25
+
26
+ # Ordered dict so rules are evaluated in registration order.
27
+ _RULES: dict[str, Callable[[PolicyContext], bool]] = {}
28
+
29
+
30
+ def tool(*, cost: int = 0) -> Callable[[F], F]:
31
+ """Declare a Nanny-governed tool.
32
+
33
+ Contacts the bridge before each call to enforce step, budget, timeout,
34
+ allowlist, and rule limits. Charges ``cost`` units on each allowed call.
35
+
36
+ In passthrough mode (no ``NANNY_BRIDGE_PORT``) the decorated function
37
+ is returned unchanged — zero overhead, zero import errors.
38
+ """
39
+
40
+ def decorator(fn: F) -> F:
41
+ if _client.is_passthrough():
42
+ return fn
43
+
44
+ tool_name = fn.__name__
45
+ sig = inspect.signature(fn)
46
+
47
+ def _str_args(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, str]:
48
+ """Bind call-site args to parameter names and stringify the values."""
49
+ bound = sig.bind(*args, **kwargs)
50
+ bound.apply_defaults()
51
+ return {k: str(v) for k, v in bound.arguments.items()}
52
+
53
+ def _check_rules(str_args: dict[str, str]) -> None:
54
+ """Evaluate all registered rules in registration order.
55
+
56
+ Raises ``RuleDenied`` on the first rule that returns ``False``.
57
+ The bridge is never contacted if a rule denies.
58
+ """
59
+ ctx = PolicyContext(last_tool_args=str_args, requested_tool=tool_name)
60
+ for rule_name, rule_fn in _RULES.items():
61
+ if not rule_fn(ctx):
62
+ _client.report_stop("RuleDenied")
63
+ raise RuleDenied(rule_name)
64
+
65
+ if inspect.iscoroutinefunction(fn):
66
+
67
+ @functools.wraps(fn)
68
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
69
+ str_args = _str_args(args, kwargs)
70
+ _check_rules(str_args)
71
+ _client.call_tool(tool_name, cost, str_args)
72
+ return await fn(*args, **kwargs)
73
+
74
+ return async_wrapper # type: ignore[return-value]
75
+
76
+ @functools.wraps(fn)
77
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
78
+ str_args = _str_args(args, kwargs)
79
+ _check_rules(str_args)
80
+ _client.call_tool(tool_name, cost, str_args)
81
+ return fn(*args, **kwargs)
82
+
83
+ return wrapper # type: ignore[return-value]
84
+
85
+ return decorator
86
+
87
+
88
+ def rule(name: str) -> Callable[[F], F]:
89
+ """Register a policy rule function.
90
+
91
+ The decorated function receives a ``PolicyContext`` and returns ``bool``.
92
+ ``False`` → ``RuleDenied(name)`` raised at the pending tool call site,
93
+ before the bridge is ever contacted.
94
+
95
+ Rules are evaluated in registration order. The first rule that returns
96
+ ``False`` stops evaluation — remaining rules are not called.
97
+
98
+ ``ctx.last_tool_args`` and ``ctx.requested_tool`` are always populated.
99
+ ``ctx.step_count``, ``ctx.cost_units_spent``, and ``ctx.tool_call_history``
100
+ reflect bridge-tracked state and are available via full context in v0.1.5+.
101
+ """
102
+
103
+ def decorator(fn: F) -> F:
104
+ _RULES[name] = fn
105
+ return fn
106
+
107
+ return decorator
108
+
109
+
110
+ def agent(name: str) -> Callable[[F], F]:
111
+ """Activate a named limit scope for the duration of the decorated function.
112
+
113
+ Calls ``/agent/enter`` on entry and ``/agent/exit`` in a ``finally``
114
+ block so the scope always exits even on exception. Supports both sync
115
+ and async functions.
116
+
117
+ ``/agent/enter`` is called **before** the ``try`` block — if the scope
118
+ is not found (bridge returns 404), ``AgentNotFound`` propagates immediately
119
+ and ``/agent/exit`` is never called (the scope was never activated).
120
+ """
121
+
122
+ def decorator(fn: F) -> F:
123
+ if _client.is_passthrough():
124
+ return fn
125
+
126
+ if inspect.iscoroutinefunction(fn):
127
+
128
+ @functools.wraps(fn)
129
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
130
+ _client.agent_enter(name)
131
+ try:
132
+ return await fn(*args, **kwargs)
133
+ finally:
134
+ _client.agent_exit(name)
135
+
136
+ return async_wrapper # type: ignore[return-value]
137
+
138
+ @functools.wraps(fn)
139
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
140
+ _client.agent_enter(name)
141
+ try:
142
+ return fn(*args, **kwargs)
143
+ finally:
144
+ _client.agent_exit(name)
145
+
146
+ return wrapper # type: ignore[return-value]
147
+
148
+ return decorator
@@ -0,0 +1,47 @@
1
+ """Nanny stop-reason exceptions.
2
+
3
+ Each variant of the Rust ``StopReason`` enum maps to a typed Python exception.
4
+ Names match exactly — no prefix, no divergence.
5
+
6
+ from nanny_sdk import BudgetExhausted, ToolDenied
7
+ """
8
+
9
+
10
+ class NannyStop(Exception):
11
+ """Base class for all Nanny stop signals."""
12
+
13
+
14
+ class MaxStepsReached(NannyStop):
15
+ """The step ceiling was reached before the agent completed."""
16
+
17
+
18
+ class BudgetExhausted(NannyStop):
19
+ """The cost budget was exhausted before the agent completed."""
20
+
21
+
22
+ class TimeoutExpired(NannyStop):
23
+ """The wall-clock timeout elapsed before the agent completed."""
24
+
25
+
26
+ class AgentCompleted(NannyStop):
27
+ """The agent finished normally (used as a signal, not an error)."""
28
+
29
+
30
+ class AgentNotFound(NannyStop):
31
+ """The named agent scope is not defined in nanny.toml."""
32
+
33
+
34
+ class ToolDenied(NannyStop):
35
+ """A tool call was denied by the allowlist or a rule."""
36
+
37
+ def __init__(self, tool_name: str) -> None:
38
+ self.tool_name = tool_name
39
+ super().__init__(f"tool denied: {tool_name!r}")
40
+
41
+
42
+ class RuleDenied(NannyStop):
43
+ """A policy rule returned False and blocked the tool call."""
44
+
45
+ def __init__(self, rule_name: str) -> None:
46
+ self.rule_name = rule_name
47
+ super().__init__(f"rule denied: {rule_name!r}")
File without changes
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nanny-sdk"
7
+ version = "0.1.4"
8
+ description = "Python SDK for the Nanny execution boundary — @tool, @rule, @agent decorators"
9
+ license = { text = "Apache-2.0" }
10
+ requires-python = ">=3.11"
11
+ dependencies = ["httpx>=0.27"]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "mypy>=1.9",
16
+ "pytest>=8.0",
17
+ "pytest-asyncio>=0.23",
18
+ "pytest-httpserver>=1.0",
19
+ "ruff>=0.4",
20
+ ]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["nanny_sdk"]
24
+
25
+ [tool.ruff]
26
+ line-length = 100
27
+ target-version = "py311"
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "I", "UP"]
31
+
32
+ [tool.mypy]
33
+ strict = true
34
+ python_version = "3.11"
35
+
36
+ [tool.pytest.ini_options]
37
+ asyncio_mode = "auto"
File without changes
@@ -0,0 +1,46 @@
1
+ """Shared pytest fixtures.
2
+
3
+ ``mock_bridge`` — spins up a fake Nanny bridge via pytest-httpserver and
4
+ sets ``NANNY_BRIDGE_PORT`` so _client routes to it. Because _client reads
5
+ env vars lazily (at call time), monkeypatch works without reloading modules.
6
+
7
+ ``_reset_rules`` — clears the global rule registry before and after every
8
+ test so rules registered in one test don't bleed into the next.
9
+ """
10
+
11
+ from collections.abc import Generator
12
+
13
+ import pytest
14
+ from pytest_httpserver import HTTPServer
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def _reset_rules() -> Generator[None, None, None]:
19
+ """Clear _RULES before and after every test."""
20
+ from nanny_sdk._decorators import _RULES
21
+
22
+ _RULES.clear()
23
+ yield
24
+ _RULES.clear()
25
+
26
+
27
+ @pytest.fixture()
28
+ def mock_bridge(httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch) -> HTTPServer:
29
+ """Fake Nanny bridge for unit tests.
30
+
31
+ Sets ``NANNY_BRIDGE_PORT`` and ``NANNY_SESSION_TOKEN`` for the duration
32
+ of the test, then restores the original environment on teardown.
33
+
34
+ Returns the ``HTTPServer`` so tests can register expected requests::
35
+
36
+ def test_something(mock_bridge):
37
+ mock_bridge.expect_request("/health").respond_with_json({"status": "ok"})
38
+ assert client.health() is True
39
+ """
40
+ monkeypatch.setenv("NANNY_BRIDGE_PORT", str(httpserver.port))
41
+ monkeypatch.setenv("NANNY_SESSION_TOKEN", "test-token")
42
+ # Permanent catch-all for POST /stop — report_stop() calls this on every denial.
43
+ # Using expect_request (not expect_oneshot_request) so it handles any number of calls
44
+ # and is NOT checked by check_assertions(), avoiding noise in allow-path tests.
45
+ httpserver.expect_request("/stop", method="POST").respond_with_json({"status": "ok"})
46
+ return httpserver