toolbound 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ """Infer a JSON Schema from a Python handler's signature (for PythonTool)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import inspect
7
+ import json
8
+ import types
9
+ import typing
10
+ from collections.abc import Callable, Iterable
11
+ from typing import Any, Literal, Union, get_args, get_origin
12
+
13
+ from .errors import ConfigurationError
14
+
15
+ _SIMPLE: dict[type, dict[str, Any]] = {
16
+ str: {"type": "string"},
17
+ int: {"type": "integer"},
18
+ float: {"type": "number"},
19
+ bool: {"type": "boolean"},
20
+ # A bare ``dict`` hint is a deliberate free-form object: say so explicitly.
21
+ dict: {"type": "object", "additionalProperties": True},
22
+ }
23
+
24
+
25
+ def handler_parameters(fn: Callable[..., Any]) -> tuple[set[str], bool, bool]:
26
+ """Return (named parameters, accepts **kwargs, accepts ctx)."""
27
+ sig = inspect.signature(fn)
28
+ names: set[str] = set()
29
+ var_kw = False
30
+ for name, param in sig.parameters.items():
31
+ if param.kind is inspect.Parameter.VAR_KEYWORD:
32
+ var_kw = True
33
+ elif param.kind in (
34
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
35
+ inspect.Parameter.KEYWORD_ONLY,
36
+ ):
37
+ names.add(name)
38
+ return names - {"ctx"}, var_kw, "ctx" in sig.parameters
39
+
40
+
41
+ def infer_input_schema(fn: Callable[..., Any], *, exclude: Iterable[str] = ()) -> dict[str, Any]:
42
+ """Build an input schema from type hints. Raises ConfigurationError if unsupported."""
43
+ skip = set(exclude) | {"ctx"}
44
+ sig = inspect.signature(fn)
45
+ try:
46
+ hints = typing.get_type_hints(fn, include_extras=True)
47
+ except Exception as exc:
48
+ raise ConfigurationError(f"cannot read type hints of {fn!r}: {exc}") from exc
49
+ properties: dict[str, Any] = {}
50
+ required: list[str] = []
51
+ for name, param in sig.parameters.items():
52
+ if name in skip or param.kind in (
53
+ inspect.Parameter.VAR_POSITIONAL,
54
+ inspect.Parameter.VAR_KEYWORD,
55
+ ):
56
+ continue
57
+ if param.kind is inspect.Parameter.POSITIONAL_ONLY:
58
+ raise ConfigurationError(f"{fn.__name__}: positional-only parameters are not supported")
59
+ if name not in hints:
60
+ raise ConfigurationError(
61
+ f"{fn.__name__}: parameter {name!r} needs a type hint (or pass input= explicitly)"
62
+ )
63
+ schema = _schema_for(hints[name], fn.__name__, name)
64
+ if param.default is inspect.Parameter.empty:
65
+ required.append(name)
66
+ else:
67
+ try:
68
+ json.dumps(param.default)
69
+ except TypeError as exc:
70
+ raise ConfigurationError(
71
+ f"{fn.__name__}: default of {name!r} is not JSON-serializable"
72
+ ) from exc
73
+ schema["default"] = param.default
74
+ properties[name] = schema
75
+ return {"type": "object", "properties": properties, "required": required}
76
+
77
+
78
+ def _schema_for(tp: Any, fn: str, name: str) -> dict[str, Any]:
79
+ origin = get_origin(tp)
80
+ if origin is typing.Annotated:
81
+ base, *meta = get_args(tp)
82
+ schema = _schema_for(base, fn, name)
83
+ for item in meta:
84
+ if isinstance(item, str):
85
+ schema["description"] = item
86
+ elif isinstance(item, dict):
87
+ schema.update(item)
88
+ return schema
89
+ if tp in _SIMPLE:
90
+ return dict(_SIMPLE[tp])
91
+ if origin is Literal:
92
+ return {"enum": list(get_args(tp))}
93
+ if isinstance(tp, type) and issubclass(tp, enum.Enum):
94
+ return {"enum": [m.value for m in tp]}
95
+ if origin in (list, tuple, set, frozenset) or tp in (list, tuple):
96
+ args = get_args(tp)
97
+ array: dict[str, Any] = {"type": "array"}
98
+ if args and args[0] is not Ellipsis:
99
+ array["items"] = _schema_for(args[0], fn, name)
100
+ if origin in (set, frozenset):
101
+ array["uniqueItems"] = True
102
+ return array
103
+ if origin is dict:
104
+ args = get_args(tp)
105
+ values = _schema_for(args[1], fn, name) if len(args) == 2 else True
106
+ return {"type": "object", "additionalProperties": values}
107
+ if origin in (Union, types.UnionType):
108
+ members = [a for a in get_args(tp) if a is not type(None)]
109
+ nullable = len(members) != len(get_args(tp))
110
+ if len(members) == 1:
111
+ inner = _schema_for(members[0], fn, name)
112
+ return {"anyOf": [inner, {"type": "null"}]} if nullable else inner
113
+ options = [_schema_for(m, fn, name) for m in members]
114
+ if nullable:
115
+ options.append({"type": "null"})
116
+ return {"anyOf": options}
117
+ raise ConfigurationError(
118
+ f"{fn}: cannot infer a schema for parameter {name!r} ({tp!r}); pass input= explicitly"
119
+ )
@@ -0,0 +1,65 @@
1
+ """In-memory sliding-window rate limiting (per process, thread-safe)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from collections import deque
9
+
10
+ from .context import ExecutionContext
11
+ from .contracts import RateLimit
12
+ from .errors import ErrorCode, ToolFailure
13
+
14
+ _SWEEP_EVERY = 1024
15
+
16
+
17
+ class RateLimiter:
18
+ """Sliding windows keyed by tool and actor/tenant.
19
+
20
+ Idle windows are removed, so memory stays proportional to the number of
21
+ recently active callers rather than growing forever.
22
+ """
23
+
24
+ def __init__(self) -> None:
25
+ self._windows: dict[str, tuple[float, deque[float]]] = {}
26
+ self._lock = threading.Lock()
27
+ self._checks = 0
28
+
29
+ def __len__(self) -> int:
30
+ return len(self._windows)
31
+
32
+ def check(self, tool: str, limit: RateLimit, ctx: ExecutionContext) -> None:
33
+ # JSON keeps None and "None" apart, and actors are qualified by tenant.
34
+ if limit.scope == "actor":
35
+ scope = "a:" + json.dumps([ctx.tenant, ctx.actor])
36
+ elif limit.scope == "tenant":
37
+ scope = "t:" + json.dumps(ctx.tenant)
38
+ else:
39
+ scope = "g"
40
+ key = f"{tool}|{scope}"
41
+ now = time.monotonic()
42
+ with self._lock:
43
+ self._checks += 1
44
+ if self._checks % _SWEEP_EVERY == 0:
45
+ self._sweep(now)
46
+ entry = self._windows.get(key)
47
+ window = entry[1] if entry is not None else deque()
48
+ while window and window[0] <= now - limit.per_seconds:
49
+ window.popleft()
50
+ if len(window) >= limit.calls:
51
+ retry_after = max(0.0, window[0] + limit.per_seconds - now)
52
+ raise ToolFailure(
53
+ ErrorCode.RATE_LIMITED,
54
+ f"rate limit reached for this tool ({limit.calls} calls per "
55
+ f"{limit.per_seconds:g}s)",
56
+ retry_after=retry_after,
57
+ request_sent=False,
58
+ )
59
+ window.append(now)
60
+ self._windows[key] = (limit.per_seconds, window)
61
+
62
+ def _sweep(self, now: float) -> None:
63
+ idle = [k for k, (period, w) in self._windows.items() if not w or w[-1] <= now - period]
64
+ for key in idle:
65
+ del self._windows[key]
@@ -0,0 +1,100 @@
1
+ """Tool results.
2
+
3
+ A :class:`ToolResult` has two audiences:
4
+
5
+ * **Host code** can read every attribute, including ``approval`` (a
6
+ :class:`PendingApproval` to show a human) and diagnostics such as
7
+ ``http_status`` and ``duration_ms``.
8
+ * **The model** only ever receives :meth:`ToolResult.to_model` (or
9
+ :meth:`ToolResult.to_json`): the shaped data, transparent metadata about what
10
+ was left out, and a safe error message.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from .approval import PendingApproval
19
+ from .budget import to_json
20
+ from .errors import ErrorCode
21
+ from .shaping import Omission
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ToolError:
26
+ code: ErrorCode
27
+ message: str
28
+ retryable: bool = False
29
+ details: dict[str, Any] = field(default_factory=dict)
30
+ retry_after: float | None = None
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ out: dict[str, Any] = {"code": self.code.value, "message": self.message}
34
+ if self.retryable:
35
+ out["retryable"] = True
36
+ if self.retry_after is not None:
37
+ out["retry_after_seconds"] = round(self.retry_after, 1)
38
+ out.update(self.details)
39
+ return out
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ResultMeta:
44
+ truncated: bool = False
45
+ omitted: tuple[Omission, ...] = ()
46
+ redacted: int = 0
47
+ warnings: tuple[str, ...] = ()
48
+ next_cursor: str | None = None
49
+ items_returned: int | None = None
50
+ items_total: int | None = None
51
+ estimated_tokens: int | None = None
52
+ source: dict[str, Any] = field(default_factory=dict)
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ out: dict[str, Any] = {"truncated": self.truncated}
56
+ if self.omitted:
57
+ out["omitted"] = [o.to_dict() for o in self.omitted]
58
+ if self.redacted:
59
+ out["redacted"] = self.redacted
60
+ if self.warnings:
61
+ out["warnings"] = list(self.warnings)
62
+ if self.items_returned is not None:
63
+ out["items_returned"] = self.items_returned
64
+ if self.items_total is not None:
65
+ out["items_in_upstream_page"] = self.items_total
66
+ if self.next_cursor is not None:
67
+ out["next_cursor"] = self.next_cursor
68
+ if self.source:
69
+ out["source"] = self.source
70
+ return out
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class ToolResult:
75
+ """Outcome of one tool call."""
76
+
77
+ ok: bool
78
+ tool: str
79
+ data: Any = None
80
+ meta: ResultMeta = field(default_factory=ResultMeta)
81
+ error: ToolError | None = None
82
+ approval: PendingApproval | None = None
83
+ http_status: int | None = None
84
+ duration_ms: float | None = None
85
+ call_id: str | None = None
86
+
87
+ def to_model(self) -> dict[str, Any]:
88
+ """The model-visible envelope. Contains no credentials or host-only data."""
89
+ if self.ok:
90
+ return {"ok": True, "data": self.data, "meta": self.meta.to_dict()}
91
+ assert self.error is not None
92
+ return {"ok": False, "error": self.error.to_dict()}
93
+
94
+ def to_json(self) -> str:
95
+ """Compact JSON of :meth:`to_model`, ready for a tool-result message."""
96
+ return to_json(self.to_model())
97
+
98
+ @property
99
+ def needs_approval(self) -> bool:
100
+ return self.approval is not None
@@ -0,0 +1,201 @@
1
+ """Input schema normalization and validation (structural validation only).
2
+
3
+ Structural validation proves that arguments have the expected shape. It says
4
+ nothing about whether the caller may act (authorization) or whether data is
5
+ true (factual verification).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import warnings
13
+ from collections.abc import Mapping
14
+ from typing import Any
15
+
16
+ from jsonschema import Draft202012Validator, FormatChecker
17
+ from jsonschema.exceptions import SchemaError, ValidationError
18
+
19
+ from .errors import ConfigurationError, ErrorCode, ToolboundWarning, ToolFailure
20
+
21
+ _CHECKED_FORMATS = frozenset(FormatChecker().checkers)
22
+
23
+ _MAX_SCHEMA_DEPTH = 16
24
+ _MAX_SCHEMA_BYTES = 64 * 1024
25
+ _MAX_MESSAGE = 240
26
+
27
+ RESERVED_ARGUMENTS = frozenset({"fields", "cursor"})
28
+
29
+
30
+ def normalize_input_schema(tool_name: str, spec: Mapping[str, Any]) -> dict[str, Any]:
31
+ """Turn a developer input spec into a closed, checked JSON Schema object.
32
+
33
+ ``spec`` is either a full JSON Schema with ``"type": "object"`` or a
34
+ shorthand mapping of property name to property schema. In shorthand, every
35
+ property without a ``default`` is required.
36
+ """
37
+ raw: dict[str, Any] = copy.deepcopy(dict(spec))
38
+ schema: dict[str, Any]
39
+ if "type" in raw:
40
+ if raw.get("type") != "object":
41
+ raise ConfigurationError(f"tool {tool_name}: input schema must be type 'object'")
42
+ schema = raw
43
+ schema.setdefault("properties", {})
44
+ else:
45
+ required = [k for k, v in raw.items() if not (isinstance(v, Mapping) and "default" in v)]
46
+ schema = {"type": "object", "properties": raw, "required": required}
47
+
48
+ if schema.get("additionalProperties", False) is not False:
49
+ raise ConfigurationError(
50
+ f"tool {tool_name}: top-level additionalProperties must be false; unknown "
51
+ "arguments are always rejected"
52
+ )
53
+ schema["additionalProperties"] = False
54
+ _close_nested_objects(schema, depth=0, tool_name=tool_name)
55
+ _reject_remote_refs(schema, tool_name)
56
+ try:
57
+ Draft202012Validator.check_schema(schema)
58
+ except SchemaError as exc:
59
+ raise ConfigurationError(f"tool {tool_name}: invalid input schema: {exc.message}") from exc
60
+ if len(json.dumps(schema)) > _MAX_SCHEMA_BYTES:
61
+ raise ConfigurationError(f"tool {tool_name}: input schema is too large")
62
+ unchecked = sorted(_formats(schema) - _CHECKED_FORMATS)
63
+ if unchecked:
64
+ warnings.warn(
65
+ f"tool {tool_name}: JSON Schema format(s) {unchecked} are not enforced in this "
66
+ "environment (jsonschema needs optional packages for them). Use 'pattern', "
67
+ "'enum' or limits for anything security-relevant.",
68
+ ToolboundWarning,
69
+ stacklevel=4,
70
+ )
71
+ return schema
72
+
73
+
74
+ def _formats(node: Any) -> set[str]:
75
+ found: set[str] = set()
76
+ if isinstance(node, dict):
77
+ value = node.get("format")
78
+ if isinstance(value, str):
79
+ found.add(value)
80
+ for child in node.values():
81
+ found |= _formats(child)
82
+ elif isinstance(node, list):
83
+ for child in node:
84
+ found |= _formats(child)
85
+ return found
86
+
87
+
88
+ def _close_nested_objects(node: Any, *, depth: int, tool_name: str, where: str = "") -> None:
89
+ """Close every nested object schema that lists ``properties``.
90
+
91
+ Unknown keys are rejected unless the contract sets ``additionalProperties``
92
+ explicitly. A schema counts as an object schema when it has ``properties``,
93
+ whatever its ``type`` says (``["object", "null"]``, or no ``type`` at all). An
94
+ object schema *without* ``properties`` and without an explicit
95
+ ``additionalProperties`` accepts any keys; that is allowed but warned about.
96
+ """
97
+ if depth > _MAX_SCHEMA_DEPTH:
98
+ raise ConfigurationError(f"tool {tool_name}: input schema nests too deeply")
99
+ if isinstance(node, dict):
100
+ if depth > 0 and _is_object_schema(node):
101
+ if "properties" in node:
102
+ node.setdefault("additionalProperties", False)
103
+ elif "additionalProperties" not in node and "patternProperties" not in node:
104
+ warnings.warn(
105
+ f"tool {tool_name}: object at {where or '$'} accepts any keys. Add "
106
+ "'properties' (unknown keys are then rejected) or set "
107
+ "'additionalProperties' explicitly.",
108
+ ToolboundWarning,
109
+ stacklevel=5,
110
+ )
111
+ for key, value in node.items():
112
+ if key in ("properties", "$defs", "definitions", "patternProperties"):
113
+ if isinstance(value, dict):
114
+ for name, sub in value.items():
115
+ _close_nested_objects(
116
+ sub, depth=depth + 1, tool_name=tool_name, where=f"{where}.{name}"
117
+ )
118
+ elif isinstance(value, dict | list):
119
+ _close_nested_objects(value, depth=depth + 1, tool_name=tool_name, where=where)
120
+ elif isinstance(node, list):
121
+ for item in node:
122
+ _close_nested_objects(item, depth=depth + 1, tool_name=tool_name, where=where)
123
+
124
+
125
+ def _is_object_schema(node: dict[str, Any]) -> bool:
126
+ kind = node.get("type")
127
+ if "properties" in node:
128
+ return True
129
+ return kind == "object" or (isinstance(kind, list) and "object" in kind)
130
+
131
+
132
+ def _reject_remote_refs(node: Any, tool_name: str) -> None:
133
+ if isinstance(node, dict):
134
+ for key, value in node.items():
135
+ if key in ("$ref", "$dynamicRef") and not (
136
+ isinstance(value, str) and value.startswith("#")
137
+ ):
138
+ raise ConfigurationError(f"tool {tool_name}: only local '#/...' $ref is allowed")
139
+ if key == "$id":
140
+ raise ConfigurationError(f"tool {tool_name}: $id is not allowed in input schemas")
141
+ _reject_remote_refs(value, tool_name)
142
+ elif isinstance(node, list):
143
+ for item in node:
144
+ _reject_remote_refs(item, tool_name)
145
+
146
+
147
+ class ArgumentValidator:
148
+ """Compiled validator for one tool's arguments."""
149
+
150
+ def __init__(self, schema: dict[str, Any], *, max_argument_bytes: int) -> None:
151
+ self.schema = schema
152
+ self._validator = Draft202012Validator(schema, format_checker=FormatChecker())
153
+ self._defaults = {
154
+ name: prop["default"]
155
+ for name, prop in schema.get("properties", {}).items()
156
+ if isinstance(prop, dict) and "default" in prop
157
+ }
158
+ self._max_bytes = max_argument_bytes
159
+
160
+ def validate(self, arguments: Any) -> dict[str, Any]:
161
+ """Return validated arguments (with top-level defaults) or raise ToolFailure."""
162
+ if not isinstance(arguments, dict):
163
+ raise ToolFailure(ErrorCode.INVALID_ARGUMENTS, "arguments must be a JSON object")
164
+ try:
165
+ size = len(json.dumps(arguments, ensure_ascii=False).encode())
166
+ except (TypeError, ValueError) as exc:
167
+ raise ToolFailure(
168
+ ErrorCode.INVALID_ARGUMENTS, "arguments must be JSON-serializable"
169
+ ) from exc
170
+ if size > self._max_bytes:
171
+ raise ToolFailure(
172
+ ErrorCode.INVALID_ARGUMENTS,
173
+ f"arguments are too large ({size} bytes, limit {self._max_bytes})",
174
+ )
175
+ errors = sorted(self._validator.iter_errors(arguments), key=_error_sort_key)
176
+ if errors:
177
+ raise ToolFailure(
178
+ ErrorCode.INVALID_ARGUMENTS,
179
+ "; ".join(_describe(e) for e in errors[:3]),
180
+ details={"allowed_arguments": sorted(self.schema.get("properties", {}))},
181
+ )
182
+ result = dict(arguments)
183
+ for name, value in self._defaults.items():
184
+ result.setdefault(name, copy.deepcopy(value))
185
+ return result
186
+
187
+
188
+ def _error_sort_key(error: ValidationError) -> tuple[int, str]:
189
+ return (len(error.absolute_path), str(list(error.absolute_path)))
190
+
191
+
192
+ def _describe(error: ValidationError) -> str:
193
+ location = ".".join(str(p) for p in error.absolute_path) or "arguments"
194
+ if error.validator == "additionalProperties":
195
+ props = error.schema.get("properties", {}) if isinstance(error.schema, dict) else {}
196
+ extra = sorted(set(error.instance) - set(props)) if isinstance(error.instance, dict) else []
197
+ message = f"unknown argument(s) {extra}" if extra else error.message
198
+ else:
199
+ message = error.message
200
+ text = f"{location}: {message}"
201
+ return text if len(text) <= _MAX_MESSAGE else text[: _MAX_MESSAGE - 1] + "…"