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.
@@ -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