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.
- toolbound/__init__.py +123 -0
- toolbound/adapters/__init__.py +9 -0
- toolbound/adapters/_common.py +84 -0
- toolbound/adapters/anthropic.py +81 -0
- toolbound/adapters/openai.py +115 -0
- toolbound/cli.py +69 -0
- toolbound/core/__init__.py +4 -0
- toolbound/core/approval.py +314 -0
- toolbound/core/budget.py +42 -0
- toolbound/core/context.py +56 -0
- toolbound/core/contracts.py +689 -0
- toolbound/core/credentials.py +41 -0
- toolbound/core/errors.py +117 -0
- toolbound/core/exposure.py +68 -0
- toolbound/core/gateway.py +1461 -0
- toolbound/core/network.py +267 -0
- toolbound/core/observe.py +60 -0
- toolbound/core/paths.py +138 -0
- toolbound/core/ports.py +166 -0
- toolbound/core/pyschema.py +119 -0
- toolbound/core/ratelimit.py +65 -0
- toolbound/core/result.py +100 -0
- toolbound/core/schema.py +201 -0
- toolbound/core/shaping.py +711 -0
- toolbound/core/signing.py +137 -0
- toolbound/core/state.py +102 -0
- toolbound/estimators.py +24 -0
- toolbound/loaders/__init__.py +202 -0
- toolbound/loaders/meta_schema.py +162 -0
- toolbound/py.typed +0 -0
- toolbound/sync.py +76 -0
- toolbound/transport/__init__.py +19 -0
- toolbound/transport/http.py +269 -0
- toolbound/transport/mock.py +89 -0
- toolbound-0.2.0.dist-info/METADATA +547 -0
- toolbound-0.2.0.dist-info/RECORD +39 -0
- toolbound-0.2.0.dist-info/WHEEL +4 -0
- toolbound-0.2.0.dist-info/entry_points.txt +2 -0
- toolbound-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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]
|
toolbound/core/result.py
ADDED
|
@@ -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
|
toolbound/core/schema.py
ADDED
|
@@ -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] + "…"
|