agentshim 0.6.7__tar.gz → 0.6.8__tar.gz
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.
- {agentshim-0.6.7 → agentshim-0.6.8}/CHANGELOG.md +23 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/PKG-INFO +1 -1
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/__init__.py +1 -1
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/profile.py +6 -3
- agentshim-0.6.8/agentshim/core/schema.py +352 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/pyproject.toml +1 -1
- agentshim-0.6.7/agentshim/core/schema.py +0 -281
- {agentshim-0.6.7 → agentshim-0.6.8}/.gitignore +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/README.md +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/agent.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/_files.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/env.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/errors.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/events.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/mcp.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/provider.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/stream.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/turn.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/usage.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/executor.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/host.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/transform.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/parser.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/provider.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/scripted.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/user_hooks.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/_toml.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/parser.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/provider.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/rules.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/sandbox.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/scripted.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/scripted.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/scripted.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/scripted.py +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/py.typed +0 -0
- {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/testing/__init__.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.8 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
A tightening of the Codex (`STRICT`) schema check: schemas it now rejects
|
|
6
|
+
were already rejected by the API, after a full model turn.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- A Codex output schema is checked against OpenAI's strict structured-output
|
|
11
|
+
rules before the CLI starts. `turn()` raises `SchemaDialectError`, naming
|
|
12
|
+
each offending node by JSON pointer, when an object leaves a property out
|
|
13
|
+
of `required` (declare it required and nullable instead), lists a
|
|
14
|
+
`required` name missing from `properties`, omits `properties` (`{}` is
|
|
15
|
+
fine) or `additionalProperties: false` (including on a nullable
|
|
16
|
+
`["object", "null"]` object), uses `anyOf` at
|
|
17
|
+
the root, or has a `$ref` that resolves to nothing. Codex used to fail such
|
|
18
|
+
a turn with `invalid_json_schema` only after the model ran. The Claude
|
|
19
|
+
(`OPEN`) dialect is unchanged.
|
|
20
|
+
- A `$ref` of `"#"` (root recursion) is accepted as local in both dialects.
|
|
21
|
+
- Problem pointers escape `/` and `~` in property names (RFC 6901), and
|
|
22
|
+
`enum`, `const` and `required` values are no longer inspected as schemas.
|
|
23
|
+
- `normalize` closes a nullable object node too, and gives an object with
|
|
24
|
+
no `properties` an empty one, so its output passes the `STRICT` check.
|
|
25
|
+
|
|
3
26
|
## 0.6.7 (2026-09-27)
|
|
4
27
|
|
|
5
28
|
Additive, with one tightening: a sandboxed Codex turn no longer applies the
|
|
@@ -34,9 +34,12 @@ class OutputSchemaStyle(Enum):
|
|
|
34
34
|
class SchemaDialect(Enum):
|
|
35
35
|
"""Which JSON Schema subset a provider accepts.
|
|
36
36
|
|
|
37
|
-
``STRICT`` is Codex's ``--output-schema`` subset
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
``STRICT`` is Codex's ``--output-schema`` subset, which is OpenAI's strict
|
|
38
|
+
structured outputs: every object declares ``properties``, sets
|
|
39
|
+
``additionalProperties: false`` and lists every property in ``required`` (optional values are made
|
|
40
|
+
nullable instead), the root is not an ``anyOf``, and every ``$ref``
|
|
41
|
+
resolves. ``OPEN`` also accepts optional properties and a schema-valued,
|
|
42
|
+
``true`` or absent ``additionalProperties``.
|
|
40
43
|
"""
|
|
41
44
|
|
|
42
45
|
STRICT = "strict"
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Output-schema dialect checks and materialization.
|
|
2
|
+
|
|
3
|
+
Providers disagree about which JSON Schema subset their structured-output
|
|
4
|
+
flag accepts, and the failure mode is an expensive agent turn that ends in a
|
|
5
|
+
CLI parse error. These checks run before the process starts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
15
|
+
|
|
16
|
+
from ._files import atomic_write
|
|
17
|
+
from .errors import ProviderCapabilityError
|
|
18
|
+
from .profile import SchemaDialect
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Keywords that need schema-evaluation features outside the subset the
|
|
25
|
+
# provider CLIs implement. Field names are never inspected as keywords:
|
|
26
|
+
# ``properties``, ``$defs`` and ``definitions`` are traversed as maps of
|
|
27
|
+
# subschemas, so a property literally named ``if`` is fine.
|
|
28
|
+
UNSUPPORTED_KEYWORDS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"$anchor",
|
|
31
|
+
"$dynamicAnchor",
|
|
32
|
+
"$dynamicRef",
|
|
33
|
+
"$id",
|
|
34
|
+
"$schema",
|
|
35
|
+
"allOf",
|
|
36
|
+
"contains",
|
|
37
|
+
"dependentRequired",
|
|
38
|
+
"dependentSchemas",
|
|
39
|
+
"else",
|
|
40
|
+
"if",
|
|
41
|
+
"maxContains",
|
|
42
|
+
"minContains",
|
|
43
|
+
"not",
|
|
44
|
+
"oneOf",
|
|
45
|
+
"patternProperties",
|
|
46
|
+
"prefixItems",
|
|
47
|
+
"propertyNames",
|
|
48
|
+
"then",
|
|
49
|
+
"unevaluatedItems",
|
|
50
|
+
"unevaluatedProperties",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_SUBSCHEMA_MAPS = frozenset({"properties", "$defs", "definitions"})
|
|
55
|
+
|
|
56
|
+
# Annotations that describe a schema rather than constrain a value. They are
|
|
57
|
+
# what a generator such as Pydantic emits alongside the real constraints, and
|
|
58
|
+
# what the strictest CLI subset refuses to read, so ``normalize`` removes
|
|
59
|
+
# them. Field names are never matched against this set: ``properties`` is
|
|
60
|
+
# traversed as a map of subschemas, so a property named ``title`` survives.
|
|
61
|
+
_METADATA_KEYWORDS = frozenset({"$schema", "$id", "title", "description", "examples"})
|
|
62
|
+
|
|
63
|
+
# ``$schema`` and ``$id`` identify the dialect and the document; a CLI that
|
|
64
|
+
# accepts open-ended schemas ignores them rather than failing on them. Codex's
|
|
65
|
+
# ``--output-schema`` subset does not, so ``STRICT`` keeps reporting them.
|
|
66
|
+
_OPEN_DIALECT_TOLERATES = frozenset({"$schema", "$id"})
|
|
67
|
+
|
|
68
|
+
# Keywords whose values are data, never subschemas. ``required`` is a list of
|
|
69
|
+
# field names and ``enum``/``const`` hold instance values, so a name such as
|
|
70
|
+
# ``"$ref"`` inside them must not be read as a keyword.
|
|
71
|
+
_NON_SCHEMA_KEYWORDS = frozenset({"required", "enum", "const"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def dialect_problems(schema: Mapping[str, Any], dialect: SchemaDialect) -> list[str]:
|
|
75
|
+
"""Return every reason *schema* is not expressible in *dialect*.
|
|
76
|
+
|
|
77
|
+
An empty list means the provider will accept it. The list is returned
|
|
78
|
+
rather than raised so a caller can decide between failing and falling
|
|
79
|
+
back to a prompt-level schema instruction. Each problem starts with the
|
|
80
|
+
JSON pointer of the offending node.
|
|
81
|
+
|
|
82
|
+
``STRICT`` is OpenAI's strict structured-output subset, which Codex sends
|
|
83
|
+
with ``strict: true``: every object declares ``properties``, sets
|
|
84
|
+
``additionalProperties: false`` and lists exactly its ``properties`` in
|
|
85
|
+
``required`` (an optional value is
|
|
86
|
+
expressed as nullable instead), the root is not an ``anyOf``, and every
|
|
87
|
+
``$ref`` resolves inside the document.
|
|
88
|
+
"""
|
|
89
|
+
root = dict(schema)
|
|
90
|
+
walk = _Walk(root=root, dialect=dialect, problems=[])
|
|
91
|
+
if schema.get("type") != "object":
|
|
92
|
+
walk.problems.append("# root must be a schema with type 'object'")
|
|
93
|
+
if dialect is SchemaDialect.STRICT and "anyOf" in schema:
|
|
94
|
+
walk.problems.append("# root must not use 'anyOf'")
|
|
95
|
+
walk.visit(root, "#")
|
|
96
|
+
return walk.problems
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pointer_token(name: str) -> str:
|
|
100
|
+
"""Escape a field name as one JSON pointer reference token (RFC 6901)."""
|
|
101
|
+
return name.replace("~", "~0").replace("/", "~1")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_object_node(mapping: dict[str, Any]) -> bool:
|
|
105
|
+
"""Say whether a node describes an object, nullable or not."""
|
|
106
|
+
if mapping.get("properties") is not None:
|
|
107
|
+
return True
|
|
108
|
+
declared = mapping.get("type")
|
|
109
|
+
if isinstance(declared, list):
|
|
110
|
+
return "object" in cast("list[object]", declared)
|
|
111
|
+
return declared == "object"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class _Walk:
|
|
116
|
+
"""One pass of ``dialect_problems`` over a schema document.
|
|
117
|
+
|
|
118
|
+
Traversal is by JSON pointer so each problem names the offending node.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
root: dict[str, Any]
|
|
122
|
+
dialect: SchemaDialect
|
|
123
|
+
problems: list[str]
|
|
124
|
+
|
|
125
|
+
def visit(self, node: object, location: str) -> None:
|
|
126
|
+
"""Walk one schema node, appending a problem for every unsupported construct.
|
|
127
|
+
|
|
128
|
+
A ``$ref`` node terminates the walk: its siblings are annotations the
|
|
129
|
+
strict subset ignores, and the target is checked where it is defined,
|
|
130
|
+
since every local target is inside the document this walk covers.
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(node, list):
|
|
133
|
+
for index, value in enumerate(cast("list[object]", node)):
|
|
134
|
+
self.visit(value, f"{location}/{index}")
|
|
135
|
+
return
|
|
136
|
+
if not isinstance(node, dict):
|
|
137
|
+
return
|
|
138
|
+
mapping = cast("dict[str, Any]", node)
|
|
139
|
+
|
|
140
|
+
reference = mapping.get("$ref")
|
|
141
|
+
if reference is not None:
|
|
142
|
+
self._check_ref(reference, location)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if not self._check_properties_shape(mapping, location):
|
|
146
|
+
return
|
|
147
|
+
if self.dialect is SchemaDialect.STRICT and _is_object_node(mapping):
|
|
148
|
+
self._check_closed_object(mapping, location)
|
|
149
|
+
self._check_all_required(mapping, location)
|
|
150
|
+
self._visit_members(mapping, location)
|
|
151
|
+
|
|
152
|
+
def _check_ref(self, reference: object, location: str) -> None:
|
|
153
|
+
"""Reject a ``$ref`` the CLI would have to fetch, or one that dangles."""
|
|
154
|
+
if not isinstance(reference, str) or not (reference == "#" or reference.startswith("#/")):
|
|
155
|
+
self.problems.append(f"{location} uses a non-local $ref")
|
|
156
|
+
return
|
|
157
|
+
if self.dialect is SchemaDialect.STRICT and not _resolves(self.root, reference):
|
|
158
|
+
self.problems.append(f"{location} has a $ref {reference!r} that resolves to nothing")
|
|
159
|
+
|
|
160
|
+
def _check_properties_shape(self, mapping: dict[str, Any], location: str) -> bool:
|
|
161
|
+
"""Check that ``properties``, if present, is an object.
|
|
162
|
+
|
|
163
|
+
Returns ``False`` when it is not, in which case the caller stops:
|
|
164
|
+
nothing below a malformed ``properties`` can be read as a schema, and
|
|
165
|
+
descending would only report the same defect once per child.
|
|
166
|
+
"""
|
|
167
|
+
properties = mapping.get("properties")
|
|
168
|
+
if properties is not None and not isinstance(properties, dict):
|
|
169
|
+
self.problems.append(f"{location}/properties must be an object")
|
|
170
|
+
return False
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
def _check_closed_object(self, mapping: dict[str, Any], location: str) -> None:
|
|
174
|
+
"""Require an object node to declare ``properties`` and close itself.
|
|
175
|
+
|
|
176
|
+
Strict mode needs ``additionalProperties`` present, not merely not
|
|
177
|
+
``true``: an absent one is an open object in JSON Schema. It also
|
|
178
|
+
refuses an object with no ``properties`` at all, even an empty one.
|
|
179
|
+
"""
|
|
180
|
+
if "properties" not in mapping:
|
|
181
|
+
self.problems.append(f"{location} must declare properties (use {{}} for none)")
|
|
182
|
+
if "additionalProperties" not in mapping:
|
|
183
|
+
self.problems.append(f"{location} must set additionalProperties: false")
|
|
184
|
+
elif mapping["additionalProperties"] is not False:
|
|
185
|
+
self.problems.append(f"{location} allows arbitrary object keys")
|
|
186
|
+
|
|
187
|
+
def _check_all_required(self, mapping: dict[str, Any], location: str) -> None:
|
|
188
|
+
"""Require ``required`` to list exactly the declared properties.
|
|
189
|
+
|
|
190
|
+
Strict mode has no optional properties: a value that may be absent is
|
|
191
|
+
declared required and nullable, e.g. ``{"type": ["string", "null"]}``.
|
|
192
|
+
"""
|
|
193
|
+
properties = cast("dict[str, Any]", mapping.get("properties") or {})
|
|
194
|
+
required = mapping.get("required", [])
|
|
195
|
+
if not isinstance(required, list) or not all(
|
|
196
|
+
isinstance(name, str) for name in cast("list[object]", required)
|
|
197
|
+
):
|
|
198
|
+
self.problems.append(f"{location}/required must be an array of property names")
|
|
199
|
+
return
|
|
200
|
+
listed = set(cast("list[str]", required))
|
|
201
|
+
for name in properties:
|
|
202
|
+
if name not in listed:
|
|
203
|
+
self.problems.append(
|
|
204
|
+
f"{location}/properties/{_pointer_token(name)} is optional; strict mode "
|
|
205
|
+
"requires every property in 'required' (make it nullable instead)"
|
|
206
|
+
)
|
|
207
|
+
for name in cast("list[str]", required):
|
|
208
|
+
if name not in properties:
|
|
209
|
+
self.problems.append(
|
|
210
|
+
f"{location}/required names {name!r}, which is not in 'properties'"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _visit_members(self, mapping: dict[str, Any], location: str) -> None:
|
|
214
|
+
"""Check every entry of a schema node, keyword or subschema.
|
|
215
|
+
|
|
216
|
+
Only keys reached here are matched against ``UNSUPPORTED_KEYWORDS``,
|
|
217
|
+
which is what keeps a property named ``if`` from being read as the
|
|
218
|
+
``if`` keyword.
|
|
219
|
+
"""
|
|
220
|
+
for key, value in mapping.items():
|
|
221
|
+
if key in _SUBSCHEMA_MAPS:
|
|
222
|
+
self._visit_subschema_map(value, f"{location}/{key}")
|
|
223
|
+
elif key in UNSUPPORTED_KEYWORDS:
|
|
224
|
+
if self.dialect is not SchemaDialect.STRICT and key in _OPEN_DIALECT_TOLERATES:
|
|
225
|
+
continue
|
|
226
|
+
self.problems.append(f"{location} uses unsupported keyword {key!r}")
|
|
227
|
+
elif key in _METADATA_KEYWORDS or key in _NON_SCHEMA_KEYWORDS:
|
|
228
|
+
# Annotations or plain values: nothing below them is a schema.
|
|
229
|
+
continue
|
|
230
|
+
else:
|
|
231
|
+
self.visit(value, f"{location}/{key}")
|
|
232
|
+
|
|
233
|
+
def _visit_subschema_map(self, value: object, location: str) -> None:
|
|
234
|
+
"""Walk a map of named subschemas such as ``properties`` or ``$defs``.
|
|
235
|
+
|
|
236
|
+
The names are user-chosen field names, so they are traversed as data
|
|
237
|
+
and never inspected as schema keywords.
|
|
238
|
+
"""
|
|
239
|
+
if not isinstance(value, dict):
|
|
240
|
+
self.problems.append(f"{location} must be an object")
|
|
241
|
+
return
|
|
242
|
+
for name, subschema in cast("dict[str, Any]", value).items():
|
|
243
|
+
self.visit(subschema, f"{location}/{_pointer_token(name)}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolves(root: dict[str, Any], reference: str) -> bool:
|
|
247
|
+
"""Say whether a local ``$ref`` names a schema inside *root*."""
|
|
248
|
+
node: object = root
|
|
249
|
+
for raw in reference[2:].split("/") if reference != "#" else []:
|
|
250
|
+
token = raw.replace("~1", "/").replace("~0", "~")
|
|
251
|
+
if isinstance(node, dict):
|
|
252
|
+
mapping = cast("dict[str, object]", node)
|
|
253
|
+
if token not in mapping:
|
|
254
|
+
return False
|
|
255
|
+
node = mapping[token]
|
|
256
|
+
elif isinstance(node, list):
|
|
257
|
+
items = cast("list[object]", node)
|
|
258
|
+
if not token.isdigit() or int(token) >= len(items):
|
|
259
|
+
return False
|
|
260
|
+
node = items[int(token)]
|
|
261
|
+
else:
|
|
262
|
+
return False
|
|
263
|
+
return isinstance(node, dict)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def normalize(schema: Mapping[str, Any], dialect: SchemaDialect) -> dict[str, Any]:
|
|
267
|
+
"""Return a copy of *schema* rewritten into the provider's house style.
|
|
268
|
+
|
|
269
|
+
Generators such as Pydantic omit defaulted properties from ``required``
|
|
270
|
+
and leave ``additionalProperties`` unset, which the CLIs read as "any
|
|
271
|
+
subset of these keys, plus anything else". This closes every object,
|
|
272
|
+
requires every declared property, drops ``default``, strips the annotation
|
|
273
|
+
siblings of a ``$ref`` that the strict subset forbids, and removes the
|
|
274
|
+
document metadata (``$schema``, ``$id``, ``title``, ``description``,
|
|
275
|
+
``examples``) that ``dialect_problems`` reports under ``STRICT``. What
|
|
276
|
+
comes back is therefore a schema the strict subset accepts. Callers that
|
|
277
|
+
want the schema passed through untouched skip this.
|
|
278
|
+
"""
|
|
279
|
+
return cast("dict[str, Any]", _normalized(copy.deepcopy(dict(schema)), dialect))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _normalized(node: object, dialect: SchemaDialect) -> object:
|
|
283
|
+
if isinstance(node, list):
|
|
284
|
+
return [_normalized(value, dialect) for value in cast("list[object]", node)]
|
|
285
|
+
if not isinstance(node, dict):
|
|
286
|
+
return node
|
|
287
|
+
mapping = cast("dict[str, Any]", node)
|
|
288
|
+
|
|
289
|
+
reference = mapping.get("$ref")
|
|
290
|
+
if isinstance(reference, str):
|
|
291
|
+
return {"$ref": reference}
|
|
292
|
+
|
|
293
|
+
mapping.pop("default", None)
|
|
294
|
+
for annotation in _METADATA_KEYWORDS:
|
|
295
|
+
mapping.pop(annotation, None)
|
|
296
|
+
properties = mapping.get("properties")
|
|
297
|
+
if isinstance(properties, dict):
|
|
298
|
+
_close_object(mapping, dialect)
|
|
299
|
+
mapping["required"] = list(cast("dict[str, Any]", properties))
|
|
300
|
+
elif _is_object_node(mapping):
|
|
301
|
+
_close_object(mapping, dialect)
|
|
302
|
+
mapping["properties"] = {}
|
|
303
|
+
mapping["required"] = []
|
|
304
|
+
|
|
305
|
+
for key, value in list(mapping.items()):
|
|
306
|
+
if key in _SUBSCHEMA_MAPS and isinstance(value, dict):
|
|
307
|
+
mapping[key] = {
|
|
308
|
+
name: _normalized(subschema, dialect)
|
|
309
|
+
for name, subschema in cast("dict[str, Any]", value).items()
|
|
310
|
+
}
|
|
311
|
+
continue
|
|
312
|
+
mapping[key] = _normalized(value, dialect)
|
|
313
|
+
return mapping
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _close_object(mapping: dict[str, Any], dialect: SchemaDialect) -> None:
|
|
317
|
+
additional = mapping.get("additionalProperties")
|
|
318
|
+
if additional in (None, False):
|
|
319
|
+
mapping["additionalProperties"] = False
|
|
320
|
+
return
|
|
321
|
+
if dialect is SchemaDialect.STRICT:
|
|
322
|
+
# dialect_problems already reported this; normalize does not guess.
|
|
323
|
+
mapping["additionalProperties"] = False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def compact_json(schema: Mapping[str, Any]) -> str:
|
|
327
|
+
"""Serialize *schema* for an inline CLI flag, keeping argv small."""
|
|
328
|
+
return json.dumps(dict(schema), separators=(",", ":"))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def materialize(schema: Mapping[str, Any], host_dir: Path) -> Path:
|
|
332
|
+
"""Write *schema* under *host_dir* as ``<sha>.json`` and return the path.
|
|
333
|
+
|
|
334
|
+
The name is content-addressed so repeated turns with the same schema
|
|
335
|
+
reuse one file, and the write is atomic so a CLI reading the path
|
|
336
|
+
concurrently never sees a partial document.
|
|
337
|
+
|
|
338
|
+
A schema carrying a value JSON cannot express, a ``NaN`` or an infinity,
|
|
339
|
+
raises ``ProviderCapabilityError``: the provider cannot be given this
|
|
340
|
+
schema, which is the same answer a dialect problem gets, and it is what
|
|
341
|
+
keeps a bare ``ValueError`` out of ``turn()``.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
serialized = json.dumps(dict(schema), indent=2, sort_keys=True, allow_nan=False)
|
|
345
|
+
except ValueError as exc:
|
|
346
|
+
msg = f"output schema cannot be serialized as JSON: {exc}"
|
|
347
|
+
raise ProviderCapabilityError(msg) from exc
|
|
348
|
+
encoded = (serialized + "\n").encode()
|
|
349
|
+
digest = hashlib.sha256(encoded).hexdigest()[:16]
|
|
350
|
+
target = host_dir / f"{digest}.json"
|
|
351
|
+
atomic_write(target, encoded)
|
|
352
|
+
return target
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
"""Output-schema dialect checks and materialization.
|
|
2
|
-
|
|
3
|
-
Providers disagree about which JSON Schema subset their structured-output
|
|
4
|
-
flag accepts, and the failure mode is an expensive agent turn that ends in a
|
|
5
|
-
CLI parse error. These checks run before the process starts.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import copy
|
|
11
|
-
import hashlib
|
|
12
|
-
import json
|
|
13
|
-
from typing import TYPE_CHECKING, Any, cast
|
|
14
|
-
|
|
15
|
-
from ._files import atomic_write
|
|
16
|
-
from .errors import ProviderCapabilityError
|
|
17
|
-
from .profile import SchemaDialect
|
|
18
|
-
|
|
19
|
-
if TYPE_CHECKING:
|
|
20
|
-
from collections.abc import Mapping
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
# Keywords that need schema-evaluation features outside the subset the
|
|
24
|
-
# provider CLIs implement. Field names are never inspected as keywords:
|
|
25
|
-
# ``properties``, ``$defs`` and ``definitions`` are traversed as maps of
|
|
26
|
-
# subschemas, so a property literally named ``if`` is fine.
|
|
27
|
-
UNSUPPORTED_KEYWORDS = frozenset(
|
|
28
|
-
{
|
|
29
|
-
"$anchor",
|
|
30
|
-
"$dynamicAnchor",
|
|
31
|
-
"$dynamicRef",
|
|
32
|
-
"$id",
|
|
33
|
-
"$schema",
|
|
34
|
-
"allOf",
|
|
35
|
-
"contains",
|
|
36
|
-
"dependentRequired",
|
|
37
|
-
"dependentSchemas",
|
|
38
|
-
"else",
|
|
39
|
-
"if",
|
|
40
|
-
"maxContains",
|
|
41
|
-
"minContains",
|
|
42
|
-
"not",
|
|
43
|
-
"oneOf",
|
|
44
|
-
"patternProperties",
|
|
45
|
-
"prefixItems",
|
|
46
|
-
"propertyNames",
|
|
47
|
-
"then",
|
|
48
|
-
"unevaluatedItems",
|
|
49
|
-
"unevaluatedProperties",
|
|
50
|
-
}
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
_SUBSCHEMA_MAPS = frozenset({"properties", "$defs", "definitions"})
|
|
54
|
-
|
|
55
|
-
# Annotations that describe a schema rather than constrain a value. They are
|
|
56
|
-
# what a generator such as Pydantic emits alongside the real constraints, and
|
|
57
|
-
# what the strictest CLI subset refuses to read, so ``normalize`` removes
|
|
58
|
-
# them. Field names are never matched against this set: ``properties`` is
|
|
59
|
-
# traversed as a map of subschemas, so a property named ``title`` survives.
|
|
60
|
-
_METADATA_KEYWORDS = frozenset({"$schema", "$id", "title", "description", "examples"})
|
|
61
|
-
|
|
62
|
-
# ``$schema`` and ``$id`` identify the dialect and the document; a CLI that
|
|
63
|
-
# accepts open-ended schemas ignores them rather than failing on them. Codex's
|
|
64
|
-
# ``--output-schema`` subset does not, so ``STRICT`` keeps reporting them.
|
|
65
|
-
_OPEN_DIALECT_TOLERATES = frozenset({"$schema", "$id"})
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def dialect_problems(schema: Mapping[str, Any], dialect: SchemaDialect) -> list[str]:
|
|
69
|
-
"""Return every reason *schema* is not expressible in *dialect*.
|
|
70
|
-
|
|
71
|
-
An empty list means the provider will accept it. The list is returned
|
|
72
|
-
rather than raised so a caller can decide between failing and falling
|
|
73
|
-
back to a prompt-level schema instruction.
|
|
74
|
-
"""
|
|
75
|
-
problems: list[str] = []
|
|
76
|
-
if schema.get("type") != "object":
|
|
77
|
-
problems.append("# root must be a schema with type 'object'")
|
|
78
|
-
_visit_for_problems(dict(schema), "#", dialect, problems)
|
|
79
|
-
return problems
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _visit_for_problems(
|
|
83
|
-
node: object,
|
|
84
|
-
location: str,
|
|
85
|
-
dialect: SchemaDialect,
|
|
86
|
-
problems: list[str],
|
|
87
|
-
) -> None:
|
|
88
|
-
"""Walk one schema node, appending a problem for every unsupported construct.
|
|
89
|
-
|
|
90
|
-
Traversal is by JSON pointer so each problem names the offending node.
|
|
91
|
-
A ``$ref`` node terminates the walk: its siblings are annotations the
|
|
92
|
-
strict subset ignores, and the target is checked where it is defined.
|
|
93
|
-
"""
|
|
94
|
-
if isinstance(node, list):
|
|
95
|
-
for index, value in enumerate(cast("list[object]", node)):
|
|
96
|
-
_visit_for_problems(value, f"{location}/{index}", dialect, problems)
|
|
97
|
-
return
|
|
98
|
-
if not isinstance(node, dict):
|
|
99
|
-
return
|
|
100
|
-
mapping = cast("dict[str, Any]", node)
|
|
101
|
-
|
|
102
|
-
reference = mapping.get("$ref")
|
|
103
|
-
if reference is not None:
|
|
104
|
-
_check_local_ref(reference, location, problems)
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
if not _check_properties_shape(mapping, location, problems):
|
|
108
|
-
return
|
|
109
|
-
_check_closed_object(mapping, location, dialect, problems)
|
|
110
|
-
_visit_members(mapping, location, dialect, problems)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _check_local_ref(reference: object, location: str, problems: list[str]) -> None:
|
|
114
|
-
"""Reject a ``$ref`` the CLI would have to fetch or resolve externally."""
|
|
115
|
-
if not isinstance(reference, str) or not reference.startswith("#/"):
|
|
116
|
-
problems.append(f"{location} uses a non-local $ref")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _check_properties_shape(mapping: dict[str, Any], location: str, problems: list[str]) -> bool:
|
|
120
|
-
"""Check that ``properties``, if present, is an object.
|
|
121
|
-
|
|
122
|
-
Returns ``False`` when it is not, in which case the caller stops: nothing
|
|
123
|
-
below a malformed ``properties`` can be read as a schema, and descending
|
|
124
|
-
would only report the same defect once per child.
|
|
125
|
-
"""
|
|
126
|
-
properties = mapping.get("properties")
|
|
127
|
-
if properties is not None and not isinstance(properties, dict):
|
|
128
|
-
problems.append(f"{location}/properties must be an object")
|
|
129
|
-
return False
|
|
130
|
-
return True
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _check_closed_object(
|
|
134
|
-
mapping: dict[str, Any],
|
|
135
|
-
location: str,
|
|
136
|
-
dialect: SchemaDialect,
|
|
137
|
-
problems: list[str],
|
|
138
|
-
) -> None:
|
|
139
|
-
"""Require an object node to forbid undeclared keys, in the strict dialect.
|
|
140
|
-
|
|
141
|
-
A node counts as an object if it declares ``properties`` or says so with
|
|
142
|
-
``type``. Looser dialects accept an open object, so nothing is reported.
|
|
143
|
-
"""
|
|
144
|
-
if dialect is not SchemaDialect.STRICT:
|
|
145
|
-
return
|
|
146
|
-
if mapping.get("properties") is None and mapping.get("type") != "object":
|
|
147
|
-
return
|
|
148
|
-
additional = mapping.get("additionalProperties")
|
|
149
|
-
if additional not in (None, False):
|
|
150
|
-
problems.append(f"{location} allows arbitrary object keys")
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _visit_members(
|
|
154
|
-
mapping: dict[str, Any],
|
|
155
|
-
location: str,
|
|
156
|
-
dialect: SchemaDialect,
|
|
157
|
-
problems: list[str],
|
|
158
|
-
) -> None:
|
|
159
|
-
"""Check every entry of a schema node, keyword or subschema.
|
|
160
|
-
|
|
161
|
-
Only keys reached here are matched against ``UNSUPPORTED_KEYWORDS``, which
|
|
162
|
-
is what keeps a property named ``if`` from being read as the ``if`` keyword.
|
|
163
|
-
"""
|
|
164
|
-
for key, value in mapping.items():
|
|
165
|
-
if key in _SUBSCHEMA_MAPS:
|
|
166
|
-
_visit_subschema_map(value, f"{location}/{key}", dialect, problems)
|
|
167
|
-
elif key in UNSUPPORTED_KEYWORDS:
|
|
168
|
-
if dialect is not SchemaDialect.STRICT and key in _OPEN_DIALECT_TOLERATES:
|
|
169
|
-
continue
|
|
170
|
-
problems.append(f"{location} uses unsupported keyword {key!r}")
|
|
171
|
-
elif key in _METADATA_KEYWORDS:
|
|
172
|
-
# Annotations, not constraints: nothing below them is a schema.
|
|
173
|
-
continue
|
|
174
|
-
else:
|
|
175
|
-
_visit_for_problems(value, f"{location}/{key}", dialect, problems)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _visit_subschema_map(
|
|
179
|
-
value: object,
|
|
180
|
-
location: str,
|
|
181
|
-
dialect: SchemaDialect,
|
|
182
|
-
problems: list[str],
|
|
183
|
-
) -> None:
|
|
184
|
-
"""Walk a map of named subschemas such as ``properties`` or ``$defs``.
|
|
185
|
-
|
|
186
|
-
The names are user-chosen field names, so they are traversed as data and
|
|
187
|
-
never inspected as schema keywords.
|
|
188
|
-
"""
|
|
189
|
-
if not isinstance(value, dict):
|
|
190
|
-
problems.append(f"{location} must be an object")
|
|
191
|
-
return
|
|
192
|
-
for name, subschema in cast("dict[str, Any]", value).items():
|
|
193
|
-
_visit_for_problems(subschema, f"{location}/{name}", dialect, problems)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def normalize(schema: Mapping[str, Any], dialect: SchemaDialect) -> dict[str, Any]:
|
|
197
|
-
"""Return a copy of *schema* rewritten into the provider's house style.
|
|
198
|
-
|
|
199
|
-
Generators such as Pydantic omit defaulted properties from ``required``
|
|
200
|
-
and leave ``additionalProperties`` unset, which the CLIs read as "any
|
|
201
|
-
subset of these keys, plus anything else". This closes every object,
|
|
202
|
-
requires every declared property, drops ``default``, strips the annotation
|
|
203
|
-
siblings of a ``$ref`` that the strict subset forbids, and removes the
|
|
204
|
-
document metadata (``$schema``, ``$id``, ``title``, ``description``,
|
|
205
|
-
``examples``) that ``dialect_problems`` reports under ``STRICT``. What
|
|
206
|
-
comes back is therefore a schema the strict subset accepts. Callers that
|
|
207
|
-
want the schema passed through untouched skip this.
|
|
208
|
-
"""
|
|
209
|
-
return cast("dict[str, Any]", _normalized(copy.deepcopy(dict(schema)), dialect))
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _normalized(node: object, dialect: SchemaDialect) -> object:
|
|
213
|
-
if isinstance(node, list):
|
|
214
|
-
return [_normalized(value, dialect) for value in cast("list[object]", node)]
|
|
215
|
-
if not isinstance(node, dict):
|
|
216
|
-
return node
|
|
217
|
-
mapping = cast("dict[str, Any]", node)
|
|
218
|
-
|
|
219
|
-
reference = mapping.get("$ref")
|
|
220
|
-
if isinstance(reference, str):
|
|
221
|
-
return {"$ref": reference}
|
|
222
|
-
|
|
223
|
-
mapping.pop("default", None)
|
|
224
|
-
for annotation in _METADATA_KEYWORDS:
|
|
225
|
-
mapping.pop(annotation, None)
|
|
226
|
-
properties = mapping.get("properties")
|
|
227
|
-
if isinstance(properties, dict):
|
|
228
|
-
_close_object(mapping, dialect)
|
|
229
|
-
mapping["required"] = list(cast("dict[str, Any]", properties))
|
|
230
|
-
elif mapping.get("type") == "object":
|
|
231
|
-
_close_object(mapping, dialect)
|
|
232
|
-
mapping["required"] = []
|
|
233
|
-
|
|
234
|
-
for key, value in list(mapping.items()):
|
|
235
|
-
if key in _SUBSCHEMA_MAPS and isinstance(value, dict):
|
|
236
|
-
mapping[key] = {
|
|
237
|
-
name: _normalized(subschema, dialect)
|
|
238
|
-
for name, subschema in cast("dict[str, Any]", value).items()
|
|
239
|
-
}
|
|
240
|
-
continue
|
|
241
|
-
mapping[key] = _normalized(value, dialect)
|
|
242
|
-
return mapping
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _close_object(mapping: dict[str, Any], dialect: SchemaDialect) -> None:
|
|
246
|
-
additional = mapping.get("additionalProperties")
|
|
247
|
-
if additional in (None, False):
|
|
248
|
-
mapping["additionalProperties"] = False
|
|
249
|
-
return
|
|
250
|
-
if dialect is SchemaDialect.STRICT:
|
|
251
|
-
# dialect_problems already reported this; normalize does not guess.
|
|
252
|
-
mapping["additionalProperties"] = False
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def compact_json(schema: Mapping[str, Any]) -> str:
|
|
256
|
-
"""Serialize *schema* for an inline CLI flag, keeping argv small."""
|
|
257
|
-
return json.dumps(dict(schema), separators=(",", ":"))
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def materialize(schema: Mapping[str, Any], host_dir: Path) -> Path:
|
|
261
|
-
"""Write *schema* under *host_dir* as ``<sha>.json`` and return the path.
|
|
262
|
-
|
|
263
|
-
The name is content-addressed so repeated turns with the same schema
|
|
264
|
-
reuse one file, and the write is atomic so a CLI reading the path
|
|
265
|
-
concurrently never sees a partial document.
|
|
266
|
-
|
|
267
|
-
A schema carrying a value JSON cannot express, a ``NaN`` or an infinity,
|
|
268
|
-
raises ``ProviderCapabilityError``: the provider cannot be given this
|
|
269
|
-
schema, which is the same answer a dialect problem gets, and it is what
|
|
270
|
-
keeps a bare ``ValueError`` out of ``turn()``.
|
|
271
|
-
"""
|
|
272
|
-
try:
|
|
273
|
-
serialized = json.dumps(dict(schema), indent=2, sort_keys=True, allow_nan=False)
|
|
274
|
-
except ValueError as exc:
|
|
275
|
-
msg = f"output schema cannot be serialized as JSON: {exc}"
|
|
276
|
-
raise ProviderCapabilityError(msg) from exc
|
|
277
|
-
encoded = (serialized + "\n").encode()
|
|
278
|
-
digest = hashlib.sha256(encoded).hexdigest()[:16]
|
|
279
|
-
target = host_dir / f"{digest}.json"
|
|
280
|
-
atomic_write(target, encoded)
|
|
281
|
-
return target
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|