From ee8e6e407ec622673db9649e72085ca4e1c6ede5 Mon Sep 17 00:00:00 2001 From: ceo Date: Fri, 21 Aug 2026 12:20:07 +0000 Subject: [PATCH] fix(llm): add supportsParallelTools config field, default Gemini to false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (PR #207) threaded provider capabilities through to the parallel_tool_calls wire, but every built-in provider kind set supportsParallelTools from supportsTools — so Gemini (which supports tools) always got true, and the capability term was inert for the exact use case issue #104 describes. This adds an explicit supportsParallelTools?: boolean field to LlmProviderConfigEntry and the user config schema. Every provider kind reads it with fallback to supportsTools, but Gemini defaults to false overriding that fallback. Users can explicitly set supportsParallelTools: true on a Gemini entry to opt in. 7 new tests (3 config round-trip, 2 registry Gemini default/override, 2 rejection). --- src/config/config-schema.ts | 1 + src/config/llm-config.test.ts | 78 +++++++++++++++++++ src/config/llm-config.ts | 19 +++++ .../registry/provider-registry.test.ts | 67 ++++++++++++++++ src/llm/provider/registry/provider-types.ts | 1 + .../registry/register-built-in-providers.ts | 18 +++-- 6 files changed, 179 insertions(+), 5 deletions(-) diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index d1f06343..5df5ab1b 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -769,6 +769,7 @@ export interface AtomicAgentConfig { apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; + supportsParallelTools?: boolean; requestTimeoutMs?: number; promptCache?: "auto" | "off" | "explicit-markers"; providerPreferences?: Record; diff --git a/src/config/llm-config.test.ts b/src/config/llm-config.test.ts index ee0aad96..7aade4d3 100644 --- a/src/config/llm-config.test.ts +++ b/src/config/llm-config.test.ts @@ -505,4 +505,82 @@ describe("llm-config", () => { }), ).toThrow(/extraArgs\[1\]/); }); + + it("round-trips supportsParallelTools through parseLlmProviderEntry", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "gemini", + activeEmbeddingProvider: "gemini", + toolTransport: "auto", + providers: [ + { + id: "gemini", + kind: "gemini", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "test-key", + defaultChatModel: "gemini-2.5-flash", + supportsParallelTools: false, + }, + { + id: "openai", + kind: "openai-compatible", + baseUrl: "https://api.openai.com", + apiKey: "test-key", + defaultChatModel: "gpt-4o", + supportsParallelTools: true, + }, + ], + }, + }); + const gemini = parsed.llm?.providers.find((p) => p.id === "gemini"); + expect(gemini?.supportsParallelTools).toBe(false); + const openai = parsed.llm?.providers.find((p) => p.id === "openai"); + expect(openai?.supportsParallelTools).toBe(true); + }); + + it("accepts undefined supportsParallelTools (absent field)", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "gemini", + activeEmbeddingProvider: "gemini", + toolTransport: "auto", + providers: [ + { + id: "gemini", + kind: "gemini", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "test-key", + defaultChatModel: "gemini-2.5-flash", + }, + ], + }, + }); + const gemini = parsed.llm?.providers.find((p) => p.id === "gemini"); + expect(gemini?.supportsParallelTools).toBeUndefined(); + }); + + it("rejects non-boolean supportsParallelTools", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "gemini", + activeEmbeddingProvider: "gemini", + toolTransport: "auto", + providers: [ + { + id: "gemini", + kind: "gemini", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "test-key", + defaultChatModel: "gemini-2.5-flash", + supportsParallelTools: "yes", + }, + ], + }, + }), + ).toThrow(/supportsParallelTools/); + }); }); diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts index bc00e644..e296b6b4 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -57,6 +57,14 @@ export type UserLlmProviderEntry = { apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; + /** + * Whether the provider supports parallel tool calls in a single + * completion. Absent defaults to `true` for most providers, but + * some (e.g. Gemini) emit parallel `tool_calls` without stable + * indices — the runtime derives `parallel_tool_calls` from this + * flag combined with `agent.maxParallelToolCalls`. + */ + supportsParallelTools?: boolean; requestTimeoutMs?: number; /** * Prompt-caching policy for this provider. Declared in the config @@ -263,6 +271,17 @@ export function parseLlmProviderEntry( "expected boolean", ); })(), + supportsParallelTools: + obj.supportsParallelTools === undefined + ? undefined + : typeof obj.supportsParallelTools === "boolean" + ? obj.supportsParallelTools + : (() => { + throw new ConfigValidationError( + `${field}.supportsParallelTools`, + "expected boolean", + ); + })(), requestTimeoutMs: obj.requestTimeoutMs === undefined ? undefined diff --git a/src/llm/provider/registry/provider-registry.test.ts b/src/llm/provider/registry/provider-registry.test.ts index e7a67281..8c2b203c 100644 --- a/src/llm/provider/registry/provider-registry.test.ts +++ b/src/llm/provider/registry/provider-registry.test.ts @@ -62,6 +62,73 @@ describe("ProviderRegistry", () => { expect(registry.activeText).toBeInstanceOf(GeminiProvider); }); + it("Gemini defaults to supportsParallelTools: false", async () => { + const fakeConfig = { + ...getConfig(), + llm: { + activeTextProvider: "gemini", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto" as const, + providers: [ + { + id: "gemini", + kind: "gemini", + apiKey: "test-key", + }, + ], + }, + } as AtomicAgentConfig; + + const registry = await ProviderRegistry.fromConfig(fakeConfig, { + config: fakeConfig, + llamaClient: {} as never, + getProfile: () => ({}) as never, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as never, + }); + + const provider = registry.activeText as GeminiProvider; + expect(provider.capabilities.supportsParallelTools).toBe(false); + }); + + it("Gemini respects explicit supportsParallelTools: true override", async () => { + const fakeConfig = { + ...getConfig(), + llm: { + activeTextProvider: "gemini", + activeEmbeddingProvider: "local-llama-embed", + toolTransport: "auto" as const, + providers: [ + { + id: "gemini", + kind: "gemini", + apiKey: "test-key", + supportsParallelTools: true, + }, + ], + }, + } as AtomicAgentConfig; + + const registry = await ProviderRegistry.fromConfig(fakeConfig, { + config: fakeConfig, + llamaClient: {} as never, + getProfile: () => ({}) as never, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as never, + }); + + const provider = registry.activeText as GeminiProvider; + expect(provider.capabilities.supportsParallelTools).toBe(true); + }); + it("rejects unknown provider kind at fromConfig", async () => { registerBuiltInProviderKinds(); const fakeConfig = { diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts index 3066198b..e3773bbb 100644 --- a/src/llm/provider/registry/provider-types.ts +++ b/src/llm/provider/registry/provider-types.ts @@ -36,6 +36,7 @@ export type LlmProviderConfigEntry = { apiKeyHeader?: string; supportsTools?: boolean; supportsVision?: boolean; + supportsParallelTools?: boolean; requestTimeoutMs?: number; promptCache?: "auto" | "off" | "explicit-markers"; providerPreferences?: Record; diff --git a/src/llm/provider/registry/register-built-in-providers.ts b/src/llm/provider/registry/register-built-in-providers.ts index 4f8dc7a5..2b88dc34 100644 --- a/src/llm/provider/registry/register-built-in-providers.ts +++ b/src/llm/provider/registry/register-built-in-providers.ts @@ -62,7 +62,8 @@ export function registerBuiltInProviderKinds(): void { headers: entry.headers, apiKeyHeader: entry.apiKeyHeader, supportsVision: entry.supportsVision ?? true, - supportsParallelTools: entry.supportsTools ?? true, + supportsParallelTools: + entry.supportsParallelTools ?? entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, extraBody: entry.extraBody, }); @@ -83,7 +84,8 @@ export function registerBuiltInProviderKinds(): void { headers: entry.headers, apiKeyHeader: entry.apiKeyHeader, supportsVision: entry.supportsVision ?? true, - supportsParallelTools: entry.supportsTools ?? true, + supportsParallelTools: + entry.supportsParallelTools ?? entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, taggedToolCompatibility: "qwen", extraBody: entry.extraBody, @@ -99,7 +101,8 @@ export function registerBuiltInProviderKinds(): void { defaultChatModel: entry.defaultChatModel ?? "openrouter/auto", headers: entry.headers, supportsVision: entry.supportsVision ?? true, - supportsParallelTools: entry.supportsTools ?? true, + supportsParallelTools: + entry.supportsParallelTools ?? entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, httpReferer: OPENROUTER_APP_REFERER, xTitle: OPENROUTER_APP_TITLE, @@ -116,7 +119,8 @@ export function registerBuiltInProviderKinds(): void { defaultChatModel: entry.defaultChatModel ?? AIMLAPI_DEFAULT_CHAT_MODEL, headers: entry.headers, supportsVision: entry.supportsVision ?? true, - supportsParallelTools: entry.supportsTools ?? true, + supportsParallelTools: + entry.supportsParallelTools ?? entry.supportsTools ?? true, requestTimeoutMs: entry.requestTimeoutMs, }); }); @@ -130,7 +134,11 @@ export function registerBuiltInProviderKinds(): void { defaultChatModel: entry.defaultChatModel ?? GEMINI_DEFAULT_CHAT_MODEL, headers: entry.headers, supportsVision: entry.supportsVision ?? true, - supportsParallelTools: entry.supportsTools ?? true, + // Gemini does not emit stable indices for parallel tool calls — + // the wire still carries parallel_tool_calls: true, but the + // model drops or reorders slots, breaking the batch executor. + // Users can override with supportsParallelTools: true in config. + supportsParallelTools: entry.supportsParallelTools ?? false, requestTimeoutMs: entry.requestTimeoutMs, }); });