feat(llm): honour supportsTools: "strict" on the OpenAI tools payload - #395
Merged
Conversation
Reported on Discord: mercury-2.5 misforms tool calls without OpenAI's strict function schemas, and there was no way to ask for them. "strict" was already a legal supportsTools level, parsed out of config and carried through the model resolver, but nothing consumed it — setting it did nothing at all, and extraBody merges at the top level of the request body so it cannot reach a per-tool function.strict either. Strict schemas are far more restrictive than the descriptors this repo carries, so the conversion is per tool and refuses per tool: a mixed tools array is legal, while a whole-array flag would 400 every request the moment one tool does not fit.
The keyword allowlist bounded what a node may say; nothing bounded how
many of them there were. The built-in descriptors hid it — the deepest,
`os.shell.run`, nests two levels — but a third-party MCP `inputSchema`
is not so polite, and both failure modes are the one this module exists
to prevent:
* a schema nested past a strict compiler's ceiling is rejected with
the WHOLE request, taking every other tool's definition down with
it, which is exactly the outcome the per-tool refusal buys its way
out of;
* a self-referential schema ran the recursion into a `RangeError`
that escaped `descriptorsToOpenAiTools` and killed the step. It
cannot come off the wire, since MCP schemas arrive through
`JSON.parse`, but nothing here promised that.
Five levels is the conservative reading of the published ceiling, and
refusing deeper costs that one tool its strict marking and nothing else
— it ships exactly as it does today. Built-in coverage is unchanged at
77 of 82.
Also pins two refusals that no test held. `anyOf` beside a sibling
`type` was already declined and reads as a contradiction to a strict
compiler, so passing it through would emit a node no provider can
compile; inverting that check used to be a silent mutation. And AGENTS.md
now records that the null-drop is per request rather than per tool, so a
refused tool cannot express a literal top-level `null` while the level is
set.
…ections
Review fixes on the strict tool schemas.
The null drop was gated on the batch-level flag while the conversion is
per tool, so a tool whose schema was REFUSED — shipped byte-identical to
the flag-off payload — also had its top-level nulls deleted. For a
third-party MCP tool with a required ["string","null"] argument the model
sends null because the tool's own schema asked for it, and the server
received a call missing a required key. The adapter now reports the
escaped names it actually marked strict (strictToolNames) and the batch
side undoes the padding for exactly those, so "the refusals ship exactly
as they do with the flag off" is true on the way in as well as out.
The converter also refused the two standard spellings of "nullable", so
it was not idempotent and rejected nearly every pydantic/FastMCP schema:
type: ["string","null"], an anyOf with a {type:"null"} branch and a bare
{type:"null"} are now accepted and never widened twice, and the
annotations default/$schema/$comment are accepted and dropped (a strict
decode has no absent key for a default to fill; the null is deleted again
on the way in, so the server applies its own default as it does today).
$defs/$ref stays refused.
An absent additionalProperties was treated as false, inverting the JSON
Schema default: an MCP object the server left open was closed and still
marked strict, and a zero-property MCP tool was published as a
zero-argument tool. It is now refused unless the schema says false
outright, which costs the built-ins nothing — default-tool-args-schemas
spells it out on every object, and coverage is unchanged at 77 of 82
registered schemas, 76 of 82 emitted functions.
A property named __proto__ was silently dropped (out[name] = ... on an
object literal sets the prototype), leaving a function marked strict
whose schema forbade an argument the tool declares; properties are built
with Object.fromEntries now.
Tests: the per-tool gate end to end in step-executor (a converted tool's
forced null is dropped, a refused tool's is not), the three nullable
spellings, idempotence over every converted built-in schema, the dropped
annotations, __proto__, and the open-object refusal. Each was checked to
fail against the pre-fix behaviour.
AGENTS.md: corrected 77-of-82 (registry) vs 76-of-82 (payload, reply
overridden by its hand-tuned minLength schema), named the widened-enum
shape (18 properties) as the likeliest first 400 rather than the anyOf
branch (2), and documented the MCP coverage rules.
It also supersedes the note added one commit earlier that the drop is
per request "because carrying the strict-marked names from the request
builder to the parser is more plumbing than the case is worth": the
plumbing is one optional adapter method and one argument, and the case
is a live MCP tool.
…ng required nulls
Four defects found re-reviewing the branch, each reproduced before it
was fixed.
Tagged-tool providers. A `qwen-openai-compatible` link answers with
`<tool_call>` prose that we decode ourselves; no strict decoder is
involved, so nothing stops the model omitting an optional. But
`indexOfferedTools` read the strict payload's `required` literally —
and the rewrite puts EVERY property there — so `coerceArguments`
rejected every realistic tagged call for a missing parameter,
`parseSource` returned null, and the tool call collapsed into prose.
Turning the level on made tool calling strictly worse for that
operator, which is the outcome this design says it refuses. The
decoder now reads the strict spelling the way strict means it: a
listed property that admits `null` is optional. That costs one
presence check the tool's own validator makes again, and it holds on
whichever link the fallback chain serves, not just the configured one.
The null-drop was keyed per TOOL while the rewrite is per PROPERTY. An
argument that was already `required` is emitted byte-identical —
`z.string().nullable()` through the MCP SDK is `anyOf: [{string},
{null}]` listed in `required` — yet its null was still deleted because
some other property on the same tool got widened, handing that server
a call missing a required field. `strictToolNames` becomes
`strictWidenedArgs`: each rewritten function mapped to the arguments
whose optionality the rewrite actually erased. Both new tests fail
without the change.
The nesting bound did not reach the `anyOf` path, which recursed at
the caller's depth. A self-referential union still overflowed the
stack out of `descriptorsToOpenAiTools` — the exact failure the bound
was added for — and an arbitrarily deep union chain still converted.
Every emitted node is a spread of the node that came in, so an
allowlisted keyword the node's SHAPE has no rule for rode out
unconverted into a schema marked strict: `enum` on an object emitted
its raw sub-objects verbatim, and `items` on an object (or the object
keywords on an array, or any of them on a union) were the same bug
with different keys. Each shape now refuses its own strays, as
`convertScalar` always did.
Also: `modelWantsStrictTools` — the only path from the operator's
config to the wire — moves out of `bootstrap.ts` into
`model-strict-tools.ts` with direct tests, since reverting it left
every runtime test green; the nine `github-tool-args-schemas.ts`
entries join the pinned coverage sample (82 schemas, 77 convert, 76
emitted functions strict); and both directions of one inference share
a single build instead of converting all ~80 schemas twice.
Flag off is still byte-identical to the previous payload, checked
against origin/main's adapter.
OpenAI documents Structured Outputs as not compatible with parallel function calls — a parallel call generated under strict mode "may not match supplied schemas" — and says to send `parallel_tool_calls: false`. This branch marked every convertible tool `strict: true` and left the flag at its default `true`, so an operator who turned the level on still got best-effort adherence: the exact symptom the feature exists to cure. Found by the parallel work on PR #402, which fixed it at the body builder off its own provider flag. Implemented here off the emitted tools array instead, because strict is granted per tool: an adapter can ignore the option and a descriptor set can convert nothing, and neither should silently lose parallel calls for a request that is not constrained at all. `hasStrictFunctionTools` is the one predicate, read in two places that therefore cannot disagree — `buildLlmStreamParams` makes the decision, so the CompletionRequest is honest about it, and `buildOpenAiChatBody` is the floor under every other caller of the body builder. The executor's own `maxParallelToolCalls` batching is untouched: a model that emits several calls anyway is planned and run exactly as before.
76 of the 82 emitted functions carried `strict: true`. Three of the six refusals were over bounds the strict compiler ignores anyway — `reply` (`minLength: 1`), `vision.describe` (`maxItems`), `fusion.delegate` (`minItems`/`maxItems`/`minimum`) — so the refusal bought nothing: the tool came out unconstrained AND unbounded instead of constrained and unbounded, and `reply` is the one tool most worth constraining. So a value-range keyword is now stripped rather than refused over. It is safe for the reason `default-tool-args-schemas.ts` gives in its own header: these schemas guard shape, and every bound they carry is re-checked by the tool's own parser, which is what rejects a bad call today — checked one by one for the three recovered (the batch validator's non-empty `text` rule, `maxImagesPerCall`, `parseDelegateArgs`). `const` is deliberately not on the list: it pins a value the way a one-member `enum` does, so dropping it would widen what the tool accepts, and it stays a refusal. The fourth, `os.fs.archive.extract`, was refused over a nested `limits` that declares properties AND says `additionalProperties: true`. An object in that shape is now closed: the author wrote both halves, the declared keys are the whole documented contract, the model is shown nothing else, and `parseLimits` reads exactly those three keys. An ABSENT `additionalProperties` still refuses — same semantics, but it is what pydantic/FastMCP emit for every model, so refusing there keeps this rule to schemas somebody actually typed `true` into. A zero-property object refuses either way, so the open-object fallback is still never published as a zero-argument tool. Coverage measured over the real DEFAULT_TOOL_DESCRIPTORS: 80 of 82 emitted functions strict, refusing only `os.http.request` and `mcp.prompt.get`, where the map IS the payload. Both counts are now pinned — the registry pin alone would have missed `reply`, whose schema the adapter hand-writes. Nothing about the null-drop bookkeeping moves: `strictWidenedProperties` reads the original schema's `properties` and `required` and nothing else, and a bound says nothing about either. Pinned as its own test. The coverage idea came from the parallel work on PR #402.
Reverts 99faa2d. That commit was an optional coverage improvement — 76 to 80 of the 82 emitted functions — taken on the condition that it was low-risk. It was not. Stripping bounds made exactly two more built-ins convert, `fusion.delegate` and `os.fs.archive.extract`, and those two are the only built-ins whose strict schema contains a NESTED object (`tasks[]`, `limits`). `indexOfferedTools` — the fix two commits back that stops a strict payload's inflated `required` from rejecting every realistic `<tool_call>` — rewrites the TOP-LEVEL `required` and nothing else. So on a `qwen-openai-compatible` link with the level on, a `fusion.delegate` fan-out written the way a model actually writes one (no `deliverable`, no `files` on every task) failed the offered- tool check and the whole call collapsed back into prose. That is the precise failure the earlier commit exists to prevent, re-opened one level down. Teaching `indexOfferedTools` to recurse into nested objects and array items is the real fix and a reasonable change on its own. It is not this branch's change: this chain has now had a fixup introduce a new problem in four consecutive rounds, and trading a verified-sound converter for four more tools is not a trade worth making twice. Coverage returns to 77 of 82 registered schemas and 76 of 82 emitted functions, refusing `fusion.delegate`, `vision.describe`, `os.http.request`, `mcp.prompt.get`, `os.fs.archive.extract` and `reply`. `parallel_tool_calls: false` (fed385a) is untouched — it is a separate correctness port and nothing here implicates it. The premise both top-level walks rest on — no schema we convert has a nested object — is now pinned over the real emitted payload instead of being asserted in a comment, so the next attempt at this fails in the suite rather than on somebody's tagged link. `dropNullArgs`' header says whose premise it is and what a future nesting built-in owes both readers.
Drops the stripped-bounds and `canClose` paragraphs, restores the coverage and widened-enum counts to what the branch actually emits (77 of 82 registered / 76 of 82 emitted, 18 widened enums), and says plainly that nested nulls are left alone because nothing convertible nests, not because nesting is handled — the same premise the tagged- call narrowing walks on, now pinned. Adds the contract probe to "what this does not cover": `run-contract-probe.ts` builds its own body and sends one unmarked tool, so no preflight has ever put a `strict` function, a widened enum or the `parallel_tool_calls` floor in front of a real endpoint. That is where a first 400 would be cheapest to catch.
The strict-tool-schema section stated the premise the nested-null undo and the tagged-call narrowing both rest on as a universal: "no schema this converts contains a nested object". That is true of the built-ins, which is what the pin walks, and false of a third-party MCP inputSchema, which is exactly the class the allowlist was widened to accept. On a qwen-openai-compatible link such a schema converts, its nested required is inflated, indexOfferedTools narrows only the top level, and the tagged call collapses into prose - the same failure the narrowing exists to prevent, one level down. Scope both sentences to the built-ins, say which walk covers what, and name the MCP nesting case in "what this does not cover" alongside the fallback chain and the contract probe. Recursing indexOfferedTools and dropNullArgs together is the change that closes it, and is also what would let the tools refused over bounds be recovered safely. No code change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
Discord
#feedback-and-bugs,thegreatteacher, 2026-09-10 07:43 UTC — testing mercury 2.5 (Inception Labs) with atomic-agent: the model "makes a lot of tool calling mistakes without"strict": trueoption. Is it possible to set"strict": trueargument through config file or do you need to add provider specific patch?" The maintainer's answer was that a patch would be needed.The awkward part:
"strict"has been a documented, parseable value ofsupportsToolsall along —llm-provider.ts:35,model-resolver.ts:12,model-catalog-entry.ts:20,provider-types.ts:71, validated out of user config inllm-config.ts:486— and nothing consumed it.descriptorsToOpenAiToolsemitted{type:"function", function:{name, description, parameters}}and never astrictfield, so an operator who set the level got silence.extraBodyis not a workaround either: it merges at the top level of the request body and can never reach a per-toolfunction.strict.What this does
When the resolved model declares
supportsTools: "strict", the native-tools request marks function definitionsstrict: trueand hands them a strict-compatible schema. Opt-in, default-off: no wire change for anyone who does not set the level, and the flag-off payload is byte-for-byte whatorigin/mainemits.Strict decoding also turns parallel calls off. OpenAI documents Structured Outputs as not compatible with parallel function calls — a parallel call generated under strict mode "may not match supplied schemas" — and says to send
parallel_tool_calls: false. Marking every convertible toolstrict: trueand leaving that flag at its defaulttruebuys best-effort adherence, i.e. the exact symptom this feature exists to cure. This is the one thing the parallel PR #402 got right and this branch had missed outright; the reasoning and the fix are taken from there. Implemented here off the emitted tools array rather than off the config level, because strict is granted per tool: an adapter can ignore the option and a descriptor set can convert nothing, and neither should silently lose parallel calls for a request that is not constrained at all. One predicate,hasStrictFunctionTools, read in two places that therefore cannot disagree —buildLlmStreamParamsmakes the decision, so theCompletionRequestis honest about it, andbuildOpenAiChatBodyis the floor under every other caller of the body builder. The executor's ownmaxParallelToolCallsbatching is untouched: a model that emits several calls anyway is planned and run exactly as before.The hazard, and how it is engineered around. Strict schemas are much narrower than this repo's descriptors: every object must carry
additionalProperties: false, every property must appear inrequired(optionals as anullunion), and the bounds keywords are rejected outright. Getting that wrong is a 400 on every request once the flag is on — worse for the user than the bug being fixed. So the newstrict-tool-schema.tsconverts per tool, against a keyword allowlist, and refuses per tool;descriptorsToOpenAiToolsmarksstrictonly on what converted and leaves the rest byte-identical to today. A mixedtoolsarray is legal, and a partial win beats a request that cannot be served.Retracted this round: the coverage expansion. An earlier commit on this branch stripped value-range bounds instead of refusing over them, taking coverage from 76 to 80 of the 82 emitted functions by recovering
reply,vision.describe,fusion.delegateandos.fs.archive.extract. It has been reverted, and coverage is back to 76.The reason is specific. Those last two are the only built-ins whose strict schema contains a nested object (
fusion.delegate.tasks[],os.fs.archive.extract.limits), andindexOfferedTools— the fix described under tagged-tool providers below — narrows the top-levelrequiredand nothing else. So with both converted, a realistic<tool_call>for either one (afusion.delegatefan-out that does not filldeliverableandfileson every task, say) failed the offered-tool check and the whole call collapsed back into prose on aqwen-openai-compatiblelink — where it worked before the expansion. That is the exact failure the tagged fix exists to prevent, re-opened one level down.Teaching
indexOfferedToolsto recurse into nested objects and array items is a real fix and a reasonable future change. It is deliberately not this branch's change. A fixup on this chain has introduced a new problem in four consecutive review rounds, and trading a converter that four rounds have now signed off for four more tools is not a trade worth making again. The branch is back to refuse-on-unsupported-keyword, which is the shape that was verified.What survives from #402 is the other port,
parallel_tool_calls: falseabove — that one is a correctness fix and is not implicated in any of this.Coverage, measured here over the real
DEFAULT_TOOL_DESCRIPTORSrather than a sample: 76 of the 82 emitted functions carrystrict: true(77 of the 82 registered schemas convert; the emitted figure is one lower becausereply's hand-tunedminLength: 1schema in the adapter, not its registry schema, is the one that ships). The six refusals:os.http.requestheadersis a typed open map;bodymay be any objectmcp.prompt.getargumentsis a map of arbitrary string keysos.fs.archive.extractlimitsthat declares properties and saysadditionalProperties: truefusion.delegateminItems/maxItems/minimumon the task listvision.describemaxItemsonpathsreplyminLength: 1ontext{properties:{}, additionalProperties:true}fallback has no strict form short of making the tool zero-argumentIn the first two the map is the payload, so closing it would delete the tool's arguments. The other four are the bounds-and-open-object refusals restored by the revert above.
Nesting is bounded separately, at five levels. The allowlist bounds what a node may say; nothing bounded how many of them there are. Object properties, array
itemsandanyOfbranches all count as a level. Built-in descriptors reach two (os.shell.run), so only a third-partyinputSchemagets near it — and a schema past a strict compiler's ceiling is rejected with the whole request, every other tool's definition with it, which is the one outcome the per-tool refusal exists to avoid. The same bound answers a self-referential schema — throughproperties,itemsoranyOfalike — with a refusal instead of aRangeErrorescapingdescriptorsToOpenAiTools.The keyword allowlist is the safety property, per node shape. Every emitted node is a spread of the node that came in, so an allowlisted keyword the node's shape has no rule for would ride out unconverted inside a schema we then mark strict —
enumon an object node emitting its raw sub-objects verbatim is the sharp case;itemson an object, or the object keywords on an array or a union, are the same bug with different keys. Each shape refuses its own strays.Third-party MCP schemas. The converter has to survive whatever an MCP server ships, and be worth something there rather than just not crashing. All three spellings of nullable are accepted and never widened twice —
type: ["string","null"], ananyOfwith a{type:"null"}branch, and a bare{type:"null"}— which makes the conversion idempotent (feed its own output back in, nothing changes) and makes a pydantic/FastMCPOptional[str]convert.default,$schemaand$commentare accepted and dropped: a strict decode has no absent key for a default to fill, and the key is deleted again on the way in, so the server applies its own default exactly as today. Still refused, deliberately:$defs/$ref(a pydantic model nested in another one — resolving references faithfully is a separate change), any bound orformat, and an object with noadditionalPropertiesat all. Absent means open in JSON Schema; closing it would forbid arguments the server accepts today, and would publish a zero-property MCP tool as a zero-argument tool. Our own descriptors spelladditionalProperties: falseout on every object, so requiring it costs the built-ins nothing.The one non-cosmetic rewrite is optionality, and its undo is per argument. An optional property is unioned with
nulland moved intorequired, so the model now answers"cwd": nullwhere it used to omit the key. That is not harmless:memory.profile.set(pinned,keywords),memory.notes.recall(id) andos.git.init(userName/userEmail) branch onrawArgs.x !== undefinedand would take a branch a literalnulldoes not mean. SoopenAiToolCallsToBatchdrops top-level nulls again — but only for the arguments the converter actually moved, via thestrictWidenedArgsmap (escaped function name → the argument names whose optionality the rewrite erased) that the adapter reports for the same descriptors.Two kinds of
nulltherefore survive, and both have to:required. The converter only widens what it moves, so such an argument is also emitted byte-identical — and if it is also nullable (z.string().nullable()through the official MCP SDK:anyOf: [{string},{null}], listed inrequired) the model means the null literally, and deleting the key would hand that server a call missing a required field.Nested nulls are left alone in every case — a
nulldeeper inside an argument is data the model meant to send. That is safe because of a premise, not because nesting is handled: no schema this converts contains a nested object. The two built-ins that nest are both refused (see the table above). The premise is load-bearing twice — the tagged-call narrowing below walks the top level too — so it is now pinned directly over the real emitted payload rather than asserted in a comment, and the pin fails if either nesting tool is ever converted.Tagged-tool providers read the strict payload differently. A
qwen-openai-compatiblelink answers with<tool_call>prose that we parse ourselves; no strict decoder is involved, so nothing stops the model omitting an optional. Read literally, the strict payload's inflatedrequiredmadecoerceArgumentsreject every realistic tagged call for a missing parameter, and the whole tool call collapsed into text — turning the level on would have made tool calling strictly worse there.indexOfferedToolsnow reads astrict: truefunction'srequiredthe way strict means it: a listed property that admitsnullis optional. That costs only a presence check the tool's own validator makes again, and it holds on whichever link the fallback chain serves. It rewrites the top-levelrequiredonly, which is as far as it needs to reach while nothing convertible nests an object — the premise pinned above. A built-in that nested one would have to teachindexOfferedToolsto recurse in the same change; that is what the retraction at the top of this description is about.Plumbing mirrors
supportsParallelToolsexactly:modelWantsStrictTools(its own module, per-model rather than a provider capability, re-read per inference so a hot-swapped model is seen immediately) →AgentLoopDependencies.strictTools/ResolvedTurnLlmSlice→StepDependencies.strictTools→ both directions of the adapter, the incoming one keyed by what the outgoing one actually changed. Both directions share one build of the function definitions rather than converting every registered schema twice per inference.Test evidence
npx vitest run src/llm src/config src/agent→ 113 files, 1661 tests, all pass (1653 before this round; the 8 net new ones are listed below)npx vitest run src/llm/provider/openai→ 14 files, 193 tests, all pass (188 before this round). The previous revision of this description said 193 while the head at the time emitted 197; that head has been reverted and 193 is the measured figure now.npm run lint(tsc --noEmit) → cleannullwhile the widened optional beside it is dropped; a refused tool's deliberatebody: nullsurvives untouched; a<tool_call>tagged response that omits optionals still parses against the strict payload; a 12-deep and a self-referential schema are both refused rather than emitted or blowing the stack; and the flag off still emits nostrictmarking anywhere.descriptorsToOpenAiToolson the branch and onorigin/mainproduce identical JSON over the built-in schemas, with the option absent,{}, and{strict:false}.openai-tool-call-adapter.tsand re-running its suite fails exactly the behavioural cases while the two control cases ("changes nothing when the option is off", "leaves nested nulls alone") keep passing. Each individual fix was re-checked the same way by mutating it back: batch-level null drop → the adapter's required-nullable case and the step-executor wiring case both fail; per-tool (rather than per-argument) keying → 2 failures; reading the tagged payload'srequiredliterally → the qwen strict case fails; absentadditionalPropertiestreated as closed → 4;out[name] = ...for__proto__→ 1; no type-array support → 4; no annotation stripping → 2; non-idempotentnullable()→ 2.New this round, net of the revert: four
parallel_tool_callscases inopenai-build-body.test.ts, three instep-executor.test.ts(including the end-to-end one that runs the captured step params throughbuildOpenAiChatBodyand readsparallel_tool_callsoff the wire body), and one pin inopenai-tool-call-adapter.test.ts— "emits no nested object inside a function it marked strict" — which walks every emitted strict schema throughproperties,itemsandanyOf. That pin is the control for the retraction: restoring the reverted converter makes it fail with the two nested objects named above, so a future attempt at the same coverage fails in the suite rather than on somebody's tagged link.Existing coverage:
strict-tool-schema.test.ts(the promotions, enum widening, union widening, recursion, non-mutation, the three nullable spellings, idempotence over every convertible built-in schema, dropped annotations,__proto__, which properties the rewrite widened, and the refusals — bounds, map objects,$ref,$defs,oneOf,anyOfbeside a siblingtype, open objects, per-shape stray keywords, over-deep and cyclic schemas through both thepropertiesand theanyOfspelling, plus a pinned refusal list and count over all 82 registered schemas); strict cases inopenai-tool-call-adapter.test.ts(flag-off byte identity, arbitrary MCPinputSchemas refused rather than blowing up, a pydantic-shaped one converting, a refused tool keeping its nulls, and a converted tool keeping the null of an argument that was already required);model-strict-tools.test.tsfor the config leg; a strict block inqwen-tagged-tool-response-adapter.test.ts; and end-to-end cases instep-executor.test.tsthat run a native-tools step with the level on and check every half of the contract on the real wiring.What this does not cover
type: ["string","null"]withnullappended toenum, on 18 emitted properties (os.fs.list.kind,os.fs.hash.algorithm,github.pr.list.state, ...) against 2 for theanyOf+{type:"null"}branch. If a provider rejects it, the fix is to tighten the converter (refuse an optional enum outright) — not to loosen anything. The one preflight that could have caught it cannot:run-contract-probe.tsbuilds its own request body and sends a single unmarked tool, bypassingbuildOpenAiChatBody, so no probe has ever put astrictfunction, a widened enum or theparallel_tool_callsfloor in front of a real endpoint. Routing the probe through the body builder is where a first 400 would be cheapest to find, and it is the obvious next change — out of scope here.supportsParallelToolsdoes, so a cross-provider fallover ships the strict-marked payload to a link whose model never declared the level. It is inert on a provider that ignoresstrict, and the tagged-decoder path above is handled wherever the payload lands, but a provider that rejects the field would 400 the fallover. Re-resolving per link means rebuildingtoolsinsidellm-fallback-seam.ts, which has neither the descriptors nor the adapter — the same gapsupportsParallelToolshas today, and a maintainer call to fix for both at once.$defs/$refMCP schemas do not convert. A pydantic model nested inside another one is the common shape that still ships non-strict.$refargument, or a bound, silently joins the refusals. The pinned test instrict-tool-schema.test.tsiteratesDEFAULT_TOOL_NAMES, a hand-written literal array of the 82 names (DEFAULT_TOOL_ARGS_SCHEMASis not exported, so it cannot be reflected) — so it catches a bound added to one of those 82, or one of them disappearing, and it does not catch a brand-new tool, which is the case that sentence used to claim. Verified: addingminLengthtofinish.textfails the pin; adding a whole new entry leaves the suite green. Coverage can therefore drift down in two directions without a red test — the other beingreply's hand-written adapter schema, since the emitted-side count pin went out with the revert. It degrades gracefully in both (the tool just ships non-strict), but it is not the guarantee the old wording promised.indexOfferedToolsrecursion first — not the strip on its own.nulland reach the server that way, since the null-drop is top-level only — and on a tagged link the same nesting is outside whatindexOfferedToolsnarrows. Both walks would need the same recursion.toolsentirely.On PR #402
For whoever is choosing between the two branches: #402 reaches its higher coverage — 80 of 82, the figure this branch has just given up — by exactly the strip-don't-refuse rule retracted above. Its
openai-strict-tools.tsstripsminItems,maxItems,minimum,minLength,maxLength,pattern,format,default,examples,uniqueItemsandconst, which is what makesfusion.delegateandos.fs.archive.extractconvert there too. #402 also enables the flag onqwen-openai-compatible(one of its five provider kinds) and does not touchqwen-tagged-tool-response-adapter.tsat all — it has no equivalent of theindexOfferedToolsnarrowing, at any level. Verified read-only against #402's diff at0fd12f11(14 changed files); nothing on that PR or its branch was touched.