Skip to content

feat(llm): honour supportsTools: "strict" on the OpenAI tools payload - #395

Merged
plombeer31 merged 10 commits into
mainfrom
feat/strict-tool-schemas
Sep 11, 2026
Merged

feat(llm): honour supportsTools: "strict" on the OpenAI tools payload#395
plombeer31 merged 10 commits into
mainfrom
feat/strict-tool-schemas

Conversation

@plombeer31

@plombeer31 plombeer31 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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": true option. Is it possible to set "strict": true argument 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 of supportsTools all along — llm-provider.ts:35, model-resolver.ts:12, model-catalog-entry.ts:20, provider-types.ts:71, validated out of user config in llm-config.ts:486 — and nothing consumed it. descriptorsToOpenAiTools emitted {type:"function", function:{name, description, parameters}} and never a strict field, so an operator who set the level got silence. extraBody is not a workaround either: it merges at the top level of the request body and can never reach a per-tool function.strict.

What this does

When the resolved model declares supportsTools: "strict", the native-tools request marks function definitions strict: true and 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 what origin/main emits.

"llm": { "providers": [{ "id": "inception", "kind": "openai-compatible",
  "userModels": [{ "id": "mercury-2.5", "kind": "chat", "supportsTools": "strict" }] }] }

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 tool strict: true and leaving that flag at its default true buys 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 — 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.

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 in required (optionals as a null union), 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 new strict-tool-schema.ts converts per tool, against a keyword allowlist, and refuses per tool; descriptorsToOpenAiTools marks strict only on what converted and leaves the rest byte-identical to today. A mixed tools array 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.delegate and os.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), and indexOfferedTools — the fix described under tagged-tool providers below — narrows the top-level required and nothing else. So with both converted, a realistic <tool_call> for either one (a fusion.delegate fan-out that does not fill deliverable and files on every task, say) failed the offered-tool check and the whole call collapsed back into prose on a qwen-openai-compatible link — where it worked before the expansion. That is the exact failure the tagged fix exists to prevent, re-opened one level down.

Teaching indexOfferedTools to 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: false above — that one is a correctness fix and is not implicated in any of this.

Coverage, measured here over the real DEFAULT_TOOL_DESCRIPTORS rather than a sample: 76 of the 82 emitted functions carry strict: true (77 of the 82 registered schemas convert; the emitted figure is one lower because reply's hand-tuned minLength: 1 schema in the adapter, not its registry schema, is the one that ships). The six refusals:

tool why
os.http.request headers is a typed open map; body may be any object
mcp.prompt.get arguments is a map of arbitrary string keys
os.fs.archive.extract a nested limits that declares properties and says additionalProperties: true
fusion.delegate minItems/maxItems/minimum on the task list
vision.describe maxItems on paths
reply minLength: 1 on text
any descriptor with no schema the {properties:{}, additionalProperties:true} fallback has no strict form short of making the tool zero-argument

In 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 items and anyOf branches all count as a level. Built-in descriptors reach two (os.shell.run), so only a third-party inputSchema gets 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 — through properties, items or anyOf alike — with a refusal instead of a RangeError escaping descriptorsToOpenAiTools.

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 — enum on an object node emitting its raw sub-objects verbatim is the sharp case; items on 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"], an anyOf with 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/FastMCP Optional[str] convert. default, $schema and $comment are 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 or format, and an object with no additionalProperties at 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 spell additionalProperties: false out 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 null and moved into required, so the model now answers "cwd": null where it used to omit the key. That is not harmless: memory.profile.set (pinned, keywords), memory.notes.recall (id) and os.git.init (userName/userEmail) branch on rawArgs.x !== undefined and would take a branch a literal null does not mean. So openAiToolCallsToBatch drops top-level nulls again — but only for the arguments the converter actually moved, via the strictWidenedArgs map (escaped function name → the argument names whose optionality the rewrite erased) that the adapter reports for the same descriptors.

Two kinds of null therefore survive, and both have to:

  • every argument of a tool whose schema was refused. It went out byte-identical to the flag-off payload, so its nulls are the model answering the tool's own schema.
  • an argument of a tool that did convert but was already 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 in required) 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 null deeper 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-compatible link 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 inflated required made coerceArguments reject 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. indexOfferedTools now reads a strict: true function's required the way strict means it: a listed property that admits null is 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-level required only, 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 teach indexOfferedTools to recurse in the same change; that is what the retraction at the top of this description is about.

