agent-runtime-client 0.1.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.
- agent_runtime_client/__init__.py +84 -0
- agent_runtime_client/client.py +319 -0
- agent_runtime_client/errors.py +51 -0
- agent_runtime_client/functions.py +92 -0
- agent_runtime_client/helpers.py +30 -0
- agent_runtime_client/models.py +131 -0
- agent_runtime_client/py.typed +0 -0
- agent_runtime_client/validation.py +493 -0
- agent_runtime_client-0.1.0.dist-info/METADATA +239 -0
- agent_runtime_client-0.1.0.dist-info/RECORD +13 -0
- agent_runtime_client-0.1.0.dist-info/WHEEL +5 -0
- agent_runtime_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_runtime_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Shapes of the agent-runtime API, as TypedDicts over the JSON the server sends and receives."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 8):
|
|
8
|
+
from typing import Literal, TypedDict
|
|
9
|
+
else: # pragma: no cover
|
|
10
|
+
from typing_extensions import Literal, TypedDict
|
|
11
|
+
|
|
12
|
+
RequestRole = Literal["user", "assistant", "system"]
|
|
13
|
+
InferenceErrorCode = Literal["invalid_format", "schema_mismatch", "empty_answer", "max_turns", "timeout", "provider_error", "internal_error"]
|
|
14
|
+
InferenceStatus = Literal["running", "success", "error"]
|
|
15
|
+
InferenceMode = Literal["object", "text"]
|
|
16
|
+
AttachmentKind = Literal["file", "image", "tool_image", "output"]
|
|
17
|
+
|
|
18
|
+
FormatModel = Dict[str, Any]
|
|
19
|
+
"""A JSON Schema (draft 2020-12) whose top level is an object with ``required`` and ``properties``."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RequestMessage(TypedDict, total=False):
|
|
23
|
+
"""One message of the conversation; the shape of a wwwroot.jsonl record message."""
|
|
24
|
+
|
|
25
|
+
role: RequestRole
|
|
26
|
+
content: str
|
|
27
|
+
images: List[str]
|
|
28
|
+
"""Base64 images (raw or data: URL). Shown to the model in user messages and stored in the history; not written to the workspace."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RequestFile(TypedDict):
|
|
32
|
+
"""A file written into the agent workspace before the run starts."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
content: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InferenceError(TypedDict):
|
|
39
|
+
code: InferenceErrorCode
|
|
40
|
+
message: str
|
|
41
|
+
details: List[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RunInfo(TypedDict):
|
|
45
|
+
id: str
|
|
46
|
+
model: str
|
|
47
|
+
attempts: int
|
|
48
|
+
turns: int
|
|
49
|
+
durationMs: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InferenceSuccess(RunInfo):
|
|
53
|
+
status: Literal["success"]
|
|
54
|
+
data: Any
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TextSuccess(RunInfo):
|
|
58
|
+
status: Literal["success"]
|
|
59
|
+
text: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InferenceFailure(RunInfo):
|
|
63
|
+
status: Literal["error"]
|
|
64
|
+
error: InferenceError
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
InferenceResponse = Union[InferenceSuccess, InferenceFailure]
|
|
68
|
+
TextResponse = Union[TextSuccess, InferenceFailure]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AttachmentMeta(TypedDict):
|
|
72
|
+
id: str
|
|
73
|
+
inferenceId: str
|
|
74
|
+
kind: AttachmentKind
|
|
75
|
+
name: str
|
|
76
|
+
mime: str
|
|
77
|
+
size: int
|
|
78
|
+
sha256: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Attachment(AttachmentMeta):
|
|
82
|
+
data: bytes
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class InferenceSummary(TypedDict):
|
|
86
|
+
id: str
|
|
87
|
+
mode: InferenceMode
|
|
88
|
+
status: InferenceStatus
|
|
89
|
+
model: str
|
|
90
|
+
createdAt: str
|
|
91
|
+
finishedAt: Optional[str]
|
|
92
|
+
durationMs: Optional[int]
|
|
93
|
+
attempts: int
|
|
94
|
+
turns: int
|
|
95
|
+
errorCode: Optional[str]
|
|
96
|
+
errorMessage: Optional[str]
|
|
97
|
+
messageCount: int
|
|
98
|
+
fileCount: int
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class StoredRequest(TypedDict, total=False):
|
|
102
|
+
"""Request as stored by the server: image and file payloads replaced by attachment ids."""
|
|
103
|
+
|
|
104
|
+
messages: List[Dict[str, Any]]
|
|
105
|
+
files: List[Dict[str, Any]]
|
|
106
|
+
format: FormatModel
|
|
107
|
+
model: str
|
|
108
|
+
instructions: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class InferenceRecord(InferenceSummary):
|
|
112
|
+
request: StoredRequest
|
|
113
|
+
messages: List[Any]
|
|
114
|
+
result: Any
|
|
115
|
+
error: Optional[InferenceError]
|
|
116
|
+
attachments: List[AttachmentMeta]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ListInferencesResponse(TypedDict):
|
|
120
|
+
total: int
|
|
121
|
+
items: List[InferenceSummary]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Health(TypedDict):
|
|
125
|
+
status: str
|
|
126
|
+
model: str
|
|
127
|
+
host: str
|
|
128
|
+
vision: bool
|
|
129
|
+
running: bool
|
|
130
|
+
queued: int
|
|
131
|
+
uptimeSeconds: int
|
|
File without changes
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Answer validator, a port of the one shared by the agent-runtime server and agent-runtime-sdk.
|
|
2
|
+
|
|
3
|
+
The source of truth is ``src/validation/validateToolArguments.ts`` in the agent-runtime
|
|
4
|
+
repository (the same file byte for byte in ``packages/node-sdk``). This module mirrors its
|
|
5
|
+
logic and its messages so that a Python client rejects exactly what the server rejects:
|
|
6
|
+
JSON Schema draft 2020-12 with ``type`` as string or list (incl. ``"null"``), ``enum``/``const``
|
|
7
|
+
of any JSON value, ``oneOf``/``anyOf``/``allOf``/``not``, ``if``/``then``/``else``, local ``$ref``
|
|
8
|
+
(JSON pointer into the root, e.g. ``#/$defs/predicate``), ``additionalProperties``,
|
|
9
|
+
``patternProperties``, ``propertyNames``, ``dependentRequired``/``dependentSchemas``,
|
|
10
|
+
``minProperties``/``maxProperties``, ``items``/``prefixItems``/``contains``/``uniqueItems``,
|
|
11
|
+
``minItems``/``maxItems``, ``minLength``/``maxLength``/``pattern``, ``minimum``/``maximum``/
|
|
12
|
+
``exclusiveMinimum``/``exclusiveMaximum``/``multipleOf``. Unknown keywords and ``format`` are
|
|
13
|
+
ignored, so it is never stricter than the server's ajv check. No dependencies.
|
|
14
|
+
|
|
15
|
+
One error is reported, as a dotted path from the root ``answer``::
|
|
16
|
+
|
|
17
|
+
signal.direction: must be exactly "long"
|
|
18
|
+
claims[0].id: must match pattern ^c[0-9]{1,2}$
|
|
19
|
+
|
|
20
|
+
``pattern`` uses Python's ``re``; a pattern it cannot compile (``\\p{L}`` classes, for example)
|
|
21
|
+
is skipped unless the optional ``regex`` package is installed (``pip install agent-runtime-client[unicode]``).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Dict, List, Optional, Pattern
|
|
30
|
+
from urllib.parse import unquote
|
|
31
|
+
|
|
32
|
+
try: # the optional `regex` package understands JavaScript-style \p{...} classes
|
|
33
|
+
import regex as _regex # type: ignore
|
|
34
|
+
except ImportError: # pragma: no cover
|
|
35
|
+
_regex = None
|
|
36
|
+
|
|
37
|
+
ROOT_PATH = "answer"
|
|
38
|
+
|
|
39
|
+
_UNDEFINED = object()
|
|
40
|
+
"""Stands in for JavaScript's ``undefined`` where ``None`` would mean JSON ``null``."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ValidationResult:
|
|
45
|
+
success: bool
|
|
46
|
+
data: Any = None
|
|
47
|
+
error: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def type_of(value: Any) -> str:
|
|
51
|
+
"""JSON type name of a value, as the error messages report it."""
|
|
52
|
+
if value is None:
|
|
53
|
+
return "null"
|
|
54
|
+
if isinstance(value, bool):
|
|
55
|
+
return "boolean"
|
|
56
|
+
if isinstance(value, (int, float)):
|
|
57
|
+
return "number"
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
return "string"
|
|
60
|
+
if isinstance(value, list):
|
|
61
|
+
return "array"
|
|
62
|
+
if isinstance(value, dict):
|
|
63
|
+
return "object"
|
|
64
|
+
return type(value).__name__
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_number(value: Any) -> bool:
|
|
68
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _js(value: Any) -> Any:
|
|
72
|
+
"""Shapes a value the way JSON.stringify prints it: integral floats without ".0", non-finite numbers as null."""
|
|
73
|
+
if isinstance(value, bool) or value is None or isinstance(value, (str, int)):
|
|
74
|
+
return value
|
|
75
|
+
if isinstance(value, float):
|
|
76
|
+
if not math.isfinite(value):
|
|
77
|
+
return None
|
|
78
|
+
return int(value) if value.is_integer() else value
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return [_js(item) for item in value]
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
return {str(key): _js(item) for key, item in value.items()}
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _show(value: Any) -> str:
|
|
87
|
+
return json.dumps(_js(value), separators=(",", ":"), ensure_ascii=False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _num(value: Any) -> str:
|
|
91
|
+
"""A number in a message, printed as JavaScript prints it."""
|
|
92
|
+
if isinstance(value, float) and math.isfinite(value) and value.is_integer():
|
|
93
|
+
return str(int(value))
|
|
94
|
+
return str(value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _join(path: str, key: str) -> str:
|
|
98
|
+
return key if path == ROOT_PATH else f"{path}.{key}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _at(path: str, index: int) -> str:
|
|
102
|
+
return f"{path}[{index}]"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _head(error: str) -> str:
|
|
106
|
+
index = error.find(":")
|
|
107
|
+
return error[:index] if index >= 0 else error[:-1]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_TYPE_MISMATCH = re.compile(
|
|
111
|
+
r": must be (?:[a-z]+(?: with required [^,]+(?:, [^,]+)*| of [a-z ]+)?)(?: or [a-z]+(?: with required [^,]+(?:, [^,]+)*| of [a-z ]+)?)*, got [a-z]+$"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_type_mismatch(error: str) -> bool:
|
|
116
|
+
return _TYPE_MISMATCH.search(error) is not None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _rank_of(error: str) -> int:
|
|
120
|
+
"""Rank of a branch error of a oneOf/anyOf, higher is more specific: deeper path first; on equal
|
|
121
|
+
depth a branch that matched the value's type but failed inside it beats a branch that did not."""
|
|
122
|
+
depth = len(re.findall(r"[.\[]", _head(error)))
|
|
123
|
+
return depth * 2 + (0 if _is_type_mismatch(error) else 1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _branch_error(errors: List[str], path: str) -> str:
|
|
127
|
+
"""One message for a failed oneOf/anyOf at ``path``: the deepest branch error when some branch
|
|
128
|
+
matched the value's shape, otherwise the alternatives merged into one line."""
|
|
129
|
+
prefix = f"{path}: "
|
|
130
|
+
content = [error for error in errors if not (_is_type_mismatch(error) and _head(error) == path)]
|
|
131
|
+
pool = content if content else errors
|
|
132
|
+
at_root = all(_head(error) == path for error in pool)
|
|
133
|
+
if not at_root:
|
|
134
|
+
best = pool[0] if pool else "does not match the schema"
|
|
135
|
+
for error in pool:
|
|
136
|
+
if _rank_of(error) > _rank_of(best):
|
|
137
|
+
best = error
|
|
138
|
+
return best
|
|
139
|
+
messages: List[str] = []
|
|
140
|
+
for error in pool:
|
|
141
|
+
message = error[len(prefix):]
|
|
142
|
+
if message not in messages:
|
|
143
|
+
messages.append(message)
|
|
144
|
+
types: List[str] = []
|
|
145
|
+
values: List[str] = []
|
|
146
|
+
others: List[str] = []
|
|
147
|
+
got = ""
|
|
148
|
+
for message in messages:
|
|
149
|
+
type_match = re.fullmatch(r"must be (.+), got ([a-z]+)", message)
|
|
150
|
+
exact = re.fullmatch(r"must be exactly (.+)", message)
|
|
151
|
+
one_of = re.fullmatch(r"must be one of \[(.+)\]", message)
|
|
152
|
+
if type_match:
|
|
153
|
+
for name in type_match.group(1).split(" or "):
|
|
154
|
+
if name not in types:
|
|
155
|
+
types.append(name)
|
|
156
|
+
got = type_match.group(2)
|
|
157
|
+
elif exact:
|
|
158
|
+
values.append(exact.group(1))
|
|
159
|
+
elif one_of:
|
|
160
|
+
values.append(one_of.group(1))
|
|
161
|
+
else:
|
|
162
|
+
others.append(message)
|
|
163
|
+
parts: List[str] = []
|
|
164
|
+
if types:
|
|
165
|
+
parts.append(f"must be {' or '.join(types)}, got {got}")
|
|
166
|
+
if values:
|
|
167
|
+
parts.append(f"must be one of [{','.join(values)}]")
|
|
168
|
+
parts.extend(others)
|
|
169
|
+
return f"{path}: {'; or '.join(parts)}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _deep_equal(a: Any, b: Any) -> bool:
|
|
173
|
+
kind = type_of(a)
|
|
174
|
+
if kind != type_of(b):
|
|
175
|
+
return False
|
|
176
|
+
if kind == "null":
|
|
177
|
+
return True
|
|
178
|
+
if kind == "array":
|
|
179
|
+
return len(a) == len(b) and all(_deep_equal(x, y) for x, y in zip(a, b))
|
|
180
|
+
if kind == "object":
|
|
181
|
+
return len(a) == len(b) and all(key in b and _deep_equal(a[key], b[key]) for key in a)
|
|
182
|
+
return a == b
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _matches_type(value: Any, name: str) -> bool:
|
|
186
|
+
if name == "string":
|
|
187
|
+
return isinstance(value, str)
|
|
188
|
+
if name == "number":
|
|
189
|
+
return _is_number(value) and math.isfinite(value)
|
|
190
|
+
if name == "integer":
|
|
191
|
+
return _is_number(value) and math.isfinite(value) and float(value).is_integer()
|
|
192
|
+
if name == "boolean":
|
|
193
|
+
return isinstance(value, bool)
|
|
194
|
+
if name == "null":
|
|
195
|
+
return value is None
|
|
196
|
+
if name == "array":
|
|
197
|
+
return isinstance(value, list)
|
|
198
|
+
if name == "object":
|
|
199
|
+
return isinstance(value, dict)
|
|
200
|
+
return True # unknown type name: do not reject, the server decides
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _describe_type(name: str, node: Dict[str, Any]) -> str:
|
|
204
|
+
"""``object`` alone says nothing about the shape: name its required properties; for arrays the item type."""
|
|
205
|
+
required = node.get("required")
|
|
206
|
+
if name == "object" and isinstance(required, list) and required:
|
|
207
|
+
return "object with required " + ", ".join(_show(key) for key in required)
|
|
208
|
+
items = node.get("items")
|
|
209
|
+
if name == "array" and isinstance(items, dict) and items.get("type") is not None:
|
|
210
|
+
item_type = items["type"]
|
|
211
|
+
return "array of " + (" or ".join(item_type) if isinstance(item_type, list) else str(item_type))
|
|
212
|
+
return name
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_regex_cache: Dict[str, Optional[Pattern[str]]] = {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _to_regexp(pattern: str) -> Optional[Pattern[str]]:
|
|
219
|
+
if pattern in _regex_cache:
|
|
220
|
+
return _regex_cache[pattern]
|
|
221
|
+
compiled: Optional[Pattern[str]] = None
|
|
222
|
+
try:
|
|
223
|
+
compiled = re.compile(pattern)
|
|
224
|
+
except re.error:
|
|
225
|
+
if _regex is not None:
|
|
226
|
+
try:
|
|
227
|
+
compiled = _regex.compile(pattern)
|
|
228
|
+
except Exception: # pragma: no cover
|
|
229
|
+
compiled = None
|
|
230
|
+
_regex_cache[pattern] = compiled
|
|
231
|
+
return compiled
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def resolve_ref(root: Any, ref: str) -> Any:
|
|
235
|
+
"""Resolves a local JSON pointer reference (``#/$defs/name``, ``#/properties/x``) against the root schema.
|
|
236
|
+
Returns the sentinel ``_UNDEFINED`` when the reference does not resolve."""
|
|
237
|
+
if not ref.startswith("#"):
|
|
238
|
+
return _UNDEFINED
|
|
239
|
+
segments = [unquote(segment).replace("~1", "/").replace("~0", "~") for segment in ref[1:].split("/") if segment]
|
|
240
|
+
node: Any = root
|
|
241
|
+
for segment in segments:
|
|
242
|
+
if isinstance(node, dict):
|
|
243
|
+
node = node.get(segment, _UNDEFINED)
|
|
244
|
+
elif isinstance(node, list) and segment.isdigit() and int(segment) < len(node):
|
|
245
|
+
node = node[int(segment)]
|
|
246
|
+
else:
|
|
247
|
+
return _UNDEFINED
|
|
248
|
+
if node is _UNDEFINED:
|
|
249
|
+
return _UNDEFINED
|
|
250
|
+
return node
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def validate_value(value: Any, schema: Any, path: str, root: Any = _UNDEFINED) -> Optional[str]:
|
|
254
|
+
"""Validates ``value`` against ``schema``; returns the first problem as ``path: message`` or None.
|
|
255
|
+
``root`` is the schema that ``$ref``s are resolved against (defaults to ``schema``)."""
|
|
256
|
+
if root is _UNDEFINED:
|
|
257
|
+
root = schema
|
|
258
|
+
if schema is True or schema is _UNDEFINED or schema is None:
|
|
259
|
+
return None
|
|
260
|
+
if schema is False:
|
|
261
|
+
return f"{path}: is not allowed"
|
|
262
|
+
if not isinstance(schema, dict):
|
|
263
|
+
return None
|
|
264
|
+
node = schema
|
|
265
|
+
|
|
266
|
+
ref = node.get("$ref")
|
|
267
|
+
if isinstance(ref, str):
|
|
268
|
+
target = resolve_ref(root, ref)
|
|
269
|
+
if target is _UNDEFINED:
|
|
270
|
+
return f"{path}: schema error, unresolvable $ref {ref}"
|
|
271
|
+
error = validate_value(value, target, path, root)
|
|
272
|
+
if error:
|
|
273
|
+
return error
|
|
274
|
+
|
|
275
|
+
if node.get("type") is not None:
|
|
276
|
+
types = node["type"] if isinstance(node["type"], list) else [node["type"]]
|
|
277
|
+
if not any(_matches_type(value, name) for name in types):
|
|
278
|
+
return f"{path}: must be {' or '.join(_describe_type(name, node) for name in types)}, got {type_of(value)}"
|
|
279
|
+
if "const" in node and not _deep_equal(value, node["const"]):
|
|
280
|
+
return f"{path}: must be exactly {_show(node['const'])}"
|
|
281
|
+
enum = node.get("enum")
|
|
282
|
+
if isinstance(enum, list) and not any(_deep_equal(value, item) for item in enum):
|
|
283
|
+
return f"{path}: must be one of {_show(enum)}"
|
|
284
|
+
|
|
285
|
+
if isinstance(value, str):
|
|
286
|
+
min_length = node.get("minLength")
|
|
287
|
+
if _is_number(min_length) and len(value) < min_length:
|
|
288
|
+
return f"{path}: must NOT have fewer than {_num(min_length)} characters"
|
|
289
|
+
max_length = node.get("maxLength")
|
|
290
|
+
if _is_number(max_length) and len(value) > max_length:
|
|
291
|
+
return f"{path}: must NOT have more than {_num(max_length)} characters"
|
|
292
|
+
pattern = node.get("pattern")
|
|
293
|
+
if isinstance(pattern, str):
|
|
294
|
+
compiled = _to_regexp(pattern)
|
|
295
|
+
if compiled is not None and compiled.search(value) is None:
|
|
296
|
+
return f"{path}: must match pattern {pattern}"
|
|
297
|
+
|
|
298
|
+
if _is_number(value):
|
|
299
|
+
minimum = node.get("minimum")
|
|
300
|
+
if _is_number(minimum) and value < minimum:
|
|
301
|
+
return f"{path}: must be >= {_num(minimum)}"
|
|
302
|
+
maximum = node.get("maximum")
|
|
303
|
+
if _is_number(maximum) and value > maximum:
|
|
304
|
+
return f"{path}: must be <= {_num(maximum)}"
|
|
305
|
+
exclusive_minimum = node.get("exclusiveMinimum")
|
|
306
|
+
if _is_number(exclusive_minimum) and value <= exclusive_minimum:
|
|
307
|
+
return f"{path}: must be > {_num(exclusive_minimum)}"
|
|
308
|
+
exclusive_maximum = node.get("exclusiveMaximum")
|
|
309
|
+
if _is_number(exclusive_maximum) and value >= exclusive_maximum:
|
|
310
|
+
return f"{path}: must be < {_num(exclusive_maximum)}"
|
|
311
|
+
multiple_of = node.get("multipleOf")
|
|
312
|
+
if _is_number(multiple_of) and multiple_of > 0:
|
|
313
|
+
ratio = value / multiple_of
|
|
314
|
+
if not math.isfinite(ratio) or ratio != math.trunc(ratio):
|
|
315
|
+
return f"{path}: must be multiple of {_num(multiple_of)}"
|
|
316
|
+
|
|
317
|
+
if isinstance(value, list):
|
|
318
|
+
min_items = node.get("minItems")
|
|
319
|
+
if _is_number(min_items) and len(value) < min_items:
|
|
320
|
+
return f"{path}: must NOT have fewer than {_num(min_items)} items"
|
|
321
|
+
max_items = node.get("maxItems")
|
|
322
|
+
if _is_number(max_items) and len(value) > max_items:
|
|
323
|
+
return f"{path}: must NOT have more than {_num(max_items)} items"
|
|
324
|
+
if node.get("uniqueItems") is True:
|
|
325
|
+
for i in range(len(value)):
|
|
326
|
+
for j in range(i + 1, len(value)):
|
|
327
|
+
if _deep_equal(value[i], value[j]):
|
|
328
|
+
return f"{path}: must NOT have duplicate items (items {i} and {j} are identical)"
|
|
329
|
+
prefix = node.get("prefixItems") if isinstance(node.get("prefixItems"), list) else []
|
|
330
|
+
for i in range(min(len(prefix), len(value))):
|
|
331
|
+
error = validate_value(value[i], prefix[i], _at(path, i), root)
|
|
332
|
+
if error:
|
|
333
|
+
return error
|
|
334
|
+
if "items" in node:
|
|
335
|
+
for i in range(len(prefix), len(value)):
|
|
336
|
+
error = validate_value(value[i], node["items"], _at(path, i), root)
|
|
337
|
+
if error:
|
|
338
|
+
return error
|
|
339
|
+
if "contains" in node:
|
|
340
|
+
matches = sum(1 for item in value if validate_value(item, node["contains"], path, root) is None)
|
|
341
|
+
min_contains = node.get("minContains") if _is_number(node.get("minContains")) else 1
|
|
342
|
+
if matches < min_contains:
|
|
343
|
+
return f"{path}: must contain at least {_num(min_contains)} valid item(s)"
|
|
344
|
+
max_contains = node.get("maxContains")
|
|
345
|
+
if _is_number(max_contains) and matches > max_contains:
|
|
346
|
+
return f"{path}: must contain at most {_num(max_contains)} valid item(s)"
|
|
347
|
+
|
|
348
|
+
if isinstance(value, dict):
|
|
349
|
+
required = node.get("required")
|
|
350
|
+
if isinstance(required, list):
|
|
351
|
+
missing = [key for key in required if key not in value]
|
|
352
|
+
if len(missing) == 1:
|
|
353
|
+
return f"{path}: is missing required property {_show(missing[0])}"
|
|
354
|
+
if len(missing) > 1:
|
|
355
|
+
return f"{path}: is missing required properties {', '.join(_show(key) for key in missing)}"
|
|
356
|
+
min_properties = node.get("minProperties")
|
|
357
|
+
if _is_number(min_properties) and len(value) < min_properties:
|
|
358
|
+
return f"{path}: must NOT have fewer than {_num(min_properties)} properties"
|
|
359
|
+
max_properties = node.get("maxProperties")
|
|
360
|
+
if _is_number(max_properties) and len(value) > max_properties:
|
|
361
|
+
return f"{path}: must NOT have more than {_num(max_properties)} properties"
|
|
362
|
+
properties = node.get("properties") if isinstance(node.get("properties"), dict) else {}
|
|
363
|
+
patterns = []
|
|
364
|
+
if isinstance(node.get("patternProperties"), dict):
|
|
365
|
+
for pattern, sub in node["patternProperties"].items():
|
|
366
|
+
compiled = _to_regexp(pattern)
|
|
367
|
+
if compiled is not None:
|
|
368
|
+
patterns.append((compiled, sub))
|
|
369
|
+
for key, item in value.items():
|
|
370
|
+
covered = False
|
|
371
|
+
if key in properties:
|
|
372
|
+
covered = True
|
|
373
|
+
error = validate_value(item, properties[key], _join(path, key), root)
|
|
374
|
+
if error:
|
|
375
|
+
return error
|
|
376
|
+
for compiled, sub in patterns:
|
|
377
|
+
if compiled.search(key) is None:
|
|
378
|
+
continue
|
|
379
|
+
covered = True
|
|
380
|
+
error = validate_value(item, sub, _join(path, key), root)
|
|
381
|
+
if error:
|
|
382
|
+
return error
|
|
383
|
+
if not covered and "additionalProperties" in node:
|
|
384
|
+
if node["additionalProperties"] is False:
|
|
385
|
+
return f"{path}: has unexpected property {_show(key)}"
|
|
386
|
+
error = validate_value(item, node["additionalProperties"], _join(path, key), root)
|
|
387
|
+
if error:
|
|
388
|
+
return error
|
|
389
|
+
if "propertyNames" in node:
|
|
390
|
+
error = validate_value(key, node["propertyNames"], _join(path, key), root)
|
|
391
|
+
if error:
|
|
392
|
+
return f"{path}: property name {_show(key)} is not allowed ({error[error.find(':') + 2:]})"
|
|
393
|
+
if isinstance(node.get("dependentRequired"), dict):
|
|
394
|
+
for key, deps in node["dependentRequired"].items():
|
|
395
|
+
if key not in value:
|
|
396
|
+
continue
|
|
397
|
+
missing = [dep for dep in (deps or []) if dep not in value]
|
|
398
|
+
if missing:
|
|
399
|
+
return f"{path}: must have {', '.join(_show(dep) for dep in missing)} when {_show(key)} is present"
|
|
400
|
+
if isinstance(node.get("dependentSchemas"), dict):
|
|
401
|
+
for key, sub in node["dependentSchemas"].items():
|
|
402
|
+
if key not in value:
|
|
403
|
+
continue
|
|
404
|
+
error = validate_value(value, sub, path, root)
|
|
405
|
+
if error:
|
|
406
|
+
return error
|
|
407
|
+
|
|
408
|
+
if isinstance(node.get("allOf"), list):
|
|
409
|
+
for sub in node["allOf"]:
|
|
410
|
+
error = validate_value(value, sub, path, root)
|
|
411
|
+
if error:
|
|
412
|
+
return error
|
|
413
|
+
if isinstance(node.get("anyOf"), list):
|
|
414
|
+
errors = [validate_value(value, sub, path, root) for sub in node["anyOf"]]
|
|
415
|
+
if not any(error is None for error in errors):
|
|
416
|
+
return _branch_error([error for error in errors if error is not None], path)
|
|
417
|
+
if isinstance(node.get("oneOf"), list):
|
|
418
|
+
errors = [validate_value(value, sub, path, root) for sub in node["oneOf"]]
|
|
419
|
+
passed = sum(1 for error in errors if error is None)
|
|
420
|
+
if passed == 0:
|
|
421
|
+
return _branch_error([error for error in errors if error is not None], path)
|
|
422
|
+
if passed > 1:
|
|
423
|
+
return f"{path}: must match exactly one of the allowed shapes, matched {passed}"
|
|
424
|
+
if "not" in node and validate_value(value, node["not"], path, root) is None:
|
|
425
|
+
return f"{path}: must NOT match the forbidden shape"
|
|
426
|
+
if "if" in node:
|
|
427
|
+
holds = validate_value(value, node["if"], path, root) is None
|
|
428
|
+
branch_key = "then" if holds else "else"
|
|
429
|
+
if branch_key in node:
|
|
430
|
+
error = validate_value(value, node[branch_key], path, root)
|
|
431
|
+
# a then/else violation only makes sense together with its condition
|
|
432
|
+
if error:
|
|
433
|
+
return f"{error} (rule: {'when' if holds else 'unless'} {_describe_condition(node['if'], path)})"
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _describe_condition(schema: Any, path: str) -> str:
|
|
438
|
+
"""Human-readable form of an ``if`` schema: ``verdict is "summary_only"``, ``a.b is one of [...]``, ``(x is true or y is true)``."""
|
|
439
|
+
if schema is True:
|
|
440
|
+
return "always"
|
|
441
|
+
if schema is False:
|
|
442
|
+
return "never"
|
|
443
|
+
if not isinstance(schema, dict):
|
|
444
|
+
return "the condition holds"
|
|
445
|
+
parts: List[str] = []
|
|
446
|
+
if "const" in schema:
|
|
447
|
+
parts.append(f"{path} is {_show(schema['const'])}")
|
|
448
|
+
if isinstance(schema.get("enum"), list):
|
|
449
|
+
parts.append(f"{path} is one of {_show(schema['enum'])}")
|
|
450
|
+
if schema.get("type") is not None and "const" not in schema and not isinstance(schema.get("enum"), list):
|
|
451
|
+
type_name = schema["type"]
|
|
452
|
+
parts.append(f"{path} is {' or '.join(type_name) if isinstance(type_name, list) else type_name}")
|
|
453
|
+
if isinstance(schema.get("properties"), dict):
|
|
454
|
+
for key, sub in schema["properties"].items():
|
|
455
|
+
parts.append(_describe_condition(sub, _join(path, key)))
|
|
456
|
+
if isinstance(schema.get("allOf"), list):
|
|
457
|
+
parts.extend(_describe_condition(sub, path) for sub in schema["allOf"])
|
|
458
|
+
if isinstance(schema.get("anyOf"), list):
|
|
459
|
+
parts.append("(" + " or ".join(_describe_condition(sub, path) for sub in schema["anyOf"]) + ")")
|
|
460
|
+
if isinstance(schema.get("oneOf"), list):
|
|
461
|
+
parts.append("(exactly one of: " + ", ".join(_describe_condition(sub, path) for sub in schema["oneOf"]) + ")")
|
|
462
|
+
if "not" in schema:
|
|
463
|
+
parts.append(f"not ({_describe_condition(schema['not'], path)})")
|
|
464
|
+
if isinstance(schema.get("$ref"), str):
|
|
465
|
+
parts.append(f"{path} matches {schema['$ref']}")
|
|
466
|
+
return " and ".join(parts) if parts else f"{path} matches {_show(schema)[:120]}"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def validate_tool_arguments(parsed_arguments: Any, schema: Dict[str, Any]) -> ValidationResult:
|
|
470
|
+
"""Validates an answer (the ``provide_answer`` arguments) against the ``format`` schema."""
|
|
471
|
+
if parsed_arguments is None:
|
|
472
|
+
if isinstance(schema, dict) and schema.get("required"):
|
|
473
|
+
return ValidationResult(False, error="Tool call has empty arguments")
|
|
474
|
+
return ValidationResult(True, data={})
|
|
475
|
+
if not isinstance(parsed_arguments, dict):
|
|
476
|
+
return ValidationResult(False, error=f"Invalid arguments: expected object, got {type_of(parsed_arguments)}")
|
|
477
|
+
error = validate_value(parsed_arguments, schema, ROOT_PATH, schema)
|
|
478
|
+
if error:
|
|
479
|
+
return ValidationResult(False, error=f"Invalid arguments: {error}")
|
|
480
|
+
return ValidationResult(True, data=parsed_arguments)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def validate_answer(value: Any, format: Dict[str, Any]) -> ValidationResult:
|
|
484
|
+
"""Checks an answer (from the server, a cache on disk, anywhere) against its schema."""
|
|
485
|
+
return validate_tool_arguments(value, format)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def assert_answer(value: Any, format: Dict[str, Any]) -> Dict[str, Any]:
|
|
489
|
+
"""Raises ``ValueError`` when the answer does not match the schema; returns it otherwise."""
|
|
490
|
+
result = validate_answer(value, format)
|
|
491
|
+
if not result.success:
|
|
492
|
+
raise ValueError(result.error or "Invalid answer")
|
|
493
|
+
return result.data
|