fix(cli): also reject root anyOf/oneOf schemas whose branches can't accept objects

Addresses a review follow-up: the previous root-object check only inspected
the top-level `type` keyword, so a schema like
`{"anyOf":[{"type":"array"},{"type":"string"}]}` slipped through even though
none of its branches can ever validate the object-shaped arguments that
function-calling APIs send.

Replace the single `type` check with `schemaRootAcceptsObject`, which
recursively walks root-level anyOf/oneOf branches and requires at least one
to accept objects. Absent `type`, `type: "object"`, `type: ["object", ...]`,
and mixed anyOf branches where one accepts object all still pass. `allOf`
is left to Ajv's runtime behaviour — guessing intent across contradictory
allOf branches at parse time is fragile.
This commit is contained in:
wenshao 2026-04-25 10:20:32 +08:00
parent 868408dbf6
commit 2f6b406b9d
2 changed files with 76 additions and 14 deletions

View file

@ -165,6 +165,40 @@ export interface CliArgs {
inputFile?: string | undefined;
}
/**
* Returns true if the root of the given schema can accept a JSON object.
*
* Considers:
* - explicit root `type` (string or array)
* - root `anyOf` / `oneOf` branches (at least one branch must accept
* object-typed values)
*
* Leaves `allOf` alone tight interactions between `allOf` branches with
* contradictory types are rare for `--json-schema` input and we'd rather
* let Ajv surface that at runtime than guess wrong here.
*/
function schemaRootAcceptsObject(schema: Record<string, unknown>): boolean {
const rawType = schema['type'];
if (rawType !== undefined) {
const types = Array.isArray(rawType) ? rawType : [rawType];
return types.includes('object');
}
for (const key of ['anyOf', 'oneOf'] as const) {
const variants = schema[key];
if (Array.isArray(variants) && variants.length > 0) {
return variants.some(
(v) =>
typeof v === 'object' &&
v !== null &&
!Array.isArray(v) &&
schemaRootAcceptsObject(v as Record<string, unknown>),
);
}
}
// No narrowing at the root — lenient default, treated as object-compatible.
return true;
}
/**
* Resolves the `--json-schema` argument into a parsed JSON Schema object.
*
@ -218,18 +252,17 @@ export function resolveJsonSchemaArg(
// The schema will be installed as a TOOL PARAMETER schema. All function-
// calling APIs (Gemini/OpenAI/Anthropic) require tool arguments to be a
// JSON object, so a root type like "array" or "string" registers an
// unusable synthetic tool that the model could never satisfy. Reject any
// explicit non-object root here. Absent `type`, `type: "object"`, or a
// `type` array that includes `"object"` are all acceptable.
const rawType = (parsed as Record<string, unknown>)['type'];
if (rawType !== undefined) {
const types = Array.isArray(rawType) ? rawType : [rawType];
if (!types.includes('object')) {
throw new FatalConfigError(
`--json-schema root "type" must be "object" (tool parameters are object-valued); got ${JSON.stringify(rawType)}.`,
);
}
// JSON object, so a schema that cannot accept objects registers an
// unusable synthetic tool the model could never satisfy. Check the root
// *and* any top-level anyOf/oneOf narrowing — a schema without a root
// `type` but whose only anyOf branches are non-object is equally broken.
if (!schemaRootAcceptsObject(parsed as Record<string, unknown>)) {
throw new FatalConfigError(
'--json-schema root must accept object-typed values (tool parameters ' +
'are always JSON objects). Every branch of a root anyOf/oneOf must ' +
'be satisfiable by an object, or the root must omit `type` / declare ' +
'`type: "object"`.',
);
}
// Ajv compile-time validation. SchemaValidator.validate is deliberately

View file

@ -82,10 +82,10 @@ describe('resolveJsonSchemaArg', () => {
it('rejects a schema whose root type is not object', () => {
expect(() => resolveJsonSchemaArg('{"type":"array"}')).toThrow(
/root "type" must be "object"/,
/must accept object-typed values/,
);
expect(() => resolveJsonSchemaArg('{"type":"string"}')).toThrow(
/root "type" must be "object"/,
/must accept object-typed values/,
);
});
@ -101,4 +101,33 @@ describe('resolveJsonSchemaArg', () => {
const schema = resolveJsonSchemaArg('{"properties":{"foo":{}}}');
expect(schema).toBeDefined();
});
it('rejects root anyOf where no branch accepts object', () => {
expect(() =>
resolveJsonSchemaArg(
'{"anyOf":[{"type":"array"},{"type":"string"}]}',
),
).toThrow(/must accept object-typed values/);
});
it('rejects root oneOf where no branch accepts object', () => {
expect(() =>
resolveJsonSchemaArg('{"oneOf":[{"type":"number"},{"type":"boolean"}]}'),
).toThrow(/must accept object-typed values/);
});
it('accepts root anyOf when at least one branch accepts object', () => {
const schema = resolveJsonSchemaArg(
'{"anyOf":[{"type":"object"},{"type":"string"}]}',
);
expect(schema).toBeDefined();
});
it('accepts nested anyOf/oneOf chains where a deep branch accepts object', () => {
// The recursion should see through one level of nesting.
const schema = resolveJsonSchemaArg(
'{"anyOf":[{"oneOf":[{"type":"object"}]},{"type":"string"}]}',
);
expect(schema).toBeDefined();
});
});