Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
9545e63
fix(core): honor empty core tool allowlists
yiliang114 Aug 26, 2026
7e7a12b
fix(core): enforce empty coreTools allowlist for MCP tools and reject…
yiliang114 Aug 26, 2026
762f662
fix(core): keep coreTools allowlist null-tolerant and single-sourced
yiliang114 Aug 26, 2026
c548c0f
test(core): pin structured_output and mcp tools under empty coreTools
yiliang114 Aug 26, 2026
e37a11a
test(cli): cover argv --core-tools precedence over settings tools.core
yiliang114 Aug 26, 2026
9f9420b
fix(cli): warn at startup when tools.core is an empty allowlist
yiliang114 Aug 26, 2026
c97c624
Merge branch 'main' into fix/issue-10065-empty-core-tools
wenshao Aug 27, 2026
b318af8
Merge branch 'main' into fix/issue-10065-empty-core-tools
yiliang114 Aug 27, 2026
1ac1ecd
fix(core): normalize empty/non-array tools.core; restart caveat + not…
yiliang114 Aug 27, 2026
2561b41
Merge branch 'main' into fix/issue-10065-empty-core-tools
yiliang114 Aug 28, 2026
1d830f6
fix(cli): drop non-string tools.core entries at the settings boundary
yiliang114 Aug 28, 2026
dbed630
fix(core): omit empty tools from OpenAI-compatible wire requests
yiliang114 Aug 28, 2026
dca5b97
test(core): pin non-string tools.core entry handling in PermissionMan…
yiliang114 Aug 28, 2026
7b74350
docs(cli): correct safe-mode coreTools comment in config test
yiliang114 Aug 28, 2026
e073c9d
fix(core): drop tools.core entries without a usable parsed name
yiliang114 Aug 28, 2026
6380032
fix(cli): share core's name-less tools.core classification with the n…
yiliang114 Aug 28, 2026
332d2e5
test: pin name-less tools.core collapse on gate and startup notice
yiliang114 Aug 28, 2026
fbf098a
docs: align tools.core empty-list descriptions with the #10065 semantic
yiliang114 Aug 28, 2026
70d0ac0
fix(core): refresh empty-gate ordering comment, drop dead test mocks
yiliang114 Aug 28, 2026
6d3c84e
fix(core): normalize grammar-sensitive tool schemas
yiliang114 Aug 28, 2026
0fc1cd6
Merge origin/main into fix/issue-10065-empty-core-tools
yiliang114 Aug 28, 2026
75d7a66
fix(core): relax closed zero-property schemas
yiliang114 Aug 28, 2026
117b1c6
fix(core): cover object-capable empty schemas
yiliang114 Aug 28, 2026
87a3997
fix(core): guard schema relaxation with local validation
yiliang114 Aug 29, 2026
60d4157
fix(core): require enforceable schemas before relaxation
yiliang114 Aug 29, 2026
53c1503
Merge branch 'main' into fix/issue-10065-empty-core-tools
yiliang114 Aug 30, 2026
e1d34bb
fix(core): make schema compilation idempotent for $id-bearing schemas
yiliang114 Aug 30, 2026
75b030a
fix(core): restore $id-mediated $ref resolution while keeping compile…
yiliang114 Aug 30, 2026
0700c6e
fix(core): make shared-$id schema eviction collision-triggered and po…
yiliang114 Aug 31, 2026
aec6fce
Merge branch 'main' into fix/issue-10065-empty-core-tools
yiliang114 Sep 1, 2026
2261b54
fix(core): isolate schema grammar validation
yiliang114 Sep 1, 2026
926e1a9
fix(core): keep identified schemas constrained
yiliang114 Sep 1, 2026
65c4ed0
fix(core): reject nested schema identifiers
yiliang114 Sep 1, 2026
c45f690
fix(core): avoid schema identifier false positives
yiliang114 Sep 1, 2026
70378ad
Merge branch 'main' into fix/issue-10065-empty-core-tools
yiliang114 Sep 1, 2026
8ace11c
Merge branch 'main' into fix/issue-10065-empty-core-tools
yiliang114 Sep 2, 2026
337e4ae
perf(core): cache tool schema validation
yiliang114 Sep 2, 2026
6ead0fe
Merge branch 'main' into fix/issue-10065-empty-core-tools
yiliang114 Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/design/openai-tool-schema-grammar-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# OpenAI Tool Schema Grammar Compatibility

## Problem

Some OpenAI-compatible runtimes compile every function schema in a request into one grammar. Older llama.cpp builds reject empty `properties` maps and large string or array repetition limits, so one valid but unsupported schema prevents the entire request from starting. Qwen Code ships tools with both shapes and can also receive them from MCP servers.

Disabling all tools avoids grammar construction but also removes the functionality the user asked the agent to use. It is therefore a diagnostic fallback, not the primary fix.

## Design

Keep the registered tool set unchanged. Before an OpenAI-compatible request is sent, recursively relax only the wire copy of each schema:

- omit empty `properties` maps and `additionalProperties: false` on object-capable schemas with zero declared properties;
- omit `minLength`, `maxLength`, `minItems`, and `maxItems` values at or above 1999, the lowest failing boundary measured across the four keywords;
- preserve smaller limits and all other supported constraints.

Apply these grammar-specific relaxations only when the source schema passes the existing isolated strict compilation for its selected dialect and has no top-level `$id`. Schemas with a top-level `$id` keep their constraints because runtime validation uses a shared schema registry where duplicate IDs can prevent enforcement. If local validation cannot enforce the complete schema, keep its grammar constraints on the wire rather than broadening both enforcement layers.

The original schema remains attached to the tool and continues to drive client-side parameter validation. The provider receives a schema it can compile, while Qwen Code still rejects tool calls that violate the original limits.

## Compatibility

This applies to both built-in tools and MCP-provided schemas because they share the same OpenAI conversion boundary. Native Gemini requests are unchanged. Providers that accept the original constraints receive a slightly relaxed wire schema, but local validation preserves their behavior.

## Verification

Unit coverage exercises recursive empty objects, the 1998/1999 boundary, the actual OpenAI tool converter, and source-schema immutability. A live LM Studio smoke remains useful when that runtime is available, but the regression test pins the exact request shapes that caused grammar initialization to fail.
162 changes: 161 additions & 1 deletion packages/core/src/core/openaiContentGenerator/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { OpenAIContentConverter } from './converter.js';
import { StreamingToolCallParser } from './streamingToolCallParser.js';
import { TaggedThinkingParser } from './taggedThinkingParser.js';
Expand All @@ -24,6 +24,7 @@ import { convertToFunctionResponse } from '../coreToolScheduler.js';
import { getToolCallPreparations } from '../tool-call-preparation.js';
import { isOpenAIReasoningThoughtPart } from '../../utils/thoughtUtils.js';
import { getGenAiUsageProvenance } from '../../telemetry/gen-ai-usage.js';
import { SchemaValidator } from '../../utils/schemaValidator.js';

