From 0ae84cc730bc21cfd0209f4d4c4ff88179ba923a Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 2 Apr 2026 15:16:25 +0200 Subject: [PATCH] Improve default agent compatibility for local OpenAI backends --- .../openai-compatible-chat-language-model.ts | 52 +++++++++++---- packages/opencode/src/session/prompt.ts | 9 +-- packages/opencode/src/tool/bash.ts | 22 ++++++- packages/opencode/src/tool/registry.ts | 1 + packages/opencode/src/tool/tool.ts | 6 +- .../copilot/copilot-chat-model.test.ts | 65 +++++++++++++++++++ packages/opencode/test/tool/bash.test.ts | 18 +++++ 7 files changed, 151 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts index 280970c41b4f..08d1fa9e9dca 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts @@ -50,6 +50,29 @@ export type OpenAICompatibleChatConfig = { supportedUrls?: () => LanguageModelV3["supportedUrls"] } +function hasToolCalls(toolCalls: unknown): toolCalls is Array<{ function: { name: string; arguments: string } }> { + return Array.isArray(toolCalls) && toolCalls.length > 0 +} + +function normalizeFinishReason( + finishReason: string | null | undefined, + sawToolCalls: boolean, +): { + unified: ReturnType + raw: string | undefined +} { + if ((finishReason === "tool_calls" || finishReason === "function_call") && !sawToolCalls) { + return { + unified: "stop", + raw: finishReason ?? undefined, + } + } + return { + unified: mapOpenAICompatibleFinishReason(finishReason), + raw: finishReason ?? undefined, + } +} + export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { readonly specificationVersion = "v3" @@ -240,8 +263,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { } // tool calls: - if (choice.message.tool_calls != null) { - for (const toolCall of choice.message.tool_calls) { + const toolCalls = choice.message.tool_calls ?? [] + const sawToolCalls = hasToolCalls(toolCalls) + if (sawToolCalls) { + for (const toolCall of toolCalls) { content.push({ type: "tool-call", toolCallId: toolCall.id ?? generateId(), @@ -273,10 +298,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { return { content, - finishReason: { - unified: mapOpenAICompatibleFinishReason(choice.finish_reason), - raw: choice.finish_reason ?? undefined, - }, + finishReason: normalizeFinishReason(choice.finish_reason, sawToolCalls), usage: { inputTokens: { total: responseBody.usage?.prompt_tokens ?? undefined, @@ -375,6 +397,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { let isActiveReasoning = false let isActiveText = false let reasoningOpaque: string | undefined + let sawToolCalls = false return { stream: response.pipeThrough( @@ -452,14 +475,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { const choice = value.choices[0] - if (choice?.finish_reason != null) { - finishReason = { - unified: mapOpenAICompatibleFinishReason(choice.finish_reason), - raw: choice.finish_reason ?? undefined, - } - } - if (choice?.delta == null) { + if (choice?.finish_reason != null) { + finishReason = normalizeFinishReason(choice.finish_reason, sawToolCalls) + } return } @@ -523,7 +542,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { }) } - if (delta.tool_calls != null) { + if (delta.tool_calls != null && delta.tool_calls.length > 0) { + sawToolCalls = true // If reasoning was active and we're starting tool calls, end reasoning first // This handles the case where reasoning goes directly to tool calls with no content if (isActiveReasoning) { @@ -642,6 +662,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { } } } + + if (choice?.finish_reason != null) { + finishReason = normalizeFinishReason(choice.finish_reason, sawToolCalls) + } }, flush(controller) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 436847ed4e3c..4fa4cdc1f13d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -443,15 +443,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the description: item.description, inputSchema: jsonSchema(schema as any), execute(args, options) { + const normalizedArgs = item.normalizeInput ? item.normalizeInput(args) : args return Effect.runPromise( Effect.gen(function* () { - const ctx = context(args, options) + const ctx = context(normalizedArgs, options) yield* plugin.trigger( "tool.execute.before", { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, - { args }, + { args: normalizedArgs }, ) - const result = yield* Effect.promise(() => item.execute(args, ctx)) + const result = yield* Effect.promise(() => item.execute(normalizedArgs, ctx)) const output = { ...result, attachments: result.attachments?.map((attachment) => ({ @@ -463,7 +464,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } yield* plugin.trigger( "tool.execute.after", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args: normalizedArgs }, output, ) return output diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e50f09cc38ce..b0743e5dffb5 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -258,6 +258,13 @@ function preview(text: string) { return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." } +function defaultDescription(command: string) { + const cleaned = command.replace(/\s+/g, " ").trim() + if (!cleaned) return "Run shell command" + if (cleaned.length <= 80) return cleaned + return cleaned.slice(0, 77).trimEnd() + "..." +} + async function parse(command: string, ps: boolean) { const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command)) if (!tree) throw new Error("Failed to parse command") @@ -452,6 +459,15 @@ export const BashTool = Tool.define("bash", async () => { .replaceAll("${chaining}", chain) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), + normalizeInput(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) return input + const next = { ...input } as Record + if (typeof next.command !== "string") return next + if (typeof next.description !== "string" || !next.description.trim()) { + next.description = defaultDescription(next.command) + } + return next + }, parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), @@ -465,9 +481,11 @@ export const BashTool = Tool.define("bash", async () => { .string() .describe( "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), + ) + .optional(), }), async execute(params, ctx) { + const description = params.description ?? defaultDescription(params.command) const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) @@ -487,7 +505,7 @@ export const BashTool = Tool.define("bash", async () => { cwd, env: await shellEnv(ctx, cwd), timeout, - description: params.description, + description, }, ctx, ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 133a5018ad43..faea873bdb45 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -186,6 +186,7 @@ export namespace ToolRegistry { id: tool.id, description: output.description, parameters: output.parameters, + normalizeInput: next.normalizeInput, execute: next.execute, formatValidationError: next.formatValidationError, } diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 98fa50f8c7ac..d7fec4bd6f10 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -28,6 +28,7 @@ export namespace Tool { export interface Def { description: string parameters: Parameters + normalizeInput?(args: unknown): unknown execute( args: z.infer, ctx: Context, @@ -58,8 +59,9 @@ export namespace Tool { const toolInfo = init instanceof Function ? await init(initCtx) : init const execute = toolInfo.execute toolInfo.execute = async (args, ctx) => { + const normalized = toolInfo.normalizeInput ? toolInfo.normalizeInput(args) : args try { - toolInfo.parameters.parse(args) + toolInfo.parameters.parse(normalized) } catch (error) { if (error instanceof z.ZodError && toolInfo.formatValidationError) { throw new Error(toolInfo.formatValidationError(error), { cause: error }) @@ -69,7 +71,7 @@ export namespace Tool { { cause: error }, ) } - const result = await execute(args, ctx) + const result = await execute(normalized as z.infer, ctx) // skip truncation for tools that handle it themselves if (result.metadata.truncated !== undefined) { return result diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts index 389a72bb377b..217b6dc5fc42 100644 --- a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts +++ b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts @@ -71,6 +71,12 @@ const FIXTURES = { `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`, `data: [DONE]`, ], + + emptyToolCalls: [ + `data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"Done"},"finish_reason":null}],"created":1769917421,"id":"empty-tool-calls","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"qwen-local"}`, + `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"role":"assistant","tool_calls":[]}}],"created":1769917421,"id":"empty-tool-calls","usage":{"completion_tokens":8,"prompt_tokens":21,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":29,"reasoning_tokens":0},"model":"qwen-local"}`, + `data: [DONE]`, + ], } function createMockFetch(chunks: string[]) { @@ -91,6 +97,15 @@ function createMockFetch(chunks: string[]) { }) } +function createJsonFetch(body: unknown) { + return mock(async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) +} + function createModel(fetchFn: ReturnType) { return new OpenAICompatibleChatLanguageModel("test-model", { provider: "copilot.chat", @@ -533,6 +548,56 @@ describe("doStream", () => { const rawChunks = parts.filter((p) => p.type === "raw") expect(rawChunks.length).toBeGreaterThan(0) }) + + test("should treat empty tool_calls as stop in streams", async () => { + const mockFetch = createMockFetch(FIXTURES.emptyToolCalls) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + const finish = parts.find((p) => p.type === "finish") + expect(finish).toMatchObject({ + type: "finish", + finishReason: { unified: "stop", raw: "tool_calls" }, + }) + expect(parts.some((p) => p.type === "tool-call")).toBe(false) + }) +}) + +describe("doGenerate", () => { + test("should treat empty tool_calls as stop in json responses", async () => { + const mockFetch = createJsonFetch({ + id: "empty-tool-calls-json", + model: "qwen-local", + choices: [ + { + message: { + role: "assistant", + content: "Done", + tool_calls: [], + }, + finish_reason: "tool_calls", + }, + ], + usage: { + prompt_tokens: 21, + completion_tokens: 8, + total_tokens: 29, + }, + }) + const model = createModel(mockFetch) + + const result = await model.doGenerate({ + prompt: TEST_PROMPT, + }) + + expect(result.finishReason).toEqual({ unified: "stop", raw: "tool_calls" }) + expect(result.content).toEqual([{ type: "text", text: "Done", providerMetadata: undefined }]) + }) }) describe("request body", () => { diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index e4ba881fb166..404aaa3090b4 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -131,6 +131,24 @@ describe("tool.bash", () => { }, }) }) + + each("fills missing description from command", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { + command: "echo fallback description", + } as any, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.description).toBe("echo fallback description") + expect(result.title).toBe("echo fallback description") + }, + }) + }) }) describe("tool.bash permissions", () => {