Skip to content

v0.43.0 regression: hoisted read/write/edit emit non-compliant anyOf schema (no root type:"object") → 400 on strict providers, empty {} args on GLM #151

Description

@grodingo

Summary

Since v0.43.0, the hoisted read / write / edit tools expose a non-compliant JSON Schema: a top-level anyOf with no root type: "object". OpenAI-compatible function-calling requires parameters to be { "type": "object", ... }, so:

  • Strict providers reject the request entirely (DeepSeek → HTTP 400).
  • Lenient models accept it but emit empty {} arguments for these three tools (GLM-5.2).

bash, grep, and AFT's own tools (aft_search / aft_zoom / aft_outline / aft_inspect) are unaffected because they use flat type: "object" schemas.

Regression window

Introduced in v0.43.0 as the fix for #148 ("Read tool fails because of wrong argument name passed" — GLM passing filePath instead of `path"). Still present in v0.45.0 (latest as of filing). Because AFT auto-updates, this broke read/write/edit between sessions for anyone on v0.42.0 → v0.43.0+.

The v0.43.0 changelog says:

the Pi read, write, and edit tools now accept filePath as an alias for path (and the reverse)

That aliasing is implemented as a TypeBox.Union of two object shapes (one where path is required, one where filePath is required), which serializes to a bare anyOf.

Reproduction

Two models, two failure modes — same root cause.

1. Strict provider (DeepSeek, OpenAI-compatible) → 400

Switch the session model to deepseek-v4-flash and trigger any of read/write/edit. The provider rejects the whole request before the model runs:

Error: 400: {"message":"Invalid schema for function 'read': schema must be a JSON Schema of 'type: \"object\"', got 'type: null'.","type":"invalid_request_error","param":null,"code":"invalid_request_error"}

"got 'type: null'" = the schema object has no type field at all (the anyOf sits at the root).

2. Lenient model (GLM-5.2) → empty {} arguments

On glm-5.2, every read/write/edit invocation comes back with empty args (the model cannot resolve which branch of the anyOf to populate):

Validation failed for tool "write":
  - filePath: must have required properties filePath, content
  - path: must have required properties path, content
  - root: must match a schema in anyOf
Received arguments:
{}

Notably the #148 fix was filed specifically because of GLM, yet the resulting anyOf schema makes GLM unable to call these tools at all — strictly worse than the original symptom.

Control cases (same session, same model)

  • bash (flat type: object schema): ✅ works on both models
  • AFT's own aft_search/aft_zoom/aft_outline (own flat schemas): ✅ work on both models
  • Disabling @cortexkit/aft-pi (restores Pi's native read/write/edit): ✅ all three work again

Root cause

dist/index.js:

// line 32448
var ReadParams = Type2.Union([
  Type2.Object({ path: Type2.String({...}), filePath: Type2.Optional(...), offset, limit }),
  Type2.Object({ path: Type2.Optional(...), filePath: Type2.String({...}), offset, limit }),
]);
// line 32470
var WriteParams  = Type2.Union([ /* path-required | filePath-required */ ]);
// line 32490
var EditParams   = Type2.Union([ /* path-required | filePath-required */ ]);
// line 32522
var GrepParams   = Type2.Object({ pattern, path, include, caseSensitive });  // ← flat, works

TypeBox.Union([...]) serializes to { "anyOf": [ {type:"object",...}, {type:"object",...} ] } with no root type: "object". Pi passes parameters straight through to the provider as tool.function.parameters, so the wire schema is non-compliant.

The runtime alias resolution that #148 actually needs already exists and does not require a schema union:

// line 9625
function coerceAliasedStringParam(value, aliasValue) { ... }
// line 32530 / 32533
function readPathArg(args)        { return coerceAliasedStringParam(args.path, args.filePath); }
function mutationFilePathArg(args){ return coerceAliasedStringParam(args.filePath, args.path); }

So the Union is redundant: the coercion layer already accepts either name. GrepParams (flat Type.Object) proves the working pattern.

Suggested fix

Drop the Union and model both names as optional in a single flat Type.Object, then enforce "at least one present" at runtime (the coercion functions above already do the right thing). This restores a compliant { "type": "object", "properties": {...} } schema while keeping the path/filePath alias behavior from #148:

var ReadParams = Type2.Object({
  path:     Type2.Optional(Type2.String({ description: "Path to the file (absolute or relative to project root)" })),
  filePath: Type2.Optional(Type2.String({ description: "Compatibility alias for `path`." })),
  offset:   optionalInt(1, Number.MAX_SAFE_INTEGER),
  limit:    optionalInt(1, Number.MAX_SAFE_INTEGER),
});
// runtime: throw if readPathArg(args) is undefined (already the case today)

Same shape for WriteParams / EditParams. GrepParams is already correct and can serve as the template.

(Alternative if schema-level "exactly-one-of" is desired: keep it out of the top-level schema — embed the constraint inside properties — but the top-level object must always be type: "object".)

Workaround

  • Pin @cortexkit/aft-pi to 0.42.0, or
  • Disable the package (loses aft_* tools + the Rust reader until fixed).

Environment

  • @cortexkit/aft-pi: 0.45.0 (latest; regression started 0.43.0)
  • Pi: 0.80.3
  • Providers reproducing: DeepSeek (deepseek-v4-flash, strict), ZAI (glm-5.2, lenient)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions