diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index d1f0634..5df5ab1 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 ee0aad9..7aade4d 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 bc00e64..e296b6b 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 e7a6728..8c2b203 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 3066198..e3773bb 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 4f8dc7a..2b88dc3 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, }); });