Plumbing mirrors supportsParallelTools exactly: 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 / ResolvedTurnLlmSliceStepDependencies.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/agent113 files, 1661 tests, all pass (1653 before this round; the 8 net new ones are listed below)
  • npx vitest run src/llm/provider/openai14 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) → clean
  • The three earlier rounds' scenarios re-run against the new head, not just the new tests: a required-nullable argument of a converted tool keeps its null while the widened optional beside it is dropped; a refused tool's deliberate body: null survives 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 no strict marking anywhere.
  • Flag-off byte identity is checked, not asserted. descriptorsToOpenAiTools on the branch and on origin/main produce identical JSON over the built-in schemas, with the option absent, {}, and {strict:false}.
  • Checked the tests fail without the src change. Reverting only openai-tool-call-adapter.ts and 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's required literally → the qwen strict case fails; absent additionalProperties treated as closed → 4; out[name] = ... for __proto__ → 1; no type-array support → 4; no annotation stripping → 2; non-idempotent nullable() → 2.

New this round, net of the revert: four parallel_tool_calls cases in openai-build-body.test.ts, three in step-executor.test.ts (including the end-to-end one that runs the captured step params through buildOpenAiChatBody and reads parallel_tool_calls off the wire body), and one pin in openai-tool-call-adapter.test.ts — "emits no nested object inside a function it marked strict" — which walks every emitted strict schema through properties, items and anyOf. 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, anyOf beside a sibling type, open objects, per-shape stray keywords, over-deep and cyclic schemas through both the properties and the anyOf spelling, plus a pinned refusal list and count over all 82 registered schemas); strict cases in openai-tool-call-adapter.test.ts (flag-off byte identity, arbitrary MCP inputSchemas 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.ts for the config leg; a strict block in qwen-tagged-tool-response-adapter.test.ts; and end-to-end cases in step-executor.test.ts that 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

  • Untested against a live strict endpoint. Every assertion here is about the payload we emit; nobody has run it against mercury-2.5, or against OpenAI itself. The reporter is the natural first tester. The shape most likely to draw a provider's first 400 is the widened enum — type: ["string","null"] with null appended to enum, on 18 emitted properties (os.fs.list.kind, os.fs.hash.algorithm, github.pr.list.state, ...) against 2 for the anyOf + {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.ts builds its own request body and sends a single unmarked tool, bypassing buildOpenAiChatBody, so no probe has ever put a strict function, a widened enum or the parallel_tool_calls floor 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.
  • The strict decision is resolved before the fallback chain picks a link. It comes from the active (or pinned) provider's model, exactly as supportsParallelTools does, 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 ignores strict, 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 rebuilding tools inside llm-fallback-seam.ts, which has neither the descriptors nor the adapter — the same gap supportsParallelTools has today, and a maintainer call to fix for both at once.
  • A genuinely-required nullable argument is optional to the tagged decoder. Strict cannot distinguish "required and nullable" from "optional", so the qwen-tagged narrowing above treats both as optional. That is the lenient direction: worst case a tagged call missing a required argument reaches the tool, whose own validator rejects it — against the alternative, which was every tagged call disappearing into prose.
  • $defs/$ref MCP schemas do not convert. A pydantic model nested inside another one is the common shape that still ships non-strict.
  • The refusals are not a fixed list, and the pin over them is narrower than it looks. A new default tool with a map-shaped or $ref argument, or a bound, silently joins the refusals. The pinned test in strict-tool-schema.test.ts iterates DEFAULT_TOOL_NAMES, a hand-written literal array of the 82 names (DEFAULT_TOOL_ARGS_SCHEMAS is 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: adding minLength to finish.text fails 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 being reply'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.
  • A bound costs the tool strict mode. Four built-ins are refused over bounds the strict compiler would have ignored anyway, so they ship unconstrained and unbounded. Recovering them is what the retraction above is about, and doing it safely means the indexOfferedTools recursion first — not the strip on its own.
  • Nested optionals in third-party MCP schemas. No built-in schema that converts has a nested object (pinned), but a third-party MCP schema can. If one converts and nests, its inner optionals arrive as explicit null and reach the server that way, since the null-drop is top-level only — and on a tagged link the same nesting is outside what indexOfferedTools narrows. Both walks would need the same recursion.
  • No TUI surface. The level is set by hand in the config file; the Providers tab does not offer it.
  • Nothing here touches the grammar/local path — llama-server ignores tools entirely.

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.ts strips minItems, maxItems, minimum, minLength, maxLength, pattern, format, default, examples, uniqueItems and const, which is what makes fusion.delegate and os.fs.archive.extract convert there too. #402 also enables the flag on qwen-openai-compatible (one of its five provider kinds) and does not touch qwen-tagged-tool-response-adapter.ts at all — it has no equivalent of the indexOfferedTools narrowing, at any level. Verified read-only against #402's diff at 0fd12f11 (14 changed files); nothing on that PR or its branch was touched.

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.
@plombeer31
plombeer31 merged commit 82947f5 into main Sep 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant