Skip to content
Closed
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
31 changes: 31 additions & 0 deletions src/Tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test'

import { findToolByNameOrUniquePrefix, type Tools } from './Tool.js'

const tools = [
{ name: 'Read' },
{ name: 'Bash' },
{ name: 'Grep' },
] as Tools

describe('findToolByNameOrUniquePrefix', () => {
test('recovers an unambiguous provider-truncated built-in tool name', () => {
expect(findToolByNameOrUniquePrefix(tools, 'Rea')?.name).toBe('Read')
})

test('does not guess short, ambiguous, or MCP tool names', () => {
expect(findToolByNameOrUniquePrefix(tools, 'R')).toBeUndefined()
expect(
findToolByNameOrUniquePrefix(
[{ name: 'Read' }, { name: 'Ready' }] as Tools,
'Rea',
),
).toBeUndefined()
expect(
findToolByNameOrUniquePrefix(
[{ name: 'mcp__files__read' }] as Tools,
'mcp__files__rea',
),
).toBeUndefined()
})
})
19 changes: 19 additions & 0 deletions src/Tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,25 @@ export function findToolByName(tools: Tools, name: string): Tool | undefined {
return tools.find(t => toolMatchesName(t, name))
}

/**
* Resolves a provider-truncated built-in tool name only when the prefix is
* unambiguous among tools already available to the model. This keeps malformed
* calls such as `Rea` recoverable as `Read` without guessing MCP tool names or
* accepting very short, collision-prone prefixes.
*/
export function findToolByNameOrUniquePrefix(
tools: Tools,
name: string,
): Tool | undefined {
const exactMatch = findToolByName(tools, name)
if (exactMatch) return exactMatch

if (name.length < 3 || name.startsWith('mcp__')) return undefined

const prefixMatches = tools.filter(tool => tool.name.startsWith(name))
return prefixMatches.length === 1 ? prefixMatches[0] : undefined
}

export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
Expand Down
108 changes: 108 additions & 0 deletions src/services/api/openaiShim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2335,6 +2335,114 @@ test('preserves Gemini tool call extra_content from streaming chunks', async ()
})
})

test('assembles function names split across streaming tool-call deltas', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-split-tool-name',
object: 'chat.completion.chunk',
model: 'max/deepseek-v4-pro',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-split-name',
type: 'function',
function: {
name: 'Rea',
arguments: '',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-split-tool-name',
object: 'chat.completion.chunk',
model: 'max/deepseek-v4-pro',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
function: {
name: 'd',
arguments: '{"file_path":"/tmp/example.ts"}',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-split-tool-name',
object: 'chat.completion.chunk',
model: 'max/deepseek-v4-pro',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])

return makeSseResponse(chunks)
}) as FetchType

const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'max/deepseek-v4-pro',
system: 'test system',
messages: [{ role: 'user', content: 'Read the example file' }],
max_tokens: 64,
stream: true,
})
.withResponse()

const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}

const toolStarts = events.filter(
(event) =>
event.type === 'content_block_start' &&
typeof event.content_block === 'object' &&
event.content_block !== null &&
(event.content_block as Record<string, unknown>).type === 'tool_use',
) as Array<{ content_block: Record<string, unknown> }>
const inputDeltas = events.filter(
(event) =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
) as Array<{ delta: { partial_json: string } }>

expect(toolStarts.at(-1)?.content_block).toMatchObject({
type: 'tool_use',
id: 'function-call-split-name',
name: 'Read',
})
expect(inputDeltas.map((event) => event.delta.partial_json).join('')).toBe(
'{"file_path":"/tmp/example.ts"}',
)
})

test('normalizes plain string Bash tool arguments from OpenAI-compatible responses', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
Expand Down
65 changes: 32 additions & 33 deletions src/services/api/openaiShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,7 @@ async function* openaiStreamToAnthropic(
index: number
jsonBuffer: string
normalizeAtStop: boolean
nameWasContinued: boolean
extra_content?: Record<string, unknown>
}
>()
Expand Down Expand Up @@ -1503,6 +1504,7 @@ async function* openaiStreamToAnthropic(
index: toolBlockIndex,
jsonBuffer: initialArguments,
normalizeAtStop,
nameWasContinued: false,
extra_content: initEC,
})