describe('OpenAIContentConverter', () => {
let converter: typeof OpenAIContentConverter;
Expand Down Expand Up @@ -6035,6 +6036,31 @@ describe('OpenAIContentConverter', () => {
});

describe('convertLlmToolsToOpenAI', () => {
it('compiles a stable tool schema only once', async () => {
const parametersJsonSchema = {
type: 'object',
properties: {
value: { type: 'string', maxLength: 1999 },
},
};
const tools = [
{
functionDeclarations: [
{ name: 'stable', parametersJsonSchema },
],
},
] as Tool[];
const compileStrict = vi.spyOn(SchemaValidator, 'compileStrict');

try {
await converter.convertLlmToolsToOpenAI(tools);
await converter.convertLlmToolsToOpenAI(tools);
expect(compileStrict).toHaveBeenCalledTimes(1);
} finally {
compileStrict.mockRestore();
}
});

it('removes uniqueItems from function-calling wire schemas', async () => {
const parametersJsonSchema = {
type: 'object',
Expand Down Expand Up @@ -6081,6 +6107,140 @@ describe('OpenAIContentConverter', () => {
expect(parametersJsonSchema.properties.blockedBy.uniqueItems).toBe(true);
});

it('only relaxes grammar constraints backed by local validation', async () => {
const supportedSchema = {
type: 'object',
properties: {},
additionalProperties: false,
};
const unsupportedSchema = {
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {},
additionalProperties: false,
};
const unsupportedVocabularySchema = {
type: 'object',
properties: {
tuple: {
type: 'array',
prefixItems: [
{
type: 'object',
properties: {},
additionalProperties: false,
},
],
},
},
};
const tools = [
{
functionDeclarations: [
{ name: 'supported', parametersJsonSchema: supportedSchema },
{ name: 'unsupported', parametersJsonSchema: unsupportedSchema },
{
name: 'unsupported_vocabulary',
parametersJsonSchema: unsupportedVocabularySchema,
},
{
name: 'without_local_schema',
parameters: {
type: Type.OBJECT,
properties: {},
additionalProperties: false,
},
},
],
},
] as Tool[];

const result = await converter.convertLlmToolsToOpenAI(tools);

expect(result.map(({ function: declaration }) => declaration)).toEqual([
{ name: 'supported', description: '', parameters: { type: 'object' } },
{
name: 'unsupported',
description: '',
parameters: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: 'unsupported_vocabulary',
description: '',
parameters: unsupportedVocabularySchema,
},
{
name: 'without_local_schema',
description: '',
parameters: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
]);
expect(supportedSchema).toEqual({
type: 'object',
properties: {},
additionalProperties: false,
});
expect(unsupportedSchema.$schema).toBe(
'https://json-schema.org/draft/2019-09/schema',
);
expect(
unsupportedVocabularySchema.properties.tuple.prefixItems[0]
.additionalProperties,
).toBe(false);
});

it('keeps grammar constraints for schemas with a top-level $id', async () => {
const sharedId = 'https://qwen-code.test/shared-tool-schema';
const makeSchema = () => ({
$id: sharedId,
type: 'object',
properties: {
value: { type: 'string', maxLength: 1999 },
},
});
const tools = [
{
functionDeclarations: [
{ name: 'first', parametersJsonSchema: makeSchema() },
{ name: 'second', parametersJsonSchema: makeSchema() },
],
},
] as Tool[];

const result = await converter.convertLlmToolsToOpenAI(tools);

expect(result.map(({ function: declaration }) => declaration)).toEqual([
{
name: 'first',
description: '',
parameters: {
type: 'object',
properties: {
value: { type: 'string', maxLength: 1999 },
},
},
},
{
name: 'second',
description: '',
parameters: {
type: 'object',
properties: {
value: { type: 'string', maxLength: 1999 },
},
},
},
]);
});

it('should convert Gemini tools with parameters field', async () => {
const llmTools = [
{
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/core/openaiContentGenerator/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { InvalidStreamError } from '../invalid-stream-error.js';
import { normalizeMcpToolName } from '../../utils/tool-name-utils.js';
import { setGenAiUsageProvenance } from '../../telemetry/gen-ai-usage.js';
import { SchemaValidator } from '../../utils/schemaValidator.js';

const debugLogger = createDebugLogger('CONVERTER');
const SPLIT_TOOL_MEDIA_TEXT = '(attached media from previous tool call)';
Expand Down Expand Up @@ -334,6 +335,18 @@ export function convertLlmToolParametersToOpenAI(
* Handles both Gemini tools (using 'parameters' field) and MCP tools
* (using 'parametersJsonSchema' field).
*/
const grammarSchemaValidationCache = new WeakMap<object, boolean>();

function isStrictlyValidSchema(schema: object): boolean {
const cached = grammarSchemaValidationCache.get(schema);
if (cached !== undefined) {
return cached;
}
const valid = SchemaValidator.compileStrict(schema) === null;
grammarSchemaValidationCache.set(schema, valid);
return valid;
}

export async function convertLlmToolsToOpenAI(
llmTools: ToolListUnion,
schemaCompliance: SchemaComplianceMode = 'auto',
Expand Down Expand Up @@ -373,14 +386,24 @@ export async function convertLlmToolsToOpenAI(
}

if (parameters) {
const sourceSchema = func.parametersJsonSchema;
const canValidateLocally =
typeof sourceSchema === 'object' &&
sourceSchema !== null &&
!Array.isArray(sourceSchema) &&
!('$id' in sourceSchema) &&
isStrictlyValidSchema(sourceSchema);
parameters = convertSchema(parameters, schemaCompliance);
// #7315: gateways enforcing OpenAI's structured-output contract
// promote every property to required when an object level has
// `additionalProperties: false` — forcing the model to emit
// mutually exclusive optional fields (Agent working_dir vs
// isolation). Relax the wire schema; client-side
// validateToolParams still enforces the source schema.
parameters = relaxSchemaForFunctionCalling(parameters);
parameters = relaxSchemaForFunctionCalling(
parameters,
canValidateLocally,
);
}

openAITools.push({
Expand Down
60 changes: 53 additions & 7 deletions packages/core/src/utils/schemaConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,13 +335,6 @@ describe('relaxSchemaForFunctionCalling', () => {
).toBe(false);
});

it('keeps additionalProperties:false when there are no properties to promote', () => {
const empty = { type: 'object', additionalProperties: false };
expect(relaxSchemaForFunctionCalling(empty)['additionalProperties']).toBe(
false,
);
});

it('relaxes nested object levels independently', () => {
const nested = {
type: 'object',
Expand Down Expand Up @@ -503,6 +496,59 @@ describe('relaxSchemaForFunctionCalling', () => {
});
});

it('removes grammar-hostile empty objects and repetition limits', () => {
const schema = {
type: 'object',
properties: {
empty: {
type: 'object',
properties: {},
additionalProperties: false,
},
closed: { type: 'object', additionalProperties: false },
typelessClosed: { additionalProperties: false },
nullableClosed: {
type: ['object', 'null'],
additionalProperties: false,
},
bounded: { type: 'string', maxLength: 1998 },
long: { type: 'string', maxLength: 1999 },
padded: { type: 'string', minLength: 1999 },
many: {
type: 'array',
maxItems: 1999,
items: { type: 'string' },
},
largeBatch: {
type: 'array',
minItems: 1999,
items: { type: 'string' },
},
},
};

expect(relaxSchemaForFunctionCalling(schema, true)).toEqual({
type: 'object',
properties: {
empty: { type: 'object' },
closed: { type: 'object' },
typelessClosed: {},
nullableClosed: { type: ['object', 'null'] },
bounded: { type: 'string', maxLength: 1998 },
long: { type: 'string' },
padded: { type: 'string' },
many: {
type: 'array',
items: { type: 'string' },
},
largeBatch: {
type: 'array',
items: { type: 'string' },
},
},
});
});

it('never treats schema-map keys as schema keywords', () => {
const namedUniqueItems = { uniqueItems: { type: 'string' } };
const schema = {
Expand Down
Loading
Loading