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.
- nanny_sdk-0.1.4/.gitignore +37 -0
- nanny_sdk-0.1.4/PKG-INFO +7 -0
- nanny_sdk-0.1.4/nanny_sdk/__init__.py +36 -0
- nanny_sdk-0.1.4/nanny_sdk/_client.py +188 -0
- nanny_sdk-0.1.4/nanny_sdk/_context.py +33 -0
- nanny_sdk-0.1.4/nanny_sdk/_decorators.py +148 -0
- nanny_sdk-0.1.4/nanny_sdk/exceptions.py +47 -0
- nanny_sdk-0.1.4/nanny_sdk/py.typed +0 -0
- nanny_sdk-0.1.4/pyproject.toml +37 -0
- nanny_sdk-0.1.4/tests/__init__.py +0 -0
- nanny_sdk-0.1.4/tests/conftest.py +46 -0
- nanny_sdk-0.1.4/tests/test_agent.py +190 -0
- nanny_sdk-0.1.4/tests/test_client.py +39 -0
- nanny_sdk-0.1.4/tests/test_exceptions.py +117 -0
- nanny_sdk-0.1.4/tests/test_passthrough.py +248 -0
- nanny_sdk-0.1.4/tests/test_rule.py +241 -0
- nanny_sdk-0.1.4/tests/test_tool.py +219 -0
- nanny_sdk-0.1.4/uv.lock +557 -0
|
@@ -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
|
nanny_sdk-0.1.4/PKG-INFO
ADDED
|
@@ -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
|