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.
Files changed (59) hide show
  1. {agentshim-0.6.7 → agentshim-0.6.8}/CHANGELOG.md +23 -0
  2. {agentshim-0.6.7 → agentshim-0.6.8}/PKG-INFO +1 -1
  3. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/__init__.py +1 -1
  4. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/profile.py +6 -3
  5. agentshim-0.6.8/agentshim/core/schema.py +352 -0
  6. {agentshim-0.6.7 → agentshim-0.6.8}/pyproject.toml +1 -1
  7. agentshim-0.6.7/agentshim/core/schema.py +0 -281
  8. {agentshim-0.6.7 → agentshim-0.6.8}/.gitignore +0 -0
  9. {agentshim-0.6.7 → agentshim-0.6.8}/README.md +0 -0
  10. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/agent.py +0 -0
  11. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/__init__.py +0 -0
  12. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/_files.py +0 -0
  13. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/env.py +0 -0
  14. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/errors.py +0 -0
  15. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/events.py +0 -0
  16. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/mcp.py +0 -0
  17. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/provider.py +0 -0
  18. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/stream.py +0 -0
  19. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/turn.py +0 -0
  20. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/core/usage.py +0 -0
  21. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/__init__.py +0 -0
  22. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/executor.py +0 -0
  23. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/host.py +0 -0
  24. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/execution/transform.py +0 -0
  25. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/__init__.py +0 -0
  26. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/__init__.py +0 -0
  27. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/events.py +0 -0
  28. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/hooks/__init__.py +0 -0
  29. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  30. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/parser.py +0 -0
  31. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/provider.py +0 -0
  32. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/sandbox.py +0 -0
  33. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/scripted.py +0 -0
  34. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/claude/user_hooks.py +0 -0
  35. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/__init__.py +0 -0
  36. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/_toml.py +0 -0
  37. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/events.py +0 -0
  38. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/parser.py +0 -0
  39. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/provider.py +0 -0
  40. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/rules.py +0 -0
  41. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/sandbox.py +0 -0
  42. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/codex/scripted.py +0 -0
  43. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/__init__.py +0 -0
  44. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/events.py +0 -0
  45. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/parser.py +0 -0
  46. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/provider.py +0 -0
  47. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/copilot/scripted.py +0 -0
  48. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/__init__.py +0 -0
  49. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/events.py +0 -0
  50. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/parser.py +0 -0
  51. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/provider.py +0 -0
  52. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/gemini/scripted.py +0 -0
  53. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/__init__.py +0 -0
  54. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/events.py +0 -0
  55. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/parser.py +0 -0
  56. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/provider.py +0 -0
  57. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/providers/opencode/scripted.py +0 -0
  58. {agentshim-0.6.7 → agentshim-0.6.8}/agentshim/py.typed +0 -0
  59. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.6.7
3
+ Version: 0.6.8
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -84,7 +84,7 @@ from .providers.copilot import CopilotProvider
84
84
  from .providers.gemini import GeminiProvider
85
85
  from .providers.opencode import OpencodeProvider
86
86
 
87
- __version__ = "0.6.7"
87
+ __version__ = "0.6.8"
88
88
 
89
89
  __all__ = [
90
90
  "AgentEvent",
@@ -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: every object declares
38
- its properties and forbids undeclared keys. ``OPEN`` also accepts a
39
- schema-valued or ``true`` ``additionalProperties``.
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.6.7"
3
+ version = "0.6.8"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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