Skip to content

fix(ai): keep tool schemas rooted in an object and stop retrying request-shape rejections - #718

Merged
code-yeongyu merged 5 commits into
mainfrom
fix/tool-schema-root-type-and-hard-error-classification
Aug 5, 2026
Merged

fix(ai): keep tool schemas rooted in an object and stop retrying request-shape rejections#718
code-yeongyu merged 5 commits into
mainfrom
fix/tool-schema-root-type-and-hard-error-classification

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Problem

A turn died with a provider error that no retry and no fallback could rescue:

500 server_error: Invalid request: tools.function.parameters.type is required and must be "object"

Session forensics (~/.senpi/agent/sessions/.../2026-08-04T22-33-45-197Z_*.jsonl) show every
attempt staying on the same model, then Fallback chain exhausted. A later report shows the
other 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

normalizeToolParametersForOpenAICompat hoisted a schema's type into its combiner branches and
deleted the parent. That is correct below the root, but a tool's ROOT parameters must stay an
object schema. A tool whose parameters are a root union (type: "object" + anyOf) therefore went
to the wire with no root type — byte-for-byte the error above.

The Moonshot path then made it worse. mergeRootObjectUnion merged only the BRANCH properties and
dropped 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 isRetryableErrorMessage
matched 500 / server_error and called it transient. The identical payload was then replayed on
the 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

convertTools in anthropic-messages.ts builds input_schema from top-level
properties/required only. A tool whose parameters are a root union carries neither, so it
arrived as:

{"type": "object", "properties": {}, "required": []}

Claude was told the tool takes no arguments. senpi's own monitorSchema was flattened in July to
dodge exactly this (packages/coding-agent/.../terminal/changes.md, 2026-07-27), but plugin and
MCP 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-5 is the model that
fell back to Kimi, so the fallback target was silently degraded by the same root defect.

Fix

  • Normalization tracks the root: types are hoisted only below it, and the emitted root is always a
    valid {"type": "object", ...} schema.
  • A root object union merges into ONE object schema that keeps the root's own properties and
    required alongside the branches'. required keeps root entries plus only names every branch
    shares, so the merged schema never rejects a payload the union accepts.
  • Both flavors share that one root guarantee instead of Moonshot carrying a divergent root-merge, and
    the Anthropic conversion reuses the same root resolution so no provider silently drops parameters.
  • Request-shape rejections are classified non-retryable, anchored on the 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.

Proof Before After
Root type preserved (compat + moonshot) type key absent type === "object"
Root properties preserved {"properties":{}} all 3 properties intact
Real request builder wire payload typeless root "type": "object" + full properties
Classifier on the exact session error true (retryable) false
Agent turn on a schema rejection 2 calls, same model 1 call, then immediate switch
Root-union tool sent to Anthropic {"properties":{},"required":[]} all 3 properties, correct required

Mutation proof for the agent-level tests: reverting the classifier returns callCount 2 and the
run takes 30s because it hits the real backoff — the wasted wait this removes.

  • packages/ai: 1778 passed, 0 failed.
  • retry-fallback suite (7 files): 51 passed.
  • npm run check: clean.
  • Wire-payload artifacts: local-ignore/qa-evidence/20260805-tool-schema-root-type/.

Notes

  • The reported Cannot find module .../pi-ai/dist/api/anthropic-messages.js was investigated and is
    not a source defect: that path no longer exists, and the live install (2026.8.4-2) ships both
    anthropic-messages.js and its .lazy.js. Left unchanged rather than inventing a fix.
  • One existing test asserted the old branches-only root shape. It pinned the defect, so it now
    asserts the merged root object, and a sibling test keeps below-root hoisting covered.

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
code-yeongyu merged commit 28fd075 into main Aug 5, 2026
17 checks passed
@code-yeongyu
code-yeongyu deleted the fix/tool-schema-root-type-and-hard-error-classification branch August 5, 2026 00:10
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