Skip to content

Commit b083b99

Browse files
authored
Merge pull request #3282 from Kilo-Org/christiaan/native-tool-calling-improvements
Include tool calls and results in the API conversation history
2 parents bf7fca8 + 33c7add commit b083b99

File tree

11 files changed

+140
-54
lines changed

11 files changed

+140
-54
lines changed

.changeset/loud-bears-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"kilo-code": patch
3+
---
4+
5+
Improved handling of tool calls in the API conversation history

packages/types/src/kilocode/native-function-calling.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const nativeFunctionCallingProviders = [
2121
"human-relay",
2222
] satisfies ProviderName[] as ProviderName[]
2323

24-
const modelsDefaultingToNativeFunctionCalls = ["anthropic/claude-haiku-4.5"]
24+
const modelsDefaultingToJsonKeywords = ["claude-haiku-4.5", "claude-haiku-4-5"]
2525

2626
export function getActiveToolUseStyle(settings: ProviderSettings | undefined): ToolUseStyle {
2727
if (
@@ -33,8 +33,8 @@ export function getActiveToolUseStyle(settings: ProviderSettings | undefined): T
3333
if (settings.toolStyle) {
3434
return settings.toolStyle
3535
}
36-
const model = getModelId(settings)
37-
if (model && modelsDefaultingToNativeFunctionCalls.includes(model)) {
36+
const model = getModelId(settings)?.toLowerCase()
37+
if (model && modelsDefaultingToJsonKeywords.some((keyword) => model.includes(keyword))) {
3838
return "json"
3939
}
4040
return "xml"

src/api/providers/openrouter.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
240240
if (this.providerName == "KiloCode" && isAnyRecognizedKiloCodeError(error)) {
241241
throw error
242242
}
243-
const rawError = safeJsonParse(error?.error?.metadata?.raw) as { error?: OpenAI.ErrorObject } | undefined
244-
if (rawError?.error?.message) {
245-
throw new Error(`${this.providerName} error: ${rawError.error.message}`)
246-
}
243+
throw new Error(makeOpenRouterErrorReadable(error))
247244
// kilocode_change end
248-
throw handleOpenAIError(error, this.providerName)
249245
}
250246

251247
let lastUsage: CompletionUsage | undefined = undefined
@@ -514,8 +510,13 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
514510

515511
// kilocode_change start
516512
function makeOpenRouterErrorReadable(error: any) {
513+
const metadata = error?.error?.metadata as { raw?: string; provider_name?: string } | undefined
514+
const parsedJson = safeJsonParse(metadata?.raw)
515+
const rawError = parsedJson as { error?: string & { message?: string }; detail?: string } | undefined
516+
517517
if (error?.code !== 429 && error?.code !== 418) {
518-
return `OpenRouter API Error: ${error?.message || error}`
518+
const errorMessage = rawError?.error?.message ?? rawError?.error ?? rawError?.detail ?? error?.message
519+
throw new Error(`${metadata?.provider_name ?? "Provider"} error: ${errorMessage ?? "unknown error"}`)
519520
}
520521

521522
try {

src/api/providers/utils/openai-error-handler.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55

66
import i18n from "../../../i18n/setup"
7-
import { isAnyRecognizedKiloCodeError } from "../../../shared/kilocode/errorUtils"
87

98
/**
109
* Handles OpenAI client errors and transforms them into user-friendly messages

src/core/assistant-message/AssistantMessageParser.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type ToolName, toolNames } from "@roo-code/types"
22
import { TextContent, ToolUse, ToolParamName, toolParamNames } from "../../shared/tools"
33
import { AssistantMessageContent } from "./parseAssistantMessage"
44
import { NativeToolCall, parseDoubleEncodedParams } from "./kilocode/native-tool-call"
5+
import Anthropic from "@anthropic-ai/sdk" // kilocode_change
56

67
/**
78
* Parser for assistant messages. Maintains state between chunks
@@ -75,7 +76,7 @@ export class AssistantMessageParser {
7576
* @param toolCalls Array of native tool call objects (may be partial during streaming). We
7677
* currently set parallel_tool_calls to false, so in theory there should only be 1 call.
7778
*/
78-
public processNativeToolCalls(toolCalls: NativeToolCall[]): void {
79+
public *processNativeToolCalls(toolCalls: NativeToolCall[]): Generator<Anthropic.ToolUseBlockParam> {
7980
for (const toolCall of toolCalls) {
8081
// Determine the tracking key
8182
// If we have an index, use that to look up or store the id
@@ -187,6 +188,7 @@ export class AssistantMessageParser {
187188
name: toolName as ToolName,
188189
params: parsedArgs,
189190
partial: false, // Now complete after accumulation
191+
toolUseId: accumulatedCall.id,
190192
}
191193

192194
// Add the tool use to content blocks
@@ -195,6 +197,13 @@ export class AssistantMessageParser {
195197
// Mark this tool call as processed
196198
this.processedNativeToolCallIds.add(toolCallId)
197199
this.nativeToolCallsAccumulator.delete(toolCallId)
200+
201+
yield {
202+
type: "tool_use",
203+
name: toolUse.name,
204+
id: toolUse.toolUseId ?? "",
205+
input: toolUse.params,
206+
}
198207
}
199208
}
200209
}

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { codebaseSearchTool } from "../tools/codebaseSearchTool"
4242
import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
4343
import { applyDiffToolLegacy } from "../tools/applyDiffTool"
4444
import { yieldPromise } from "../kilocode"
45+
import Anthropic from "@anthropic-ai/sdk" // kilocode_change
4546

4647
/**
4748
* Processes and presents assistant message content to the user interface.
@@ -60,7 +61,7 @@ import { yieldPromise } from "../kilocode"
6061
* as it becomes available.
6162
*/
6263

63-
export async function presentAssistantMessage(cline: Task, recursionDepth: number = 0 /*kilocode_change*/) {
64+
export async function presentAssistantMessage(cline: Task) {
6465
if (cline.abort) {
6566
throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`)
6667
}
@@ -247,16 +248,26 @@ export async function presentAssistantMessage(cline: Task, recursionDepth: numbe
247248
}
248249
}
249250

251+
const pushToolResult_withToolUseId_kilocode = (
252+
...items: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
253+
) => {
254+
if (block.toolUseId) {
255+
cline.userMessageContent.push({ type: "tool_result", tool_use_id: block.toolUseId, content: items })
256+
} else {
257+
cline.userMessageContent.push(...items)
258+
}
259+
}
260+
250261
if (cline.didRejectTool) {
251262
// Ignore any tool content after user has rejected tool once.
252263
if (!block.partial) {
253-
cline.userMessageContent.push({
264+
pushToolResult_withToolUseId_kilocode({
254265
type: "text",
255266
text: `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`,
256267
})
257268
} else {
258269
// Partial tool after user rejected a previous tool.
259-
cline.userMessageContent.push({
270+
pushToolResult_withToolUseId_kilocode({
260271
type: "text",
261272
text: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`,
262273
})
@@ -267,7 +278,7 @@ export async function presentAssistantMessage(cline: Task, recursionDepth: numbe
267278

268279
if (cline.didAlreadyUseTool) {
269280
// Ignore any content after a tool has already been used.
270-
cline.userMessageContent.push({
281+
pushToolResult_withToolUseId_kilocode({
271282
type: "text",
272283
text: `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
273284
})
@@ -276,13 +287,17 @@ export async function presentAssistantMessage(cline: Task, recursionDepth: numbe
276287
}
277288

278289
const pushToolResult = (content: ToolResponse) => {
279-
cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
290+
// kilocode_change start
291+
const items = new Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>()
292+
items.push({ type: "text", text: `${toolDescription()} Result:` })
280293

281294
if (typeof content === "string") {
282-
cline.userMessageContent.push({ type: "text", text: content || "(tool did not return anything)" })
295+
items.push({ type: "text", text: content || "(tool did not return anything)" })
283296
} else {
284-
cline.userMessageContent.push(...content)
297+
items.push(...content)
285298
}
299+
pushToolResult_withToolUseId_kilocode(...items)
300+
// kilocode_change end
286301

287302
// Once a tool result has been collected, ignore all other tool
288303
// uses since we should only ever present one tool result per
@@ -414,7 +429,7 @@ export async function presentAssistantMessage(cline: Task, recursionDepth: numbe
414429

415430
if (response === "messageResponse") {
416431
// Add user feedback to userContent.
417-
cline.userMessageContent.push(
432+
pushToolResult_withToolUseId_kilocode(
418433
{
419434
type: "text" as const,
420435
text: `Tool repetition limit reached. User feedback: ${text}`,
@@ -637,7 +652,7 @@ export async function presentAssistantMessage(cline: Task, recursionDepth: numbe
637652
// this function ourselves.
638653
// kilocode_change start: prevent excessive recursion
639654
await yieldPromise()
640-
await presentAssistantMessage(cline, recursionDepth + 1)
655+
await presentAssistantMessage(cline)
641656
// kilocode_change end
642657
return
643658
}
@@ -647,7 +662,7 @@ export async function presentAssistantMessage(cline: Task, recursionDepth: numbe
647662
if (cline.presentAssistantMessageHasPendingUpdates) {
648663
// kilocode_change start: prevent excessive recursion
649664
await yieldPromise()
650-
await presentAssistantMessage(cline, recursionDepth + 1)
665+
await presentAssistantMessage(cline)
651666
// kilocode_change end
652667
}
653668
}

src/core/task/Task.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
300300
assistantMessageContent: AssistantMessageContent[] = []
301301
presentAssistantMessageLocked = false
302302
presentAssistantMessageHasPendingUpdates = false
303-
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
303+
userMessageContent: (
304+
| Anthropic.TextBlockParam
305+
| Anthropic.ImageBlockParam
306+
| Anthropic.ToolResultBlockParam // kilocode_change
307+
)[] = []
304308
userMessageContentReady = false
305309
didRejectTool = false
306310
didAlreadyUseTool = false
@@ -2014,6 +2018,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
20142018
// limit error, which gets thrown on the first chunk).
20152019
const stream = this.attemptApiRequest()
20162020
let assistantMessage = ""
2021+
let assistantToolUses = new Array<Anthropic.Messages.ToolUseBlockParam>() // kilocode_change
20172022
let reasoningMessage = ""
20182023
let pendingGroundingSources: GroundingSource[] = []
20192024
this.isStreaming = true
@@ -2066,7 +2071,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
20662071
case "native_tool_calls": {
20672072
// Handle native OpenAI-format tool calls
20682073
// Process native tool calls through the parser
2069-
this.assistantMessageParser.processNativeToolCalls(chunk.toolCalls)
2074+
for (const toolUse of this.assistantMessageParser.processNativeToolCalls(
2075+
chunk.toolCalls,
2076+
)) {
2077+
assistantToolUses.push(toolUse)
2078+
}
20702079

20712080
// Update content blocks after processing native tool calls
20722081
const prevLength = this.assistantMessageContent.length
@@ -2417,12 +2426,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
24172426
// able to save the assistant's response.
24182427
let didEndLoop = false
24192428

2420-
// kilocode_change start: Check for tool use before determining if response is empty
2421-
const didToolUse = this.assistantMessageContent.some((block) => block.type === "tool_use")
2422-
// kilocode_change end
2423-
2424-
if (assistantMessage.length > 0 || didToolUse) {
2425-
// kilocode_change: also check for tool use
2429+
if (assistantMessage.length > 0 || assistantToolUses.length > 0 /* kilocode_change */) {
24262430
// Display grounding sources to the user if they exist
24272431
if (pendingGroundingSources.length > 0) {
24282432
const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`)
@@ -2433,10 +2437,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
24332437
})
24342438
}
24352439

2440+
// kilocode_change start: also add tool calls to history
2441+
const assistantMessageContent = new Array<Anthropic.Messages.ContentBlockParam>()
2442+
if (assistantMessage) {
2443+
assistantMessageContent.push({ type: "text", text: assistantMessage })
2444+
}
2445+
assistantMessageContent.push(...assistantToolUses)
24362446
await this.addToApiConversationHistory({
24372447
role: "assistant",
2438-
content: [{ type: "text", text: assistantMessage }],
2448+
content: assistantMessageContent,
24392449
})
2450+
// kilocode_change end
24402451

24412452
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
24422453

src/core/tools/attemptCompletionTool.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,18 @@ export async function attemptCompletionTool(
159159
})
160160

161161
toolResults.push(...formatResponse.imageBlocks(images))
162+
163+
// kilocode_change start
164+
if (block.toolUseId) {
165+
cline.userMessageContent.push({
166+
type: "tool_result",
167+
tool_use_id: block.toolUseId,
168+
content: [{ type: "text", text: `${toolDescription()} Result:` }, ...toolResults],
169+
})
170+
return
171+
}
172+
// kilocode_change end
173+
162174
cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
163175
cline.userMessageContent.push(...toolResults)
164176

src/core/tools/kilocode.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,53 @@ export async function blockFileReadWhenTooLarge(task: Task, relPath: string, con
4949
xmlContent: `<file><path>${relPath}</path><error>${errorMsg}</error></file>`,
5050
}
5151
}
52+
53+
type FileEntry = {
54+
path?: string
55+
lineRanges?: {
56+
start: number
57+
end: number
58+
}[]
59+
}
60+
61+
export function parseNativeFiles(nativeFiles: { path?: string; line_ranges?: string[] }[]) {
62+
const fileEntries = new Array<FileEntry>()
63+
for (const file of nativeFiles) {
64+
if (!file.path) continue
65+
66+
const fileEntry: FileEntry = {
67+
path: file.path,
68+
lineRanges: [],
69+
}
70+
71+
// Handle line_ranges array from native format
72+
if (file.line_ranges && Array.isArray(file.line_ranges)) {
73+
for (const range of file.line_ranges) {
74+
const match = String(range).match(/(\d+)-(\d+)/)
75+
if (match) {
76+
const [, start, end] = match.map(Number)
77+
if (!isNaN(start) && !isNaN(end)) {
78+
fileEntry.lineRanges?.push({ start, end })
79+
}
80+
}
81+
}
82+
}
83+
fileEntries.push(fileEntry)
84+
}
85+
return fileEntries
86+
}
87+
88+
export function getNativeReadFileToolDescription(blockName: string, files: FileEntry[]) {
89+
const paths = files.map((file) => file.path)
90+
if (paths.length === 0) {
91+
return `[${blockName} with no valid paths]`
92+
} else if (paths.length === 1) {
93+
// Modified part for single file
94+
return `[${blockName} for '${paths[0]}'. Reading multiple files at once is more efficient for the LLM. If other files are relevant to your current task, please read them simultaneously.]`
95+
} else if (paths.length <= 3) {
96+
const pathList = paths.map((p) => `'${p}'`).join(", ")
97+
return `[${blockName} for ${pathList}]`
98+
} else {
99+
return `[${blockName} for ${paths.length} files]`
100+
}
101+
}

src/core/tools/readFileTool.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { readLines } from "../../integrations/misc/read-lines"
1414
import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
1515
import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
1616
import { parseXml } from "../../utils/xml"
17-
import { blockFileReadWhenTooLarge } from "./kilocode"
17+
import { blockFileReadWhenTooLarge, getNativeReadFileToolDescription, parseNativeFiles } from "./kilocode"
1818
import {
1919
DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
2020
DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
@@ -26,7 +26,11 @@ import {
2626

2727
export function getReadFileToolDescription(blockName: string, blockParams: any): string {
2828
// Handle both single path and multiple files via args
29-
if (blockParams.args) {
29+
// kilocode_change start
30+
if (blockParams.files && Array.isArray(blockParams.files)) {
31+
return getNativeReadFileToolDescription(blockName, parseNativeFiles(blockParams.files))
32+
// kilocode_change end
33+
} else if (blockParams.args) {
3034
try {
3135
const parsed = parseXml(blockParams.args) as any
3236
const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
@@ -131,28 +135,7 @@ export async function readFileTool(
131135
// kilocode_change start
132136
// Handle native JSON format first (from OpenAI-style tool calls)
133137
if (nativeFiles && Array.isArray(nativeFiles)) {
134-
for (const file of nativeFiles) {
135-
if (!file.path) continue
136-
137-
const fileEntry: FileEntry = {
138-
path: file.path,
139-
lineRanges: [],
140-
}
141-
142-
// Handle line_ranges array from native format
143-
if (file.line_ranges && Array.isArray(file.line_ranges)) {
144-
for (const range of file.line_ranges) {
145-
const match = String(range).match(/(\d+)-(\d+)/)
146-
if (match) {
147-
const [, start, end] = match.map(Number)
148-
if (!isNaN(start) && !isNaN(end)) {
149-
fileEntry.lineRanges?.push({ start, end })
150-
}
151-
}
152-
}
153-
}
154-
fileEntries.push(fileEntry)
155-
}
138+
fileEntries.push(...parseNativeFiles(nativeFiles))
156139
// kilocode_change end
157140
} else if (argsXmlTag) {
158141
// Parse file entries from XML (new multi-file format)

0 commit comments

Comments
 (0)