Expand All @@ -1516,7 +1518,10 @@ async function* openaiStreamToAnthropic(
input: {},
...(initEC ? { extra_content: initEC } : {}),
...((initEC?.google as any)?.thought_signature
? { signature: (initEC.google as any).thought_signature }
? {
signature: (initEC?.google as any)
.thought_signature,
}
: {}),
},
}
Expand All @@ -1533,12 +1538,23 @@ async function* openaiStreamToAnthropic(
},
}
}
} else if (tc.function?.arguments) {
// Continuation of existing tool call
} else {
// Continuation of an existing tool call. OpenAI-compatible
// providers may split function names across SSE deltas (for
// example `Rea` then `d`). Preserve both name and argument
// fragments instead of executing a truncated unknown tool.
const active = activeToolCalls.get(tc.index)
if (active) {
if (tc.function.arguments) {
active.jsonBuffer += tc.function.arguments
const functionNameFragment = tc.function?.name
const argumentFragment = tc.function?.arguments

if (functionNameFragment) {
active.name += functionNameFragment
active.nameWasContinued = true
}

if (argumentFragment) {
active.jsonBuffer += argumentFragment
}

// Also capture extra_content/thought_signature if bundled with args
Expand All @@ -1556,7 +1572,7 @@ async function* openaiStreamToAnthropic(
}
}

if (active.normalizeAtStop) {
if (!argumentFragment || active.normalizeAtStop) {
continue
}

Expand All @@ -1565,29 +1581,10 @@ async function* openaiStreamToAnthropic(
index: active.index,
delta: {
type: 'input_json_delta',
partial_json: tc.function.arguments,
partial_json: argumentFragment,
},
}
}
} else {
// Chunk with only extra_content / thought_signature (Gemini thinking models
// may send thought_signature in a separate chunk from id/name/arguments)
const active = activeToolCalls.get(tc.index)
if (active) {
const lateSig = (tc as any).thought_signature as
string | undefined
const lateEC = tc.extra_content
? { ...tc.extra_content }
: lateSig
? { google: { thought_signature: lateSig } }
: undefined
if (lateEC) {
active.extra_content = {
...(active.extra_content ?? {}),
...lateEC,
}
}
}
}
}
}
Expand All @@ -1609,10 +1606,10 @@ async function* openaiStreamToAnthropic(
}
// Close active tool calls
for (const [, tc] of activeToolCalls) {
// Re-emit content_block_start with final extra_content so that
// late-arriving thought_signature chunks (Gemini thinking models)
// are reflected in the stored message block before it is finalized.
if (tc.extra_content) {
// Re-emit content_block_start when late metadata or function-name
// fragments arrived, so the stored block has the final tool name
// and any provider-specific signature before it is finalized.
if (tc.extra_content || tc.nameWasContinued) {
yield {
type: 'content_block_start' as const,
index: tc.index,
Expand All @@ -1621,10 +1618,12 @@ async function* openaiStreamToAnthropic(
id: tc.id,
name: tc.name,
input: {},
extra_content: tc.extra_content,
...((tc.extra_content.google as any)?.thought_signature
...(tc.extra_content
? { extra_content: tc.extra_content }
: {}),
...((tc.extra_content?.google as any)?.thought_signature
? {
signature: (tc.extra_content.google as any)
signature: (tc.extra_content?.google as any)
.thought_signature,
}
: {}),
Expand Down
24 changes: 22 additions & 2 deletions src/services/tools/StreamingToolExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import {
withMemoryCorrectionHint,
} from 'src/utils/messages.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js'
import {
findToolByNameOrUniquePrefix,
type Tools,
type ToolUseContext,
} from '../../Tool.js'
import { getAllBaseTools } from '../../tools.js'
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
import type { AssistantMessage, Message } from '../../types/message.js'
import { createChildAbortController } from '../../utils/abortController.js'
Expand Down Expand Up @@ -80,7 +85,22 @@ export class StreamingToolExecutor {
* Add a tool to the execution queue. Will start executing immediately if conditions allow.
*/
addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
const toolDefinition = findToolByName(this.toolDefinitions, block.name)
let toolDefinition = findToolByNameOrUniquePrefix(
this.toolDefinitions,
block.name,
)
if (!toolDefinition) {
const baseTool = findToolByNameOrUniquePrefix(
getAllBaseTools(),
block.name,
)
if (baseTool && !baseTool.isMcp && baseTool.name !== block.name) {
toolDefinition = baseTool
logForDebugging(
`Recovered truncated base tool name ${block.name} as ${baseTool.name}: ${block.id}`,
)
}
}
if (!toolDefinition) {
this.tools.push({
id: block.id,
Expand Down
32 changes: 28 additions & 4 deletions src/services/tools/toolExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import {
findToolByName,
findToolByNameOrUniquePrefix,
type Tool,
type ToolProgress,
type ToolProgressData,
Expand Down Expand Up @@ -388,16 +389,39 @@ export async function* runToolUse(
): AsyncGenerator<MessageUpdateLazy, void> {
const toolName = toolUse.name
// First try to find in the available tools (what the model sees)
let tool = findToolByName(toolUseContext.options.tools, toolName)
let tool = findToolByNameOrUniquePrefix(
toolUseContext.options.tools,
toolName,
)

if (tool && tool.name !== toolName) {
logForDebugging(
`Recovered truncated tool name ${toolName} as ${tool.name}: ${toolUse.id}`,
)
}

// If not found, check if it's a deprecated tool being called by alias
// (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop")
// Only fall back for tools where the name matches an alias, not the primary name
if (!tool) {
const fallbackTool = findToolByName(getAllBaseTools(), toolName)
// Only use fallback if the tool was found via alias (deprecated name)
if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
const fallbackTool = findToolByNameOrUniquePrefix(
getAllBaseTools(),
toolName,
)
// A model can only call a tool it received in its schema. Recover an exact
// alias or an unambiguous truncated built-in name when filtering removed
// the original definition from this execution path.
if (
fallbackTool &&
(fallbackTool.aliases?.includes(toolName) ||
(!fallbackTool.isMcp && fallbackTool.name !== toolName))
) {
tool = fallbackTool
if (tool.name !== toolName) {
logForDebugging(
`Recovered truncated base tool name ${toolName} as ${tool.name}: ${toolUse.id}`,
)
}
}
}
const messageId = assistantMessage.message.id
Expand Down
Loading