pi-durable-python 1.3.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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Tool-argument repair, coercion, and validation.
|
|
2
|
+
|
|
3
|
+
Port of the subset of pi-ai 1.3.0 ``utils/validation.ts`` the tool task needs. Upstream validates
|
|
4
|
+
against a TypeBox schema; the Python port validates against the JSON Schema the tool declares,
|
|
5
|
+
coercing primitives the way pi-ai does before the first validation, and removing optional
|
|
6
|
+
properties a provider sent as ``null``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..json import copy_json
|
|
19
|
+
|
|
20
|
+
_JSON_TYPES = {"number", "integer", "boolean", "string", "null", "array", "object"}
|
|
21
|
+
|
|
22
|
+
_MESSAGES = {
|
|
23
|
+
"number": "Expected number",
|
|
24
|
+
"integer": "Expected integer",
|
|
25
|
+
"boolean": "Expected boolean",
|
|
26
|
+
"string": "Expected string",
|
|
27
|
+
"null": "Expected null",
|
|
28
|
+
"array": "Expected array",
|
|
29
|
+
"object": "Expected object",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ToolArgumentError(ValueError):
|
|
34
|
+
"""Invalid arguments for a tool call, with the message the model reads."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_tool_arguments(tool: Any, call: Mapping[str, Any]) -> dict[str, Any]:
|
|
38
|
+
"""Validate a call's arguments against the tool's schema, returning the coerced arguments.
|
|
39
|
+
|
|
40
|
+
Raises :class:`ToolArgumentError` with the formatted message pi-ai throws.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name = str(call.get("name") or getattr(tool, "name", ""))
|
|
44
|
+
if call.get("argumentsError") is not None:
|
|
45
|
+
raise ToolArgumentError(f'Invalid arguments for tool "{name}": {call["argumentsError"]}')
|
|
46
|
+
schema = getattr(tool, "parameters", None) or {}
|
|
47
|
+
original = call.get("arguments")
|
|
48
|
+
args = copy.deepcopy(copy_json(original if original is not None else {}))
|
|
49
|
+
if isinstance(args, dict):
|
|
50
|
+
_normalize_optional_nulls(args, schema)
|
|
51
|
+
args = _coerce(args, schema)
|
|
52
|
+
if isinstance(args, dict) and _errors(args, schema) == []:
|
|
53
|
+
return args
|
|
54
|
+
errors = _errors(args, schema) or [("root", "Expected object")]
|
|
55
|
+
detail = "\n".join(f" - {path}: {message}" for path, message in errors)
|
|
56
|
+
raise ToolArgumentError(
|
|
57
|
+
f'Validation failed for tool "{name}":\n{detail}\n\n'
|
|
58
|
+
f"Received arguments:\n{json.dumps(original, indent=2)}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Validation ──────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _path_of(instance_path: str) -> str:
|
|
66
|
+
"""Format a JSON Pointer as a dotted path; the root is ``root``."""
|
|
67
|
+
|
|
68
|
+
path = instance_path.lstrip("/").replace("/", ".")
|
|
69
|
+
return path or "root"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _errors(value: Any, schema: Any, instance_path: str = "") -> list[tuple[str, str]]:
|
|
73
|
+
if not isinstance(schema, Mapping):
|
|
74
|
+
return []
|
|
75
|
+
found: list[tuple[str, str]] = []
|
|
76
|
+
if isinstance(schema.get("allOf"), list):
|
|
77
|
+
for nested in schema["allOf"]:
|
|
78
|
+
found.extend(_errors(value, nested, instance_path))
|
|
79
|
+
for keyword in ("anyOf", "oneOf"):
|
|
80
|
+
alternatives = schema.get(keyword)
|
|
81
|
+
if not isinstance(alternatives, list):
|
|
82
|
+
continue
|
|
83
|
+
passed = sum(1 for nested in alternatives if not _errors(value, nested, instance_path))
|
|
84
|
+
wanted = 1 if keyword == "oneOf" else 1
|
|
85
|
+
if passed < wanted:
|
|
86
|
+
found.append((_path_of(instance_path), "Expected union value"))
|
|
87
|
+
if "const" in schema and value != schema["const"]:
|
|
88
|
+
found.append((_path_of(instance_path), f"Expected {json.dumps(schema['const'])}"))
|
|
89
|
+
if isinstance(schema.get("enum"), list) and value not in schema["enum"]:
|
|
90
|
+
options = ", ".join(json.dumps(item) for item in schema["enum"])
|
|
91
|
+
found.append((_path_of(instance_path), f"Expected one of {options}"))
|
|
92
|
+
types = _schema_types(schema)
|
|
93
|
+
if types and not any(_matches(value, item) for item in types):
|
|
94
|
+
expected = " | ".join(types) if len(types) > 1 else types[0]
|
|
95
|
+
found.append((_path_of(instance_path), _MESSAGES.get(types[0], f"Expected {expected}")))
|
|
96
|
+
return found
|
|
97
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
98
|
+
found.extend(_number_errors(value, schema, instance_path))
|
|
99
|
+
if isinstance(value, str):
|
|
100
|
+
found.extend(_string_errors(value, schema, instance_path))
|
|
101
|
+
if isinstance(value, list):
|
|
102
|
+
found.extend(_array_errors(value, schema, instance_path))
|
|
103
|
+
if isinstance(value, dict):
|
|
104
|
+
found.extend(_object_errors(value, schema, instance_path))
|
|
105
|
+
return found
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _schema_types(schema: Mapping[str, Any]) -> list[str]:
|
|
109
|
+
declared = schema.get("type")
|
|
110
|
+
if isinstance(declared, str):
|
|
111
|
+
return [declared] if declared in _JSON_TYPES else []
|
|
112
|
+
if isinstance(declared, list):
|
|
113
|
+
return [item for item in declared if isinstance(item, str) and item in _JSON_TYPES]
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _matches(value: Any, json_type: str) -> bool:
|
|
118
|
+
if json_type == "number":
|
|
119
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
120
|
+
if json_type == "integer":
|
|
121
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
122
|
+
if json_type == "boolean":
|
|
123
|
+
return isinstance(value, bool)
|
|
124
|
+
if json_type == "string":
|
|
125
|
+
return isinstance(value, str)
|
|
126
|
+
if json_type == "null":
|
|
127
|
+
return value is None
|
|
128
|
+
if json_type == "array":
|
|
129
|
+
return isinstance(value, list)
|
|
130
|
+
if json_type == "object":
|
|
131
|
+
return isinstance(value, dict)
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _number_errors(value: float, schema: Mapping[str, Any], path: str) -> list[tuple[str, str]]:
|
|
136
|
+
found: list[tuple[str, str]] = []
|
|
137
|
+
minimum = schema.get("minimum")
|
|
138
|
+
if isinstance(minimum, (int, float)) and value < minimum:
|
|
139
|
+
found.append((_path_of(path), f"Expected number to be greater or equal to {minimum}"))
|
|
140
|
+
maximum = schema.get("maximum")
|
|
141
|
+
if isinstance(maximum, (int, float)) and value > maximum:
|
|
142
|
+
found.append((_path_of(path), f"Expected number to be less or equal to {maximum}"))
|
|
143
|
+
exclusive_minimum = schema.get("exclusiveMinimum")
|
|
144
|
+
if isinstance(exclusive_minimum, (int, float)) and value <= exclusive_minimum:
|
|
145
|
+
found.append((_path_of(path), f"Expected number to be greater than {exclusive_minimum}"))
|
|
146
|
+
exclusive_maximum = schema.get("exclusiveMaximum")
|
|
147
|
+
if isinstance(exclusive_maximum, (int, float)) and value >= exclusive_maximum:
|
|
148
|
+
found.append((_path_of(path), f"Expected number to be less than {exclusive_maximum}"))
|
|
149
|
+
multiple = schema.get("multipleOf")
|
|
150
|
+
if isinstance(multiple, (int, float)) and multiple > 0:
|
|
151
|
+
quotient = value / multiple
|
|
152
|
+
if not math.isclose(quotient, round(quotient), abs_tol=1e-9):
|
|
153
|
+
found.append((_path_of(path), f"Expected number to be a multiple of {multiple}"))
|
|
154
|
+
return found
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _string_errors(value: str, schema: Mapping[str, Any], path: str) -> list[tuple[str, str]]:
|
|
158
|
+
import re
|
|
159
|
+
|
|
160
|
+
found: list[tuple[str, str]] = []
|
|
161
|
+
min_length = schema.get("minLength")
|
|
162
|
+
if isinstance(min_length, int) and len(value) < min_length:
|
|
163
|
+
found.append((_path_of(path), f"Expected string to have a minimum length of {min_length}"))
|
|
164
|
+
max_length = schema.get("maxLength")
|
|
165
|
+
if isinstance(max_length, int) and len(value) > max_length:
|
|
166
|
+
found.append((_path_of(path), f"Expected string to have a maximum length of {max_length}"))
|
|
167
|
+
pattern = schema.get("pattern")
|
|
168
|
+
if isinstance(pattern, str):
|
|
169
|
+
try:
|
|
170
|
+
matched = re.search(pattern, value) is not None
|
|
171
|
+
except re.error:
|
|
172
|
+
matched = True
|
|
173
|
+
if not matched:
|
|
174
|
+
found.append((_path_of(path), f"Expected string to match '{pattern}'"))
|
|
175
|
+
return found
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _array_errors(value: list[Any], schema: Mapping[str, Any], path: str) -> list[tuple[str, str]]:
|
|
179
|
+
found: list[tuple[str, str]] = []
|
|
180
|
+
min_items = schema.get("minItems")
|
|
181
|
+
if isinstance(min_items, int) and len(value) < min_items:
|
|
182
|
+
found.append((_path_of(path), f"Expected array to have at least {min_items} items"))
|
|
183
|
+
max_items = schema.get("maxItems")
|
|
184
|
+
if isinstance(max_items, int) and len(value) > max_items:
|
|
185
|
+
found.append((_path_of(path), f"Expected array to have at most {max_items} items"))
|
|
186
|
+
if schema.get("uniqueItems") is True:
|
|
187
|
+
seen: list[Any] = []
|
|
188
|
+
for item in value:
|
|
189
|
+
if item in seen:
|
|
190
|
+
found.append((_path_of(path), "Expected array items to be unique"))
|
|
191
|
+
break
|
|
192
|
+
seen.append(item)
|
|
193
|
+
items = schema.get("items")
|
|
194
|
+
if isinstance(items, list):
|
|
195
|
+
for index, item_schema in enumerate(items):
|
|
196
|
+
if index < len(value):
|
|
197
|
+
found.extend(_errors(value[index], item_schema, f"{path}/{index}"))
|
|
198
|
+
elif isinstance(items, Mapping):
|
|
199
|
+
for index, item in enumerate(value):
|
|
200
|
+
found.extend(_errors(item, items, f"{path}/{index}"))
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _object_errors(value: dict[str, Any], schema: Mapping[str, Any], path: str) -> list[tuple[str, str]]:
|
|
205
|
+
found: list[tuple[str, str]] = []
|
|
206
|
+
properties = schema.get("properties")
|
|
207
|
+
properties = properties if isinstance(properties, Mapping) else {}
|
|
208
|
+
required = schema.get("required")
|
|
209
|
+
for name in required if isinstance(required, list) else []:
|
|
210
|
+
if name not in value:
|
|
211
|
+
found.append((_path_of(f"{path}/{name}"), "Expected required property"))
|
|
212
|
+
for name, item in value.items():
|
|
213
|
+
item_path = f"{path}/{name}"
|
|
214
|
+
if name in properties:
|
|
215
|
+
found.extend(_errors(item, properties[name], item_path))
|
|
216
|
+
continue
|
|
217
|
+
additional = schema.get("additionalProperties")
|
|
218
|
+
if additional is False:
|
|
219
|
+
found.append((_path_of(item_path), "Expected not to have additional properties"))
|
|
220
|
+
elif isinstance(additional, Mapping):
|
|
221
|
+
found.extend(_errors(item, additional, item_path))
|
|
222
|
+
min_properties = schema.get("minProperties")
|
|
223
|
+
if isinstance(min_properties, int) and len(value) < min_properties:
|
|
224
|
+
found.append((_path_of(path), f"Expected object to have at least {min_properties} properties"))
|
|
225
|
+
max_properties = schema.get("maxProperties")
|
|
226
|
+
if isinstance(max_properties, int) and len(value) > max_properties:
|
|
227
|
+
found.append((_path_of(path), f"Expected object to have at most {max_properties} properties"))
|
|
228
|
+
return found
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── Coercion ────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _normalize_optional_nulls(value: Any, schema: Any) -> None:
|
|
235
|
+
"""Remove optional properties a provider sent as ``null``, as pi-ai does before validation."""
|
|
236
|
+
|
|
237
|
+
if not isinstance(schema, Mapping):
|
|
238
|
+
return
|
|
239
|
+
if isinstance(value, list):
|
|
240
|
+
items = schema.get("items")
|
|
241
|
+
if isinstance(items, list):
|
|
242
|
+
for index, item in enumerate(value):
|
|
243
|
+
if index < len(items):
|
|
244
|
+
_normalize_optional_nulls(item, items[index])
|
|
245
|
+
elif isinstance(items, Mapping):
|
|
246
|
+
for item in value:
|
|
247
|
+
_normalize_optional_nulls(item, items)
|
|
248
|
+
return
|
|
249
|
+
if not isinstance(value, dict):
|
|
250
|
+
return
|
|
251
|
+
properties = schema.get("properties")
|
|
252
|
+
if not isinstance(properties, Mapping):
|
|
253
|
+
return
|
|
254
|
+
required = set(schema.get("required") or [])
|
|
255
|
+
for name, property_schema in properties.items():
|
|
256
|
+
if name not in value:
|
|
257
|
+
continue
|
|
258
|
+
if (
|
|
259
|
+
value[name] is None
|
|
260
|
+
and name not in required
|
|
261
|
+
and isinstance(property_schema, Mapping)
|
|
262
|
+
and not isinstance(property_schema.get("$ref"), str)
|
|
263
|
+
and _errors(None, property_schema) != []
|
|
264
|
+
):
|
|
265
|
+
del value[name]
|
|
266
|
+
else:
|
|
267
|
+
_normalize_optional_nulls(value[name], property_schema)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _coerce(value: Any, schema: Any) -> Any:
|
|
271
|
+
if not isinstance(schema, Mapping):
|
|
272
|
+
return value
|
|
273
|
+
if isinstance(schema.get("allOf"), list):
|
|
274
|
+
for nested in schema["allOf"]:
|
|
275
|
+
value = _coerce(value, nested)
|
|
276
|
+
for keyword in ("anyOf", "oneOf"):
|
|
277
|
+
alternatives = schema.get(keyword)
|
|
278
|
+
if isinstance(alternatives, list):
|
|
279
|
+
value = _coerce_union(value, alternatives)
|
|
280
|
+
types = _schema_types(schema)
|
|
281
|
+
matches_union_member = len(types) > 1 and any(_matches(value, item) for item in types)
|
|
282
|
+
if types and not matches_union_member:
|
|
283
|
+
for json_type in types:
|
|
284
|
+
candidate = _coerce_primitive(value, json_type)
|
|
285
|
+
if candidate is not value:
|
|
286
|
+
value = candidate
|
|
287
|
+
break
|
|
288
|
+
if "object" in types and isinstance(value, dict):
|
|
289
|
+
_coerce_object(value, schema)
|
|
290
|
+
if "array" in types and isinstance(value, list):
|
|
291
|
+
_coerce_array(value, schema)
|
|
292
|
+
return value
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _coerce_union(value: Any, alternatives: list[Any]) -> Any:
|
|
296
|
+
if any(not _errors(value, schema) for schema in alternatives):
|
|
297
|
+
return value
|
|
298
|
+
for schema in alternatives:
|
|
299
|
+
candidate = copy.deepcopy(value)
|
|
300
|
+
coerced = _coerce(candidate, schema)
|
|
301
|
+
if not _errors(coerced, schema):
|
|
302
|
+
return coerced
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _coerce_object(value: dict[str, Any], schema: Mapping[str, Any]) -> None:
|
|
307
|
+
properties = schema.get("properties")
|
|
308
|
+
defined = set(properties or {}) if isinstance(properties, Mapping) else set()
|
|
309
|
+
if isinstance(properties, Mapping):
|
|
310
|
+
for name, property_schema in properties.items():
|
|
311
|
+
if name in value:
|
|
312
|
+
value[name] = _coerce(value[name], property_schema)
|
|
313
|
+
additional = schema.get("additionalProperties")
|
|
314
|
+
if isinstance(additional, Mapping):
|
|
315
|
+
for name, item in value.items():
|
|
316
|
+
if name not in defined:
|
|
317
|
+
value[name] = _coerce(item, additional)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _coerce_array(value: list[Any], schema: Mapping[str, Any]) -> None:
|
|
321
|
+
items = schema.get("items")
|
|
322
|
+
if isinstance(items, list):
|
|
323
|
+
for index, item_schema in enumerate(items):
|
|
324
|
+
if index < len(value):
|
|
325
|
+
value[index] = _coerce(value[index], item_schema)
|
|
326
|
+
return
|
|
327
|
+
if isinstance(items, Mapping):
|
|
328
|
+
for index, item in enumerate(value):
|
|
329
|
+
value[index] = _coerce(item, items)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _coerce_primitive(value: Any, json_type: str) -> Any:
|
|
333
|
+
if json_type == "number":
|
|
334
|
+
if value is None:
|
|
335
|
+
return 0
|
|
336
|
+
if isinstance(value, str) and value.strip() != "":
|
|
337
|
+
try:
|
|
338
|
+
parsed = float(value)
|
|
339
|
+
except ValueError:
|
|
340
|
+
return value
|
|
341
|
+
if math.isfinite(parsed):
|
|
342
|
+
return int(parsed) if parsed.is_integer() else parsed
|
|
343
|
+
if isinstance(value, bool) and type(value) is bool:
|
|
344
|
+
return 1 if value else 0
|
|
345
|
+
return value
|
|
346
|
+
if json_type == "integer":
|
|
347
|
+
if value is None:
|
|
348
|
+
return 0
|
|
349
|
+
if isinstance(value, str) and value.strip() != "":
|
|
350
|
+
try:
|
|
351
|
+
parsed = float(value)
|
|
352
|
+
except ValueError:
|
|
353
|
+
return value
|
|
354
|
+
if math.isfinite(parsed) and parsed.is_integer():
|
|
355
|
+
return int(parsed)
|
|
356
|
+
if isinstance(value, bool) and type(value) is bool:
|
|
357
|
+
return 1 if value else 0
|
|
358
|
+
return value
|
|
359
|
+
if json_type == "boolean":
|
|
360
|
+
if value is None:
|
|
361
|
+
return False
|
|
362
|
+
if value == "true":
|
|
363
|
+
return True
|
|
364
|
+
if value == "false":
|
|
365
|
+
return False
|
|
366
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
367
|
+
if value == 1:
|
|
368
|
+
return True
|
|
369
|
+
if value == 0:
|
|
370
|
+
return False
|
|
371
|
+
return value
|
|
372
|
+
if json_type == "string":
|
|
373
|
+
if value is None:
|
|
374
|
+
return ""
|
|
375
|
+
if isinstance(value, bool):
|
|
376
|
+
return "true" if value else "false"
|
|
377
|
+
if isinstance(value, (int, float)):
|
|
378
|
+
return _js_string(value)
|
|
379
|
+
return value
|
|
380
|
+
if json_type == "null":
|
|
381
|
+
if value == "" or (isinstance(value, int) and not isinstance(value, bool) and value == 0):
|
|
382
|
+
return None
|
|
383
|
+
if value is False:
|
|
384
|
+
return None
|
|
385
|
+
return value
|
|
386
|
+
return value
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _js_string(value: int | float) -> str:
|
|
390
|
+
"""JavaScript ``String(number)``: integral floats lose their fraction, ``1e21`` is exponential."""
|
|
391
|
+
|
|
392
|
+
if isinstance(value, int):
|
|
393
|
+
return str(value)
|
|
394
|
+
if value.is_integer() and abs(value) < 1e21:
|
|
395
|
+
return str(int(value))
|
|
396
|
+
return repr(value)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
_VALIDATOR_CACHE_SIZE = 64
|
|
400
|
+
|
|
401
|
+
_STRUCTURED_VALIDATORS: OrderedDict[int, tuple[Any, Any]] = OrderedDict()
|
|
402
|
+
"""Compiled validators keyed by the identity of the schema object they validate, least recently used
|
|
403
|
+
first, so a long-lived process with many short-lived tools does not accumulate them."""
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def structured_output_error(tool: Any, result: Mapping[str, Any]) -> str | None:
|
|
407
|
+
"""Why a result's ``structuredOutput`` breaks the tool's contract, or ``None`` when it keeps it.
|
|
408
|
+
|
|
409
|
+
A tool that declares ``structured_output_schema`` must return a matching value, except on an
|
|
410
|
+
error result, where it may omit it; a tool without one must not return one. Validation needs
|
|
411
|
+
the optional ``schema`` extra, imported only here so the core package has no dependencies.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
name = str(getattr(tool, "name", ""))
|
|
415
|
+
schema = getattr(tool, "structured_output_schema", None)
|
|
416
|
+
present = "structuredOutput" in result
|
|
417
|
+
value = result.get("structuredOutput")
|
|
418
|
+
if schema is None:
|
|
419
|
+
if not present:
|
|
420
|
+
return None
|
|
421
|
+
return f"Tool {name} returned structuredOutput but declares no structuredOutputSchema"
|
|
422
|
+
if not present:
|
|
423
|
+
if result.get("isError") is True:
|
|
424
|
+
return None
|
|
425
|
+
return f"Tool {name} returned no structuredOutput"
|
|
426
|
+
validator = _structured_validator(schema)
|
|
427
|
+
errors = list(validator.iter_errors(value))
|
|
428
|
+
if not errors:
|
|
429
|
+
return None
|
|
430
|
+
first = errors[0]
|
|
431
|
+
path = ".".join(str(item) for item in first.absolute_path) or "root"
|
|
432
|
+
return f"Tool {name} returned structuredOutput that does not match its schema: {path}: {first.message}"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _structured_validator(schema: Any) -> Any:
|
|
436
|
+
cached = _STRUCTURED_VALIDATORS.get(id(schema))
|
|
437
|
+
if cached is not None and cached[0] is schema:
|
|
438
|
+
_STRUCTURED_VALIDATORS.move_to_end(id(schema))
|
|
439
|
+
return cached[1]
|
|
440
|
+
try:
|
|
441
|
+
from jsonschema import Draft202012Validator # type: ignore[import-untyped]
|
|
442
|
+
except ImportError as error:
|
|
443
|
+
# The core package keeps no runtime dependency; only a schema tool needs the extra.
|
|
444
|
+
raise ImportError(
|
|
445
|
+
"JSON Schema validation requires the 'schema' extra: "
|
|
446
|
+
"install pi-durable-python[schema]"
|
|
447
|
+
) from error
|
|
448
|
+
validator = Draft202012Validator(schema)
|
|
449
|
+
_STRUCTURED_VALIDATORS[id(schema)] = (schema, validator)
|
|
450
|
+
_STRUCTURED_VALIDATORS.move_to_end(id(schema))
|
|
451
|
+
while len(_STRUCTURED_VALIDATORS) > _VALIDATOR_CACHE_SIZE:
|
|
452
|
+
_STRUCTURED_VALIDATORS.popitem(last=False)
|
|
453
|
+
return validator
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
__all__ = [
|
|
457
|
+
"ToolArgumentError",
|
|
458
|
+
"structured_output_error",
|
|
459
|
+
"validate_tool_arguments",
|
|
460
|
+
]
|