@cartanova/qgrid-cli 2.5.4 → 2.5.6

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.
@@ -91,13 +91,125 @@ The schema explicitly tells the model:
91
91
 
92
92
  `qgrid.dispatcher.ts` strictifies this schema through `buildStrictOutputSchema` before it reaches provider dispatchers.
93
93
 
94
- When tools and a user `jsonSchema` are both present, the dispatcher builds the
95
- same outer envelope on every turn, but replaces `answer: string | null` with
96
- `answer: <user schema> | null`. The user schema is namespaced under
97
- `$defs.__qgrid_user_output`, including rebased local JSON pointers, before the
98
- whole envelope is strictified. This prevents tool availability from disabling
99
- the final structured-output constraint, including when the model answers without
100
- calling a tool.
94
+ ## Composing Tools With A User Output Schema
95
+
96
+ This section explains why `tool-emulation-schema.ts` exists at all. Read it
97
+ before changing schema composition; the mechanics look arbitrary without the
98
+ failure it was built to prevent.
99
+
100
+ ### The problem
101
+
102
+ AI SDK hands the provider `tools` and `responseFormat` independently on every
103
+ step, and gives no "this is the final turn" flag. Before 2.5.4, qgrid saw tools
104
+ and dropped the user schema, so `answer` stayed a free-form string:
105
+
106
+ ```json
107
+ { "action": "answer", "answer": "string | null", "toolCalls": "…" }
108
+ ```
109
+
110
+ `strict: true` was in force, but only over that outer envelope. A model could
111
+ therefore return valid JSON with trailing garbage and still satisfy the schema:
112
+
113
+ ```jsonc
114
+ // Envelope-valid. answer is a string, so the trailing marker is legal.
115
+ { "action": "answer", "answer": "{\"title\":\"...\"}<|proto_end|>", "toolCalls": null }
116
+ ```
117
+
118
+ Measured on the Medpath `translateBatch()` path: 5 of 10 raw Terra responses
119
+ were corrupted this way, while the same workload with a schema and no tools
120
+ failed 0 of 385 times. The model was fine; nothing constrained the final answer.
121
+
122
+ ### The fix, and why it needs rebasing
123
+
124
+ Embed the user schema in the `answer` branch so the provider's
125
+ constrained-output machinery enforces it — the same machinery that scored
126
+ 385/385. The model still chooses `tool_call` to continue the loop or `answer` to
127
+ finish, and qgrid never adds an extra turn to "finalize".
128
+
129
+ Embedding cannot be a verbatim nest, because `$ref` is document-absolute. `#`
130
+ means the root of the *final* document, not the root of the fragment it was
131
+ written in. Nest a caller schema unchanged and its pointers silently retarget
132
+ the envelope:
133
+
134
+ ```jsonc
135
+ // Caller sends this. "#/$defs/Person" resolves inside their own document.
136
+ {
137
+ "type": "object",
138
+ "properties": { "author": { "$ref": "#/$defs/Person" } },
139
+ "$defs": { "Person": { "type": "object", "properties": { "name": { "type": "string" } } } }
140
+ }
141
+ ```
142
+
143
+ Nested verbatim, `#/$defs/Person` now points at the envelope root, where no
144
+ `Person` exists — a broken reference, or worse, a coincidental match against an
145
+ unrelated definition. Validation would then check the wrong shape and say
146
+ nothing.
147
+
148
+ So composition does two things: park the caller schema under a reserved name,
149
+ and rewrite every local pointer to the new base.
150
+
151
+ ```jsonc
152
+ {
153
+ "type": "object",
154
+ "properties": {
155
+ "action": { "enum": ["answer", "tool_call"] },
156
+ // string branch becomes a reference to the caller's schema
157
+ "answer": { "anyOf": [{ "$ref": "#/$defs/__qgrid_user_output" }, { "type": "null" }] },
158
+ "toolCalls": { "…": "unchanged" }
159
+ },
160
+ "$defs": {
161
+ "__qgrid_user_output": {
162
+ "type": "object",
163
+ "properties": { "author": { "$ref": "#/$defs/__qgrid_user_output/$defs/Person" } },
164
+ "$defs": { "Person": { "type": "object", "properties": { "name": { "type": "string" } } } }
165
+ }
166
+ }
167
+ }
168
+ ```
169
+
170
+ Pointer rewriting is mechanical — `#` gains the namespace prefix:
171
+
172
+ | Caller writes | Becomes |
173
+ | --- | --- |
174
+ | `#` | `#/$defs/__qgrid_user_output` |
175
+ | `#/$defs/Person` | `#/$defs/__qgrid_user_output/$defs/Person` |
176
+
177
+ ### Why the traversal is exhaustive
178
+
179
+ A missed position means an un-rebased `$ref` shipping to the provider, so the
180
+ rewriter must visit every place a subschema can hide. JSON Schema puts them in
181
+ three shapes — name→schema maps (`properties`, `$defs`), schema arrays
182
+ (`anyOf`, `prefixItems`), and single slots (`items`, `not`, `if`) — plus Draft-7
183
+ `dependencies`, whose value is either a schema or a list of property names.
184
+
185
+ That position list is shared: `utils/providers/common/json-schema-keywords.ts`
186
+ owns it, and the rebaser, the normalization scan, and the strict rewrite all
187
+ read from it. They previously kept private copies and had already drifted
188
+ (`additionalItems` was rebased but not scanned). Add a new keyword there, not in
189
+ a consumer.
190
+
191
+ Consumers still differ in what they *do* per position, and that stays local: the
192
+ strictifier marks positions like `not` and `if` non-normalizable because
193
+ tightening them would change caller semantics.
194
+
195
+ ### What is rejected, and why silence is not an option
196
+
197
+ Composition fails loudly on `$id`, `id`, `$anchor`, `$dynamicAnchor`,
198
+ `$recursiveAnchor`, `$dynamicRef`, and `$recursiveRef`. Each one breaks the
199
+ premise that a pointer's meaning survives relocation: `$id` declares a new base
200
+ URI so descendant refs stop resolving against the document root, and dynamic
201
+ refs pick their target from runtime scope. There is no correct rewrite, and a
202
+ wrong one would validate the wrong shape without complaint — so callers get an
203
+ HTTP 400 naming the offending path instead.
204
+
205
+ Non-object top-level schemas are also rejected. Only `type: "object"` roots can
206
+ occupy the `answer` branch.
207
+
208
+ ### Compatibility
209
+
210
+ `answer` falls back to `{ "type": "string" }` whenever no user schema is present.
211
+ That single branch is why tools-only calls and older SDKs keep working against a
212
+ 2.5.4 server unchanged.
101
213
 
102
214
  ## Provider-Specific Path
103
215