Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof mapOpenAICompatibleFinishReason>
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"

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -642,6 +662,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
}
}
}

if (choice?.finish_reason != null) {
finishReason = normalizeFinishReason(choice.finish_reason, sawToolCalls)
}
},

flush(controller) {
Expand Down
9 changes: 5 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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
Expand Down
22 changes: 20 additions & 2 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<string, unknown>
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(),
Expand All @@ -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.`)
Expand All @@ -487,7 +505,7 @@ export const BashTool = Tool.define("bash", async () => {
cwd,
env: await shellEnv(ctx, cwd),
timeout,
description: params.description,
description,
},
ctx,
)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export namespace Tool {
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
description: string
parameters: Parameters
normalizeInput?(args: unknown): unknown
execute(
args: z.infer<Parameters>,
ctx: Context,
Expand Down Expand Up @@ -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 })
Expand All @@ -69,7 +71,7 @@ export namespace Tool {
{ cause: error },
)
}
const result = await execute(args, ctx)
const result = await execute(normalized as z.infer<Parameters>, ctx)
// skip truncation for tools that handle it themselves
if (result.metadata.truncated !== undefined) {
return result
Expand Down
65 changes: 65 additions & 0 deletions packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand All @@ -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<typeof mock>) {
return new OpenAICompatibleChatLanguageModel("test-model", {
provider: "copilot.chat",
Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading