fix(costrict): recover task updates from missing tool calls

## Bug 详情
CSC 在相同模型和输入下,任务流程可能先成功执行 TaskCreate,随后模型返回 stop_reason=tool_use 但没有实际 tool_use block,导致 TaskUpdate/update_todo_list 没有执行,最终任务状态更新失败。

## 根因
CoStrict/OpenAI 兼容流在工具参数不完整或模型续轮缺少工具块时,缺少稳定的结构化恢复路径;query 层原先无法基于已发生的任务工具调用链路区分普通缺失工具调用和任务状态更新流程。

## 修复方案
基于结构化 tool_use 历史识别任务流程,优先引导 CoStrict 使用 update_todo_list 恢复任务清单状态;同时为 CoStrict 工具 JSON 流增加无效参数保留和错误结果回传,避免执行 malformed tool input。

## 变更要点
- 新增 update_todo_list 兼容工具,并注册到 TaskV2 工具列表
- query 缺失 tool_use 恢复改为基于 TaskCreate/TaskUpdate/TaskList/TaskGet/update_todo_list 结构化工具历史判断
- CoStrict provider 保留 malformed tool call 的 id/name 并阻止执行无效参数
- 工具执行和 streaming executor 对 invalid tool call 返回错误 tool_result
- 补充 query 恢复、CoStrict provider、工具执行、消息规范化和 update_todo_list 单测

## 自测
- bun test src/__tests__/queryMissingToolUseRecovery.test.ts
- bun test src/costrict/provider/index.test.ts
- bun test src/utils/__tests__/messages.test.ts
- bun test src/services/tools/__tests__/StreamingToolExecutor.test.ts packages/builtin-tools/src/tools/UpdateTodoListCompatTool/__tests__/UpdateTodoListCompatTool.test.ts
- bun run typecheck 仍有当前工作区既有无关错误,未由本次修改引入
This commit is contained in:
IronRookieCoder 2026-05-20 13:44:12 +08:00
parent 321be8a128
commit 4cb047ff48
18 changed files with 1341 additions and 68 deletions

View File

@ -9,6 +9,7 @@
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
"gray-matter": "^4.0.3",
"hono": "^4.12.15",
"partial-json": "^0.1.7",
"undici": "^7.24.6",
"ws": "^8.20.0",
},
@ -2373,6 +2374,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],

View File

@ -85,6 +85,7 @@
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
"gray-matter": "^4.0.3",
"hono": "^4.12.15",
"partial-json": "^0.1.7",
"semver": "^7.7.4",
"undici": "^7.24.6",
"ws": "^8.20.0"

View File

@ -31,6 +31,7 @@ export { SkillTool } from './tools/SkillTool/SkillTool.js'
export { TaskOutputTool } from './tools/TaskOutputTool/TaskOutputTool.js'
export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
export { UpdateTodoListCompatTool } from './tools/UpdateTodoListCompatTool/UpdateTodoListCompatTool.js'
export { SearchExtraToolsTool } from './tools/SearchExtraToolsTool/SearchExtraToolsTool.js'
export { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'

View File

@ -0,0 +1,302 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
getTaskListId,
isTodoV2Enabled,
listTasks,
type TaskStatus,
} from 'src/utils/tasks.js'
import { getAPIProvider } from 'src/utils/model/providers.js'
import { TaskCreateTool } from '../TaskCreateTool/TaskCreateTool.js'
import { TaskUpdateTool } from '../TaskUpdateTool/TaskUpdateTool.js'
import { UPDATE_TODO_LIST_COMPAT_TOOL_NAME } from './constants.js'
import { DESCRIPTION, PROMPT } from './prompt.js'
type ParsedTodo = {
subject: string
status: TaskStatus
}
type SyncedTodo = ParsedTodo & {
taskId: string
}
const inputSchema = lazySchema(() =>
z.strictObject({
todos: z
.string()
.min(1)
.describe('Full markdown checklist using [ ], [-], [x] markers'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
created: z.array(
z.object({
taskId: z.string(),
subject: z.string(),
status: z.enum(['pending', 'in_progress', 'completed']),
}),
),
updated: z.array(
z.object({
taskId: z.string(),
subject: z.string(),
status: z.enum(['pending', 'in_progress', 'completed']),
}),
),
unchanged: z.array(
z.object({
taskId: z.string(),
subject: z.string(),
status: z.enum(['pending', 'in_progress', 'completed']),
}),
),
ignoredLines: z.array(z.string()),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
function normalizeSubject(subject: string): string {
return subject.trim().replace(/\s+/g, ' ')
}
function toActiveForm(subject: string): string {
return subject
}
function parseTodoLine(line: string): ParsedTodo | null {
const match = line.match(/^\s*\[( |-|x|X)\]\s+(.+?)\s*$/)
if (!match) return null
const marker = match[1]
const subject = normalizeSubject(match[2] ?? '')
if (!subject) return null
const status: TaskStatus =
marker === ' '
? 'pending'
: marker === '-'
? 'in_progress'
: 'completed'
return { subject, status }
}
function parseTodos(todos: string): {
parsed: ParsedTodo[]
ignoredLines: string[]
} {
const parsed: ParsedTodo[] = []
const ignoredLines: string[] = []
const seenSubjects = new Set<string>()
for (const rawLine of todos.split(/\r?\n/)) {
const trimmed = rawLine.trim()
if (!trimmed) continue
const todo = parseTodoLine(rawLine)
if (!todo) {
ignoredLines.push(rawLine)
continue
}
const key = todo.subject.toLowerCase()
if (seenSubjects.has(key)) continue
seenSubjects.add(key)
parsed.push(todo)
}
return { parsed, ignoredLines }
}
export const UpdateTodoListCompatTool = buildTool({
name: UPDATE_TODO_LIST_COMPAT_TOOL_NAME,
searchHint:
'preferred CoStrict tool to create or update a markdown task checklist',
maxResultSizeChars: 100_000,
alwaysLoad: true,
async description() {
return DESCRIPTION
},
async prompt() {
return PROMPT
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'UpdateTodoList'
},
isEnabled() {
return getAPIProvider() === 'costrict' && isTodoV2Enabled()
},
isConcurrencySafe() {
return false
},
toAutoClassifierInput(input) {
return input.todos
},
async checkPermissions(input) {
return { behavior: 'allow', updatedInput: input }
},
renderToolUseMessage() {
return null
},
async call({ todos }, context) {
const { parsed, ignoredLines } = parseTodos(todos)
if (parsed.length === 0) {
return {
data: {
created: [],
updated: [],
unchanged: [],
ignoredLines,
error:
'No valid todo lines found. Use [ ], [-], or [x] markers followed by task text.',
},
}
}
const existingTasks = await listTasks(getTaskListId())
const existingBySubject = new Map(
existingTasks.map(task => [
normalizeSubject(task.subject).toLowerCase(),
task,
]),
)
const created: SyncedTodo[] = []
const updated: SyncedTodo[] = []
const unchanged: SyncedTodo[] = []
for (const todo of parsed) {
const key = todo.subject.toLowerCase()
const existing = existingBySubject.get(key)
if (!existing) {
const result = await TaskCreateTool.call(
{
subject: todo.subject,
description: todo.subject,
activeForm: toActiveForm(todo.subject),
metadata: { costrictCompatTodo: true },
},
context,
)
const taskId = result.data.task.id
let syncedStatus: TaskStatus = 'pending'
if (todo.status !== 'pending') {
const updateResult = await TaskUpdateTool.call(
{
taskId,
status: todo.status,
},
context,
)
if (!updateResult.data.success) {
ignoredLines.push(
`[${todo.status}] ${todo.subject}: ${updateResult.data.error ?? 'Task status update failed'}`,
)
} else {
syncedStatus = todo.status
}
}
const synced = {
taskId,
subject: todo.subject,
status: syncedStatus,
}
created.push(synced)
existingBySubject.set(key, {
id: taskId,
subject: todo.subject,
description: todo.subject,
activeForm: toActiveForm(todo.subject),
owner: undefined,
status: syncedStatus,
blocks: [],
blockedBy: [],
metadata: { costrictCompatTodo: true },
})
continue
}
if (existing.status === todo.status) {
unchanged.push({
taskId: existing.id,
subject: existing.subject,
status: existing.status,
})
continue
}
const result = await TaskUpdateTool.call(
{
taskId: existing.id,
status: todo.status,
},
context,
)
if (result.data.success) {
updated.push({
taskId: existing.id,
subject: existing.subject,
status: todo.status,
})
} else {
ignoredLines.push(
`[${todo.status}] ${todo.subject}: ${result.data.error ?? 'Task status update failed'}`,
)
}
}
return {
data: {
created,
updated,
unchanged,
ignoredLines,
},
}
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
const { created, updated, unchanged, ignoredLines, error } =
content as Output
if (error) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: error,
}
}
const lines = [
'Todo list updated successfully.',
`Created: ${created.length}; Updated: ${updated.length}; Unchanged: ${unchanged.length}.`,
]
if (ignoredLines.length > 0) {
lines.push(`Ignored lines: ${ignoredLines.length}.`)
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: lines.join('\n'),
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdir, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
mock.module('bun:bundle', () => ({
feature: () => false,
}))
mock.module('src/utils/model/providers.js', () => ({
getAPIProvider: () => 'costrict',
}))
mock.module('src/utils/hooks.js', () => ({
executeTaskCreatedHooks: async function* () {},
getTaskCreatedHookMessage: (message: unknown) => String(message),
executeTaskCompletedHooks: async function* () {},
getTaskCompletedHookMessage: (message: unknown) => String(message),
}))
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
}))
mock.module('src/utils/agentSwarmsEnabled.js', () => ({
isAgentSwarmsEnabled: () => false,
}))
mock.module('src/utils/teammate.js', () => ({
getAgentId: () => undefined,
getAgentName: () => undefined,
getTeammateColor: () => undefined,
getTeamName: () => undefined,
}))
mock.module('src/utils/teammateMailbox.js', () => ({
writeToMailbox: async () => undefined,
}))
import { UpdateTodoListCompatTool } from '../UpdateTodoListCompatTool.js'
import {
getTaskListId,
listTasks,
resetTaskList,
} from 'src/utils/tasks.js'
import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js'
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
const originalEnableTasks = process.env.CLAUDE_CODE_ENABLE_TASKS
let tempConfigDir: string
function clearConfigDirCache() {
getClaudeConfigHomeDir.cache?.clear?.()
}
function createContext() {
return {
setAppState: (updater: (state: { expandedView?: string }) => unknown) => {
updater({})
},
abortController: new AbortController(),
} as never
}
describe('UpdateTodoListCompatTool', () => {
beforeEach(async () => {
tempConfigDir = join(tmpdir(), `csc-update-todo-compat-${Date.now()}`)
await mkdir(tempConfigDir, { recursive: true })
process.env.CLAUDE_CONFIG_DIR = tempConfigDir
process.env.CLAUDE_CODE_TASK_LIST_ID = `compat-${Date.now()}`
process.env.CLAUDE_CODE_ENABLE_TASKS = '1'
clearConfigDirCache()
await resetTaskList(getTaskListId())
})
afterEach(async () => {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
if (originalTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalTaskListId
}
if (originalEnableTasks === undefined) {
delete process.env.CLAUDE_CODE_ENABLE_TASKS
} else {
process.env.CLAUDE_CODE_ENABLE_TASKS = originalEnableTasks
}
clearConfigDirCache()
await rm(tempConfigDir, { recursive: true, force: true })
})
test('creates tasks from plugin-style markdown checklist statuses', async () => {
const result = await UpdateTodoListCompatTool.call(
{
todos:
'[ ] 创建任务列表\n[-] 标记第一个任务为完成\n[x] 验证任务列表更新功能正常',
},
createContext(),
)
expect(result.data.error).toBeUndefined()
expect(result.data.created).toHaveLength(3)
const tasks = await listTasks(getTaskListId())
expect(tasks.map(task => [task.subject, task.status])).toEqual([
['创建任务列表', 'pending'],
['标记第一个任务为完成', 'in_progress'],
['验证任务列表更新功能正常', 'completed'],
])
})
test('updates existing task status instead of creating duplicates', async () => {
await UpdateTodoListCompatTool.call(
{
todos: '[ ] 创建任务列表\n[ ] 标记第一个任务为完成',
},
createContext(),
)
const result = await UpdateTodoListCompatTool.call(
{
todos: '[x] 创建任务列表\n[-] 标记第一个任务为完成',
},
createContext(),
)
expect(result.data.created).toHaveLength(0)
expect(result.data.updated).toHaveLength(2)
const tasks = await listTasks(getTaskListId())
expect(tasks).toHaveLength(2)
expect(tasks.map(task => [task.subject, task.status])).toEqual([
['创建任务列表', 'completed'],
['标记第一个任务为完成', 'in_progress'],
])
})
test('returns recoverable error for invalid checklist input', async () => {
const result = await UpdateTodoListCompatTool.call(
{
todos: '创建任务列表\n完成第一项',
},
createContext(),
)
expect(result.data.created).toHaveLength(0)
expect(result.data.error).toContain('No valid todo lines found')
expect(await listTasks(getTaskListId())).toHaveLength(0)
})
test('is enabled only for CoStrict TaskV2 sessions', () => {
expect(UpdateTodoListCompatTool.isEnabled()).toBe(true)
})
test('prompt positions checklist sync ahead of low-level task tools', async () => {
const prompt = await UpdateTodoListCompatTool.prompt()
expect(prompt).toContain('preferred tool for ordinary todo/checklist')
expect(prompt).toContain('TaskCreate/TaskUpdate/TaskList/TaskGet')
expect(prompt).toContain('send the final complete checklist in a single call')
})
})

View File

@ -0,0 +1 @@
export const UPDATE_TODO_LIST_COMPAT_TOOL_NAME = 'update_todo_list'

View File

@ -0,0 +1,15 @@
export const DESCRIPTION =
'Preferred CoStrict tool for creating and updating a simple task checklist from markdown.'
export const PROMPT = `Use this tool to create or update the current task checklist in CoStrict.
This is the preferred tool for ordinary todo/checklist progress tracking, especially when the user asks to create a task list, update task statuses, mark items complete, or verify that task list updates work.
Use the lower-level TaskCreate/TaskUpdate/TaskList/TaskGet tools only when you need structured task-management features such as owner assignment, dependencies, teammate workflows, reading a specific task by ID, task output, or stopping background tasks.
Provide the complete checklist every time in the todos string. Each non-empty line must use one of these markers:
- [ ] pending task
- [-] task currently in progress
- [x] completed task
The tool creates missing tasks and updates the status of existing tasks with the same text. If the user asks to create a list and mark it complete in one request, send the final complete checklist in a single call.`

View File

@ -1,14 +1,23 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { randomUUID } from 'crypto'
import { resetStateForTests } from '../bootstrap/state'
import { query } from '../query'
import { getEmptyToolPermissionContext } from '../Tool'
import type { AssistantMessage } from '../types/message'
import { buildTool, getEmptyToolPermissionContext } from '../Tool'
import type { AssistantMessage, Message, UserMessage } from '../types/message'
import { createUserMessage } from '../utils/messages'
import { asSystemPrompt } from '../utils/systemPromptType'
import { lazySchema } from '../utils/lazySchema'
import { z } from 'zod/v4'
let mockAPIProvider = 'firstParty'
mock.module('../utils/model/providers.js', () => ({
getAPIProvider: () => mockAPIProvider,
}))
beforeEach(() => {
resetStateForTests()
mockAPIProvider = 'firstParty'
})
function createToolUseContext(): any {
@ -54,6 +63,36 @@ function createToolUseContext(): any {
} as any
}
const updateTodoListTool = buildTool({
name: 'update_todo_list',
maxResultSizeChars: 1000,
async description() {
return 'Update todo list'
},
async prompt() {
return 'Update todo list'
},
get inputSchema() {
return lazySchema(() => z.object({ todos: z.string() }))()
},
isConcurrencySafe() {
return false
},
async call() {
return { data: {} }
},
renderToolUseMessage() {
return null
},
mapToolResultToToolResultBlockParam(_content, toolUseID) {
return {
type: 'tool_result',
tool_use_id: toolUseID,
content: 'ok',
}
},
})
function createAssistantMessage(
content: Array<{ type: 'text'; text: string }>,
stopReason: 'end_turn' | 'tool_use',
@ -81,6 +120,65 @@ function createAssistantMessage(
} as unknown as AssistantMessage
}
function createTaskCreateAssistantMessage(): AssistantMessage {
return {
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
requestId: undefined,
message: {
id: 'msg_task_create',
type: 'message',
role: 'assistant',
model: 'test-model',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
content: [
{
type: 'tool_use',
id: 'toolu_task_create',
name: 'TaskCreate',
input: {
subject: '测试任务',
description: '测试任务',
},
},
],
},
} as unknown as AssistantMessage
}
function createTaskCreateResultMessage(
assistantMessage: AssistantMessage,
): UserMessage {
return createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_task_create',
content: 'Task #1 created successfully: 测试任务',
},
],
toolUseResult: { task: { id: '1', subject: '测试任务' } },
sourceToolAssistantUUID: assistantMessage.uuid,
})
}
function createTaskFlowMessages(): Message[] {
const assistant = createTaskCreateAssistantMessage()
return [
createUserMessage({ content: '使用任务列表跟踪并标记完成' }),
assistant,
createTaskCreateResultMessage(assistant),
]
}
describe('query missing tool_use recovery', () => {
test('continues once when provider reports tool_use without a tool_use block', async () => {
const toolUseContext = createToolUseContext()
@ -142,7 +240,179 @@ describe('query missing tool_use recovery', () => {
)
})
test('surfaces an error immediately for missing task tool use', async () => {
test('retries once for missing task tool use', async () => {
const toolUseContext = createToolUseContext()
let callCount = 0
const observedPrompts: unknown[] = []
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* ({ messages }: { messages: unknown[] }) {
callCount += 1
observedPrompts.push(messages)
yield createAssistantMessage(
[
{
type: 'text',
text:
callCount === 1
? '现在让我查看任务列表,然后更新它们的状态。'
: '任务状态已更新完成。',
},
],
callCount === 1 ? 'tool_use' : 'end_turn',
)
},
}
const generator = query({
messages: createTaskFlowMessages(),
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let next = await generator.next()
while (!next.done) {
next = await generator.next()
}
expect(next.value.reason).toBe('completed')
expect(callCount).toBe(2)
expect(JSON.stringify(observedPrompts[1])).toContain(
'Continue by issuing the missing tool call now',
)
})
test('nudges CoStrict task recovery toward update_todo_list', async () => {
mockAPIProvider = 'costrict'
const toolUseContext = createToolUseContext()
let callCount = 0
const observedPrompts: unknown[] = []
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* ({ messages }: { messages: unknown[] }) {
callCount += 1
observedPrompts.push(messages)
yield createAssistantMessage(
[
{
type: 'text',
text:
callCount === 1
? '现在让我查看任务列表,然后更新它们的状态。'
: '任务状态已更新完成。',
},
],
callCount === 1 ? 'tool_use' : 'end_turn',
)
},
}
const generator = query({
messages: createTaskFlowMessages(),
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let next = await generator.next()
while (!next.done) {
next = await generator.next()
}
expect(next.value.reason).toBe('completed')
expect(callCount).toBe(2)
expect(JSON.stringify(observedPrompts[1])).toContain('update_todo_list')
expect(JSON.stringify(observedPrompts[1])).toContain(
'complete markdown checklist',
)
})
test('nudges task recovery toward update_todo_list when the tool is available', async () => {
const toolUseContext = createToolUseContext()
toolUseContext.options.tools = [updateTodoListTool]
let callCount = 0
const observedPrompts: unknown[] = []
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* ({ messages }: { messages: unknown[] }) {
callCount += 1
observedPrompts.push(messages)
yield createAssistantMessage(
[
{
type: 'text',
text:
callCount === 1
? '任务已创建。现在让我查看任务列表并更新状态。'
: '任务状态已更新完成。',
},
],
callCount === 1 ? 'tool_use' : 'end_turn',
)
},
}
const generator = query({
messages: createTaskFlowMessages(),
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let next = await generator.next()
while (!next.done) {
next = await generator.next()
}
expect(next.value.reason).toBe('completed')
expect(callCount).toBe(2)
expect(JSON.stringify(observedPrompts[1])).toContain('update_todo_list')
})
test('surfaces a task-specific error when missing task tool use recovery fails', async () => {
const toolUseContext = createToolUseContext()
let callCount = 0
@ -164,11 +434,7 @@ describe('query missing tool_use recovery', () => {
}
const generator = query({
messages: [
createUserMessage({
content: '使用 TaskUpdate 更新任务状态',
}),
],
messages: createTaskFlowMessages(),
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
@ -189,7 +455,7 @@ describe('query missing tool_use recovery', () => {
}
expect(next.value.reason).toBe('model_error')
expect(callCount).toBe(1)
expect(callCount).toBe(2)
expect(JSON.stringify(yielded)).toContain(
'Task tool call failed: the model ended with stop_reason=tool_use',
)

View File

@ -40,6 +40,34 @@ function makeContentBlockStart(
} as any
}
function makeToolUseStart(
index: number,
id: string,
name: string,
): BetaRawMessageStreamEvent {
return {
type: 'content_block_start',
index,
content_block: {
type: 'tool_use',
id,
name,
input: {},
},
} as any
}
function makeInputJsonDelta(
index: number,
partialJson: string,
): BetaRawMessageStreamEvent {
return {
type: 'content_block_delta',
index,
delta: { type: 'input_json_delta', partial_json: partialJson },
} as any
}
function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent {
return {
type: 'content_block_delta',
@ -111,7 +139,32 @@ mock.module('@ant/model-provider', () => ({
mock.module('../../utils/messages.js', () => ({
normalizeMessagesForAPI: (msgs: any) => msgs,
normalizeContentFromAPI: (blocks: any[]) => blocks,
ensureToolResultPairing: (msgs: any) => msgs,
normalizeContentFromAPI: (
blocks: any[],
_tools: any,
_agentId: any,
opts?: any,
) =>
blocks.map(block => {
if (
opts?.preserveInvalidToolCall &&
block.type === 'tool_use' &&
block.invalidToolCallError
) {
return {
...block,
input: {},
}
}
if (block.type === 'tool_use' && typeof block.input === 'string') {
return {
...block,
input: JSON.parse(block.input),
}
}
return block
}),
createAssistantAPIErrorMessage: (opts: any) => ({
type: 'assistant',
message: {
@ -354,4 +407,83 @@ describe('queryModelCoStrict', () => {
'CoStrict API Error: The current model does not support image input. Switch to a multimodal or vision-capable model and try again.',
)
})
test('marks malformed final tool JSON as invalid while preserving id and name', async () => {
const events = [
makeMessageStart(),
makeToolUseStart(0, 'toolu_bad', 'TaskUpdate'),
makeInputJsonDelta(0, '{"status": '),
makeInputJsonDelta(0, 'in_progresss"'),
makeInputJsonDelta(0, ', "taskId": "1"}'),
makeContentBlockStop(0),
makeMessageDelta('tool_use', 5),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(events)
expect(assistantMessages).toHaveLength(1)
const toolUse = (assistantMessages[0]!.message.content as any[])[0]
expect(toolUse).toMatchObject({
type: 'tool_use',
id: 'toolu_bad',
name: 'TaskUpdate',
input: {},
})
expect(toolUse.invalidToolCallError).toContain(
'invalid tool call arguments for TaskUpdate',
)
})
test('does not execute partial-json result when final raw input is malformed', async () => {
const events = [
makeMessageStart(),
makeToolUseStart(0, 'toolu_partial_bad', 'TaskUpdate'),
makeInputJsonDelta(0, '{"status":"in_progress","taskId":"1"}'),
makeInputJsonDelta(0, ' trailing'),
makeContentBlockStop(0),
makeMessageDelta('tool_use', 5),
makeMessageStop(),
]
const { assistantMessages, streamEvents } = await runQueryModel(events)
const parsedPartialEvent = streamEvents.find(
event =>
(event.event as any).type === 'content_block_delta' &&
(event.event as any).delta.parsed_tool_input !== undefined,
)
expect((parsedPartialEvent!.event as any).delta.parsed_tool_input).toEqual({
status: 'in_progress',
taskId: '1',
})
const toolUse = (assistantMessages[0]!.message.content as any[])[0]
expect(toolUse.input).toEqual({})
expect(toolUse.invalidToolCallError).toContain(
'invalid tool call arguments for TaskUpdate',
)
})
test('keeps valid TaskUpdate JSON executable', async () => {
const events = [
makeMessageStart(),
makeToolUseStart(0, 'toolu_good', 'TaskUpdate'),
makeInputJsonDelta(0, '{"status":"completed","taskId":"1"}'),
makeContentBlockStop(0),
makeMessageDelta('tool_use', 5),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(events)
const toolUse = (assistantMessages[0]!.message.content as any[])[0]
expect(toolUse).toMatchObject({
type: 'tool_use',
id: 'toolu_good',
name: 'TaskUpdate',
input: { status: 'completed', taskId: '1' },
})
expect(toolUse.invalidToolCallError).toBeUndefined()
})
})

View File

@ -30,9 +30,11 @@ import { addToTotalSessionCost } from '../../cost-tracker.js'
import { calculateUSDCost } from '../../utils/modelCost.js'
import {
createAssistantAPIErrorMessage,
ensureToolResultPairing,
normalizeContentFromAPI,
} from '../../utils/messages.js'
import { randomUUID } from 'crypto'
import { parse as parsePartialJSON } from 'partial-json'
import { createCoStrictFetch } from './fetch.js'
import { resolveCoStrictModel } from './modelMapping.js'
import { getCoStrictBaseURL } from './auth.js'
@ -49,7 +51,35 @@ import {
import { getModelMaxOutputTokens } from '../../utils/context.js'
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object'
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return isRecord(value)
}
function tryParsePartialToolInput(
rawInput: string,
): Record<string, unknown> | undefined {
try {
const parsed = parsePartialJSON(rawInput)
return isPlainRecord(parsed) ? parsed : undefined
} catch {
return undefined
}
}
function getInvalidToolInputMessage(toolName: string): string {
return `Model response error: invalid tool call arguments for ${toolName}. The tool call was not executed.`
}
type ToolCallStreamState = {
index: number
id: string
name: string
rawInput: string
partialInput?: Record<string, unknown>
finalParseError?: string
}
function contentContainsImage(content: unknown): boolean {
@ -129,7 +159,9 @@ export async function* queryModelCoStrict(
)
// 4. 规范化消息
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
const messagesForAPI = ensureToolResultPairing(
normalizeMessagesForAPI(messages, tools),
)
if (
modelInfo?.supportsImages === false &&
messagesContainImages(messagesForAPI)
@ -227,6 +259,7 @@ export async function* queryModelCoStrict(
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, costrictModel)
const contentBlocks: Record<number, any> = {}
const toolCallStates = new Map<number, ToolCallStreamState>()
let partialMessage: any
let stopReason: string | null = null
let usage = {
@ -237,6 +270,18 @@ export async function* queryModelCoStrict(
}
let ttftMs = 0
const start = Date.now()
const finalizeToolCallStates = () => {
for (const state of toolCallStates.values()) {
try {
const parsed = JSON.parse(state.rawInput)
if (!isPlainRecord(parsed)) {
state.finalParseError = getInvalidToolInputMessage(state.name)
}
} catch {
state.finalParseError = getInvalidToolInputMessage(state.name)
}
}
}
const assembleFinalAssistantOutputs = (): (
| AssistantMessage
| SystemAPIErrorMessage
@ -244,16 +289,32 @@ export async function* queryModelCoStrict(
const outputs: (AssistantMessage | SystemAPIErrorMessage)[] = []
if (!partialMessage) return outputs
const allBlocks = Object.keys(contentBlocks)
const allBlocksWithIndices = Object.keys(contentBlocks)
.sort((a, b) => Number(a) - Number(b))
.map(k => contentBlocks[Number(k)])
.filter(Boolean)
.map(k => ({ index: Number(k), block: contentBlocks[Number(k)] }))
.filter(entry => Boolean(entry.block))
const allBlocks = allBlocksWithIndices.map(entry => entry.block)
if (allBlocks.length > 0) {
for (const { index, block } of allBlocksWithIndices) {
if (block?.type !== 'tool_use') continue
const state = toolCallStates.get(index)
if (!state) continue
block.input = state.rawInput
if (state.finalParseError) {
block.invalidToolCallError = state.finalParseError
}
}
outputs.push({
message: {
...partialMessage,
content: normalizeContentFromAPI(allBlocks, tools, options.agentId),
content: normalizeContentFromAPI(
allBlocks,
tools,
options.agentId,
{ preserveInvalidToolCall: true },
),
usage,
stop_reason: stopReason,
stop_sequence: null,
@ -295,6 +356,12 @@ export async function* queryModelCoStrict(
const cb = (event as any).content_block
if (cb.type === 'tool_use') {
contentBlocks[idx] = { ...cb, input: '' }
toolCallStates.set(idx, {
index: idx,
id: String(cb.id ?? ''),
name: typeof cb.name === 'string' ? cb.name : '',
rawInput: '',
})
} else if (cb.type === 'text') {
contentBlocks[idx] = { ...cb, text: '' }
} else if (cb.type === 'thinking') {
@ -312,7 +379,18 @@ export async function* queryModelCoStrict(
if (delta.type === 'text_delta') {
block.text = (block.text || '') + delta.text
} else if (delta.type === 'input_json_delta') {
block.input = (block.input || '') + delta.partial_json
const fragment = String(delta.partial_json ?? '')
block.input = (block.input || '') + fragment
const state = toolCallStates.get(idx)
if (state) {
state.rawInput += fragment
const partialInput = tryParsePartialToolInput(state.rawInput)
if (partialInput) {
state.partialInput = partialInput
;(delta as Record<string, unknown>).parsed_tool_input =
partialInput
}
}
} else if (delta.type === 'thinking_delta') {
block.thinking = (block.thinking || '') + delta.thinking
} else if (delta.type === 'signature_delta') {
@ -335,10 +413,12 @@ export async function* queryModelCoStrict(
}
case 'message_stop': {
if (partialMessage) {
finalizeToolCallStates()
for (const output of assembleFinalAssistantOutputs()) {
yield output
}
partialMessage = null
toolCallStates.clear()
}
break
}
@ -360,6 +440,7 @@ export async function* queryModelCoStrict(
}
if (partialMessage) {
finalizeToolCallStates()
for (const output of assembleFinalAssistantOutputs()) {
yield output
}

View File

@ -122,6 +122,11 @@ import {
} from './services/langfuse/index.js'
import { getAPIProvider } from './utils/model/providers.js'
import { uploadSessionTurn } from './utils/sessionDataUploader.js'
import { UPDATE_TODO_LIST_COMPAT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/UpdateTodoListCompatTool/constants.js'
import { TASK_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/constants.js'
import { TASK_UPDATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskUpdateTool/constants.js'
import { TASK_LIST_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskListTool/constants.js'
import { TASK_GET_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskGetTool/constants.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const snipModule = feature('HISTORY_SNIP')
@ -207,24 +212,77 @@ function isMissingToolUseResponse(msg: Message | undefined): boolean {
function isTaskRelatedMissingToolUseResponse(
msg: Message | undefined,
messages: readonly Message[] = [],
): boolean {
if (!isMissingToolUseResponse(msg)) return false
if (msg?.type !== 'assistant') return false
const content = msg.message?.content
if (!Array.isArray(content)) return false
const text = content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
return /Task(Create|Update|List|Get)|\btask(s)?\b|任务/.test(text)
return hasRecentTaskToolUse(messages)
}
const MISSING_TOOL_USE_RECOVERY_MESSAGE =
'Your previous response ended with stop_reason=tool_use but did not include a tool_use block. Continue by issuing the missing tool call now. If the user asked to update task state, call TaskUpdate with the appropriate taskId and status; do not just describe the action.'
const COSTRICT_TASK_TOOL_USE_RECOVERY_MESSAGE =
'Your previous response ended with stop_reason=tool_use but did not include a tool_use block. Continue by issuing the missing tool call now. For task checklist updates in CoStrict, call update_todo_list with the complete markdown checklist using [ ], [-], and [x] markers; do not just describe the action.'
const MISSING_TOOL_USE_FAILURE_MESSAGE =
'Model response error: the model indicated it wanted to call a tool, but no tool call was provided after retry. The requested action did not run.'
const TASK_TOOL_USE_FAILURE_MESSAGE =
'Task tool call failed: the model ended with stop_reason=tool_use but did not provide the required task tool call. The requested task action did not run.'
const COSTRICT_TASK_TOOL_USE_FAILURE_MESSAGE =
'Task checklist update failed: the model ended with stop_reason=tool_use but did not call update_todo_list. The requested task action did not run.'
function hasUpdateTodoListCompatTool(
toolUseContext: ToolUseContext,
): boolean {
return Boolean(
findToolByName(
toolUseContext.options.tools,
UPDATE_TODO_LIST_COMPAT_TOOL_NAME,
),
)
}
const TASK_TOOL_NAMES = new Set([
TASK_CREATE_TOOL_NAME,
TASK_UPDATE_TOOL_NAME,
TASK_LIST_TOOL_NAME,
TASK_GET_TOOL_NAME,
UPDATE_TODO_LIST_COMPAT_TOOL_NAME,
])
function hasRecentTaskToolUse(messages: readonly Message[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
if (message.type === 'user') {
if (!isToolResultUserMessage(message)) break
continue
}
if (message.type !== 'assistant') continue
const content = message.message?.content
if (!Array.isArray(content)) continue
if (
content.some(
block =>
block.type === 'tool_use' &&
typeof block.name === 'string' &&
TASK_TOOL_NAMES.has(block.name),
)
) {
return true
}
}
return false
}
function isToolResultUserMessage(message: Message): boolean {
if (message.type !== 'user') return false
const content = message.message?.content
return (
Array.isArray(content) &&
content.length > 0 &&
content.every(block => block.type === 'tool_result')
)
}
export type QueryParams = {
messages: Message[]
@ -1389,18 +1447,23 @@ async function* queryLoop(
return { reason: 'completed' }
}
if (isTaskRelatedMissingToolUseResponse(lastMessage)) {
const error = new Error(TASK_TOOL_USE_FAILURE_MESSAGE)
yield createAssistantAPIErrorMessage({
content: TASK_TOOL_USE_FAILURE_MESSAGE,
})
return { reason: 'model_error', error }
}
if (
isMissingToolUseResponse(lastMessage) &&
state.transition?.reason !== 'missing_tool_use_recovery'
) {
const shouldPreferChecklistTool =
isTaskRelatedMissingToolUseResponse(lastMessage, messagesForQuery) &&
(getAPIProvider() === 'costrict' ||
hasUpdateTodoListCompatTool(toolUseContext))
const recoveryKind = shouldPreferChecklistTool
? 'costrict_task'
: isTaskRelatedMissingToolUseResponse(lastMessage, messagesForQuery)
? 'task'
: 'generic'
const recoveryMessage =
recoveryKind === 'costrict_task'
? COSTRICT_TASK_TOOL_USE_RECOVERY_MESSAGE
: MISSING_TOOL_USE_RECOVERY_MESSAGE
logEvent('tengu_missing_tool_use_recovery', {
queryChainId: queryChainIdForAnalytics,
queryDepth: queryTracking.depth,
@ -1410,7 +1473,7 @@ async function* queryLoop(
...messagesForQuery,
...assistantMessages,
createUserMessage({
content: MISSING_TOOL_USE_RECOVERY_MESSAGE,
content: recoveryMessage,
isMeta: true,
}),
],
@ -1422,7 +1485,10 @@ async function* queryLoop(
pendingToolUseSummary: undefined,
stopHookActive: undefined,
turnCount,
transition: { reason: 'missing_tool_use_recovery' },
transition: {
reason: 'missing_tool_use_recovery',
kind: recoveryKind,
},
}
state = next
continue
@ -1431,9 +1497,19 @@ async function* queryLoop(
isMissingToolUseResponse(lastMessage) &&
state.transition?.reason === 'missing_tool_use_recovery'
) {
const error = new Error(MISSING_TOOL_USE_FAILURE_MESSAGE)
const failureMessage =
state.transition.kind === 'costrict_task'
? COSTRICT_TASK_TOOL_USE_FAILURE_MESSAGE
: state.transition.kind === 'task' ||
isTaskRelatedMissingToolUseResponse(
lastMessage,
messagesForQuery,
)
? TASK_TOOL_USE_FAILURE_MESSAGE
: MISSING_TOOL_USE_FAILURE_MESSAGE
const error = new Error(failureMessage)
yield createAssistantAPIErrorMessage({
content: MISSING_TOOL_USE_FAILURE_MESSAGE,
content: failureMessage,
})
return { reason: 'model_error', error }
}

View File

@ -17,5 +17,8 @@ export type Continue =
| { reason: 'max_output_tokens_recovery'; attempt: number }
| { reason: 'stop_hook_blocking' }
| { reason: 'token_budget_continuation' }
| { reason: 'missing_tool_use_recovery' }
| {
reason: 'missing_tool_use_recovery'
kind: 'costrict_task' | 'task' | 'generic'
}
| { reason: 'next_turn' }

View File

@ -1,6 +1,7 @@
import type { ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs'
import {
createUserMessage,
getInvalidToolCallError,
REJECT_MESSAGE,
withMemoryCorrectionHint,
} from 'src/utils/messages.js'
@ -102,6 +103,32 @@ export class StreamingToolExecutor {
}
}
const toolDefinition = findToolByName(this.toolDefinitions, block.name)
const invalidToolCallError = getInvalidToolCallError(block)
if (invalidToolCallError) {
this.tools.push({
id: block.id,
block,
assistantMessage,
status: 'completed',
isConcurrencySafe: true,
pendingProgress: [],
results: [
createUserMessage({
content: [
{
type: 'tool_result',
content: `<tool_use_error>${invalidToolCallError}</tool_use_error>`,
is_error: true,
tool_use_id: block.id,
},
],
toolUseResult: invalidToolCallError,
sourceToolAssistantUUID: assistantMessage.uuid,
}),
],
})
return
}
if (!toolDefinition) {
this.tools.push({
id: block.id,

View File

@ -1,6 +1,21 @@
import { describe, expect, test } from 'bun:test'
import { StreamingToolExecutor } from '../StreamingToolExecutor.js'
import type { ToolUseContext } from '../../../Tool.js'
import type { AssistantMessage } from '../../../types/message.js'
import { createAssistantMessage } from '../../../utils/messages.js'
function makeAssistantMessage(): AssistantMessage {
return createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'toolu_parent',
name: 'TaskUpdate',
input: {},
} as any,
],
})
}
function makeMinimalContext(): ToolUseContext {
const abortController = new AbortController()
@ -127,3 +142,50 @@ describe('StreamingToolExecutor.discard()', () => {
expect(internals.turnSpan).toBeNull()
})
})
describe('StreamingToolExecutor invalid tool calls', () => {
test('returns an error result without executing known tools', () => {
const ctx = makeMinimalContext()
let executed = false
const tool: any = {
name: 'TaskUpdate',
inputSchema: {
safeParse: () => ({ success: true, data: {} }),
},
isConcurrencySafe: () => {
executed = true
return true
},
}
const executor = new StreamingToolExecutor(
[tool],
() => true as any,
ctx,
)
executor.addTool(
{
type: 'tool_use',
id: 'toolu_bad',
name: 'TaskUpdate',
input: {},
invalidToolCallError:
'Model response error: invalid tool call arguments for TaskUpdate. The tool call was not executed.',
} as any,
makeAssistantMessage(),
)
const results = [...executor.getCompletedResults()]
expect(executed).toBe(false)
expect(results).toHaveLength(1)
const content = (results[0]!.message as any).message.content
expect(content[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'toolu_bad',
is_error: true,
})
expect(content[0].content).toContain(
'invalid tool call arguments for TaskUpdate',
)
})
})

View File

@ -76,6 +76,7 @@ import {
createStopHookSummaryMessage,
createToolResultStopMessage,
createUserMessage,
getInvalidToolCallError,
withMemoryCorrectionHint,
} from '../../utils/messages.js'
import type {
@ -370,6 +371,26 @@ export async function* runToolUse(
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdateLazy, void> {
const toolName = toolUse.name
const invalidToolCallError = getInvalidToolCallError(toolUse)
if (invalidToolCallError) {
logForDebugging(`Invalid tool call ${toolName}: ${toolUse.id}`)
yield {
message: createUserMessage({
content: [
{
type: 'tool_result',
content: `<tool_use_error>${invalidToolCallError}</tool_use_error>`,
is_error: true,
tool_use_id: toolUse.id,
},
],
toolUseResult: invalidToolCallError,
sourceToolAssistantUUID: assistantMessage.uuid,
}),
}
return
}
// First try to find in the available tools (what the model sees)
let tool = findToolByName(toolUseContext.options.tools, toolName)

View File

@ -93,6 +93,7 @@ import { TaskCreateTool } from '@claude-code-best/builtin-tools/tools/TaskCreate
import { TaskGetTool } from '@claude-code-best/builtin-tools/tools/TaskGetTool/TaskGetTool.js'
import { TaskUpdateTool } from '@claude-code-best/builtin-tools/tools/TaskUpdateTool/TaskUpdateTool.js'
import { TaskListTool } from '@claude-code-best/builtin-tools/tools/TaskListTool/TaskListTool.js'
import { UpdateTodoListCompatTool } from '@claude-code-best/builtin-tools/tools/UpdateTodoListCompatTool/UpdateTodoListCompatTool.js'
import uniqBy from 'lodash-es/uniqBy.js'
import { isSearchExtraToolsEnabledOptimistic } from './utils/searchExtraTools.js'
import { isTodoV2Enabled } from './utils/tasks.js'
@ -242,7 +243,13 @@ export function getAllBaseTools(): Tools {
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
...(WebBrowserTool ? [WebBrowserTool] : []),
...(isTodoV2Enabled()
? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool]
? [
UpdateTodoListCompatTool,
TaskCreateTool,
TaskGetTool,
TaskUpdateTool,
TaskListTool,
]
: []),
...(OverflowTestTool ? [OverflowTestTool] : []),
...(CtxInspectTool ? [CtxInspectTool] : []),

View File

@ -28,6 +28,7 @@ import {
DONT_ASK_REJECT_MESSAGE,
SYNTHETIC_MODEL,
normalizeContentFromAPI,
getInvalidToolCallError,
} from '../messages'
import type {
Message,
@ -258,6 +259,65 @@ describe('normalizeContentFromAPI tool input validation', () => {
}
})
test('preserves invalid tool call metadata in CoStrict tolerant mode', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: '{"status": in_progresss", "taskId": "1"}',
} as any,
],
[TaskUpdateTool] as any,
undefined,
{ preserveInvalidToolCall: true },
)
const toolUse = content[0] as any
expect(toolUse).toMatchObject({
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: {},
})
expect(getInvalidToolCallError(toolUse)).toBe(
'Model response error: invalid tool call arguments for TaskUpdate. The tool call was not executed.',
)
})
test('normalizeMessagesForAPI strips invalid tool call metadata from outbound payloads', () => {
const assistant = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: {},
invalidToolCallError:
'Model response error: invalid tool call arguments for TaskUpdate. The tool call was not executed.',
} as any,
],
})
const user = createUserMessage({
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
content: 'error',
is_error: true,
},
],
})
const normalized = normalizeMessagesForAPI(
[assistant, user],
[TaskUpdateTool] as any,
)
const toolUse = (normalized[0]!.message.content as any[])[0]
expect(toolUse.invalidToolCallError).toBeUndefined()
})
test('rejects non-object JSON tool inputs', () => {
for (const input of ['null', '[]', '"in_progress"', '1']) {
expect(() =>

View File

@ -185,10 +185,31 @@ export class InvalidToolCallResponseError extends Error {
}
}
export type InvalidToolCallToolUseBlock = ToolUseBlock & {
invalidToolCallError?: string
}
export type NormalizeContentFromAPIOptions = {
preserveInvalidToolCall?: boolean
}
function isPlainToolInput(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function getInvalidToolCallError(toolUse: unknown): string | undefined {
if (!isPlainToolInput(toolUse)) return undefined
const error = toolUse.invalidToolCallError
return typeof error === 'string' && error.length > 0 ? error : undefined
}
export function stripInternalToolUseFields<T extends Record<string, unknown>>(
block: T,
): Omit<T, 'invalidToolCallError'> {
const { invalidToolCallError: _invalidToolCallError, ...rest } = block
return rest
}
function createInvalidToolArgumentsError(toolName: string): InvalidToolCallResponseError {
return new InvalidToolCallResponseError(
`Model response error: invalid tool call arguments for ${toolName}. The tool call was not executed.`,
@ -2527,21 +2548,26 @@ export function normalizeMessagesForAPI(
)
: toolUseBlk.input
const canonicalName = tool?.name ?? toolUseBlk.name
const toolUseWithoutInternal = stripInternalToolUseFields(
block as ToolUseBlock & Record<string, unknown>,
)
// When tool search is enabled, preserve all fields including 'caller'
if (searchExtraToolsEnabled) {
return {
...block,
...toolUseWithoutInternal,
type: 'tool_use' as const,
id: toolUseBlk.id,
name: canonicalName,
input: normalizedInput,
}
} as ToolUseBlockParam
}
// When tool search is NOT enabled, strip tool-search-only fields
// like 'caller', but preserve other provider metadata attached to
// the block (for example Gemini thought signatures on tool_use).
const { caller: _caller, ...toolUseRest } =
block as ToolUseBlock &
toolUseWithoutInternal as ToolUseBlock &
Record<string, unknown> & { caller?: unknown }
return {
...toolUseRest,
@ -2549,7 +2575,7 @@ export function normalizeMessagesForAPI(
id: toolUseBlk.id,
name: canonicalName,
input: normalizedInput,
}
} as ToolUseBlockParam
}
return block
}),
@ -2977,6 +3003,7 @@ export function normalizeContentFromAPI(
contentBlocks: BetaMessage['content'],
tools: Tools,
agentId?: AgentId,
options: NormalizeContentFromAPIOptions = {},
): BetaMessage['content'] {
if (!contentBlocks) {
return []
@ -3004,33 +3031,47 @@ export function normalizeContentFromAPI(
// an empty string, this should become an empty object (nested values should be empty string).
// TODO: This needs patching as recursive fields can still be stringified
let normalizedInput: unknown
if (typeof contentBlock.input === 'string') {
const parsed = safeParseJSON(contentBlock.input)
if (parsed === null) {
// TET/FC-v3 diagnostic: the streamed tool input JSON failed to
// parse. The raw prefix goes to debug log only — no PII-tagged
// proto column exists for it yet.
logEvent('tengu_tool_input_json_parse_fail', {
toolName: sanitizeToolNameForAnalytics(contentBlock.name),
inputLen: contentBlock.input.length,
})
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`tool input JSON parse fail: ${contentBlock.input.slice(0, 200)}`,
{ level: 'warn' },
)
try {
if (typeof contentBlock.input === 'string') {
const parsed = safeParseJSON(contentBlock.input)
if (parsed === null) {
// TET/FC-v3 diagnostic: the streamed tool input JSON failed to
// parse. The raw prefix goes to debug log only — no PII-tagged
// proto column exists for it yet.
logEvent('tengu_tool_input_json_parse_fail', {
toolName: sanitizeToolNameForAnalytics(contentBlock.name),
inputLen: contentBlock.input.length,
})
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`tool input JSON parse fail: ${contentBlock.input.slice(0, 200)}`,
{ level: 'warn' },
)
}
throw createInvalidToolArgumentsError(contentBlock.name)
}
throw createInvalidToolArgumentsError(contentBlock.name)
if (!isPlainToolInput(parsed)) {
throw createInvalidToolArgumentsError(contentBlock.name)
}
normalizedInput = parsed
} else {
if (!isPlainToolInput(contentBlock.input)) {
throw createInvalidToolArgumentsError(contentBlock.name)
}
normalizedInput = contentBlock.input
}
if (!isPlainToolInput(parsed)) {
throw createInvalidToolArgumentsError(contentBlock.name)
} catch (error) {
if (
options.preserveInvalidToolCall &&
error instanceof InvalidToolCallResponseError
) {
return {
...contentBlock,
input: {},
invalidToolCallError: error.message,
} as InvalidToolCallToolUseBlock
}
normalizedInput = parsed
} else {
if (!isPlainToolInput(contentBlock.input)) {
throw createInvalidToolArgumentsError(contentBlock.name)
}
normalizedInput = contentBlock.input
throw error
}
// Then apply tool-specific corrections
@ -3276,6 +3317,7 @@ export type StreamingToolUse = {
index: number
contentBlock: BetaToolUseBlock
unparsedToolInput: string
parsedToolInput?: Record<string, unknown>
}
export type StreamingThinking = {
@ -3449,6 +3491,9 @@ export function handleMessageFromStream(
case 'input_json_delta': {
const delta = streamMsg.event.delta.partial_json
const index = streamMsg.event.index
const parsedToolInput = (
streamMsg.event.delta as { parsed_tool_input?: unknown }
).parsed_tool_input
onUpdateLength(delta)
onStreamingToolUses(_ => {
const element = _.find(_ => _.index === index)
@ -3460,6 +3505,9 @@ export function handleMessageFromStream(
{
...element,
unparsedToolInput: element.unparsedToolInput + delta,
...(isPlainToolInput(parsedToolInput)
? { parsedToolInput }
: undefined),
},
]
})