fix(ai): keep tool schemas rooted in an object and stop retrying request-shape rejections - #718
Merged
code-yeongyu merged 5 commits intoAug 5, 2026
Conversation
A root schema carrying anyOf/oneOf/allOf had its `type` hoisted into the
branches and deleted, so a tool whose parameters are a root union went to
the wire with no root type. OpenAI-compatible gateways reject that with
`tools.function.parameters.type is required and must be "object"`, which
killed an Apitopia/Kimi turn on 2026-08-04: every retry and every fallback
model resent the same invalid payload.
Normalization now tracks the root, hoists types only below it, and merges a
root object union into one object schema. That merge also keeps the root's
own properties and required entries, which were previously dropped entirely
-- a root union declaring its properties at the root normalized to
`{"properties":{},"type":"object"}` and silently shipped a tool with no
parameters at all.
Gateways wrap deterministic request-shape rejections in 5xx envelopes, e.g. `500 server_error: Invalid request: tools.function.parameters...`. Matching on status text alone classified them as transient, so the identical payload was replayed on the identical model until the retry budget was spent, and the model fallback that followed inherited the same doomed payload. These failures are permanent for the bytes we sent: no attempt and no fallback model can accept them. Classifying them non-retryable routes them straight to the hard-error path, which switches models immediately instead of burning same-model retries. The patterns are anchored on the tools./functions. request path so unrelated prose stays retryable, and the constant is renamed since it no longer covers only limit errors.
…t type
The root-object guarantee forced `type: "object"` onto any root that lacked a
type, including a root union whose branches are all scalars. That produced a
schema asserting `type: "object"` beside `anyOf: [{string}, {number}]`, which
contradicts itself and is worse than the missing keyword.
Only merge a root union when every branch is an object shape, and only restore
the type on a root that has no combiner to describe it. Tool parameters are
objects in practice, so this just stops an exotic schema from being corrupted.
`convertTools` built `input_schema` from top-level `properties`/`required`
only. A tool whose parameters are a root union carries neither, so it reached
Claude as `{"properties":{},"required":[]}` -- the model was told the tool takes
no arguments.
senpi's own `monitorSchema` was flattened in July to dodge exactly this, but
plugin and MCP tools ship root unions and we cannot flatten a schema we do not
own, so the conversion has to resolve them. It now shares the root-object
resolution with the OpenAI-compatible path; plain object schemas are untouched.
code-yeongyu
deleted the
fix/tool-schema-root-type-and-hard-error-classification
branch
August 5, 2026 00:10
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.
Problem
A turn died with a provider error that no retry and no fallback could rescue:
Session forensics (
~/.senpi/agent/sessions/.../2026-08-04T22-33-45-197Z_*.jsonl) show everyattempt staying on the same model, then
Fallback chain exhausted. A later report shows theother half of the same failure: fallback DID switch models, landed on a model that hit the
identical error, and then reported
Retrying (2/3)against a payload that could never be accepted.There are two independent defects behind that, and both are fixed here.
1. We were building an invalid tool schema
normalizeToolParametersForOpenAICompathoisted a schema'stypeinto its combiner branches anddeleted the parent. That is correct below the root, but a tool's ROOT
parametersmust stay anobject schema. A tool whose parameters are a root union (
type: "object"+anyOf) therefore wentto the wire with no root
type— byte-for-byte the error above.The Moonshot path then made it worse.
mergeRootObjectUnionmerged only the BRANCH properties anddropped the root's own, so a root union declaring its properties at the root normalized to:
{"properties": {}, "type": "object"}A tool advertised to the model with zero parameters, silently.
2. A permanent failure was classified as transient
The gateway wraps this deterministic rejection in a 5xx envelope, so
isRetryableErrorMessagematched
500/server_errorand called it transient. The identical payload was then replayed onthe identical model until the budget was spent — and the fallback that followed inherited the same
doomed bytes.
3. The same root-union schemas reached Anthropic with no parameters at all
convertToolsinanthropic-messages.tsbuildsinput_schemafrom top-levelproperties/requiredonly. A tool whose parameters are a root union carries neither, so itarrived as:
{"type": "object", "properties": {}, "required": []}Claude was told the tool takes no arguments. senpi's own
monitorSchemawas flattened in July tododge exactly this (
packages/coding-agent/.../terminal/changes.md, 2026-07-27), but plugin andMCP tools ship root unions and we cannot flatten a schema we do not own — so the conversion itself
has to resolve them. It now shares the root-object resolution with the OpenAI-compatible path.
This matters for the reported failure specifically:
anthropic/claude-opus-5is the model thatfell back to Kimi, so the fallback target was silently degraded by the same root defect.
Fix
valid
{"type": "object", ...}schema.propertiesandrequiredalongside the branches'.requiredkeeps root entries plus only names every branchshares, so the merged schema never rejects a payload the union accepts.
the Anthropic conversion reuses the same root resolution so no provider silently drops parameters.
tools./functions.request path so unrelated prose stays retryable. They now take the hard-error path, which
switches models immediately rather than burning same-model retries.
Evidence
Every criterion went RED before the fix and GREEN after; the two tests written after their fix were
mutation-proved by reverting the classifier.
typekey absenttype === "object"{"properties":{}}"type": "object"+ full propertiestrue(retryable)false{"properties":{},"required":[]}requiredMutation proof for the agent-level tests: reverting the classifier returns
callCount 2and therun takes 30s because it hits the real backoff — the wasted wait this removes.
packages/ai: 1778 passed, 0 failed.npm run check: clean.local-ignore/qa-evidence/20260805-tool-schema-root-type/.Notes
Cannot find module .../pi-ai/dist/api/anthropic-messages.jswas investigated and isnot a source defect: that path no longer exists, and the live install (2026.8.4-2) ships both
anthropic-messages.jsand its.lazy.js. Left unchanged rather than inventing a fix.asserts the merged root object, and a sibling test keeps below-root hoisting covered.