Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/invalid-tool-repair-surfaces-real-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Surface the actual tool name when the model calls an unavailable tool, instead of a confusing "unavailable tool 'invalid'" error. Malformed tool calls that cannot be repaired now report the real tool name and available tools, so the model can self-correct.
13 changes: 5 additions & 8 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,14 +380,11 @@ const live: Layer.Layer<
l.info("repairing tool call", { tool: failed.toolCall.toolName, repaired: lower }) // kilocode_change
return { ...failed.toolCall, toolName: lower }
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
// kilocode_change start - surface the original tool-name error instead of a
// repaired call to the hidden "invalid" tool, which activeTools excludes and
// therefore fails with a confusing "unavailable tool 'invalid'" error
return null
// kilocode_change end
},
temperature: prepared.params.temperature,
topP: prepared.params.topP,
Expand Down
99 changes: 99 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([LLM.node, Provider.n
// LLM.stream returns a Stream, not an Effect, so we can't use the serviceUse proxy.
const drain = (input: LLM.StreamInput) => LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain))

// collect runs the stream and returns every emitted LLMEvent for assertions. // kilocode_change
const collect = (input: LLM.StreamInput) => // kilocode_change
LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runCollect)) // kilocode_change

// drainWith builds an isolated runtime so custom replacements fully own LLM and
// its transitive deps.
const drainWith = (layer: Layer.Layer<LLM.Service>, input: LLM.StreamInput) =>
Expand Down Expand Up @@ -2250,5 +2254,100 @@ describe("session.llm.stream", () => {
}),
},
)
it.instance(
"surfaces the real tool name when a tool call cannot be repaired",
() =>
Effect.gen(function* () {
const fixture = loadFixture(alibabaQwenFixture.providerID, alibabaQwenFixture.modelID)

waitRequest(
"/chat/completions",
createEventResponse(
[
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
role: "assistant",
content: null,
tool_calls: [{ index: 0, id: "call-1", type: "function", function: { name: "Write", arguments: "" } }],
},
},
],
},
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: "{}" } }] } }],
},
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
},
],
true,
),
)

const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(alibabaQwenFixture.providerID),
ModelV2.ID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-unavailable-tool")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info

const user = {
id: MessageID.make("msg_user-unavailable-tool"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
} satisfies MessageV2.User

const events = Array.from(
yield* collect({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Write a file" }],
tools: {
read: tool({
description: "Read a file",
inputSchema: z.object({ filePath: z.string() }),
execute: async () => ({ output: "stub" }),
}),
},
}),
)

const errors = events.filter((event) => event.type === "tool-error")
expect(errors).toHaveLength(1)
expect(errors[0].name).toBe("Write")
expect(errors[0].message).toContain("unavailable tool 'Write'")
expect(errors[0].message).not.toContain("invalid")
}),
{
config: () => ({
enabled_providers: [alibabaQwenFixture.providerID],
provider: {
[alibabaQwenFixture.providerID]: {
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
},
},
}),
},
)
// kilocode_change end
})
Loading