From 4cb047ff482521af0e28aaa2e84731756c7b2fdb Mon Sep 17 00:00:00 2001 From: IronRookieCoder Date: Wed, 20 May 2026 13:44:12 +0800 Subject: [PATCH] fix(costrict): recover task updates from missing tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 仍有当前工作区既有无关错误,未由本次修改引入 --- bun.lock | 3 + package.json | 1 + packages/builtin-tools/src/index.ts | 1 + .../UpdateTodoListCompatTool.ts | 302 ++++++++++++++++++ .../UpdateTodoListCompatTool.test.ts | 167 ++++++++++ .../UpdateTodoListCompatTool/constants.ts | 1 + .../tools/UpdateTodoListCompatTool/prompt.ts | 15 + .../queryMissingToolUseRecovery.test.ts | 286 ++++++++++++++++- src/costrict/provider/index.test.ts | 134 +++++++- src/costrict/provider/index.ts | 95 +++++- src/query.ts | 116 +++++-- src/query/transitions.ts | 5 +- src/services/tools/StreamingToolExecutor.ts | 27 ++ .../__tests__/StreamingToolExecutor.test.ts | 62 ++++ src/services/tools/toolExecution.ts | 21 ++ src/tools.ts | 9 +- src/utils/__tests__/messages.test.ts | 60 ++++ src/utils/messages.ts | 104 ++++-- 18 files changed, 1341 insertions(+), 68 deletions(-) create mode 100644 packages/builtin-tools/src/tools/UpdateTodoListCompatTool/UpdateTodoListCompatTool.ts create mode 100644 packages/builtin-tools/src/tools/UpdateTodoListCompatTool/__tests__/UpdateTodoListCompatTool.test.ts create mode 100644 packages/builtin-tools/src/tools/UpdateTodoListCompatTool/constants.ts create mode 100644 packages/builtin-tools/src/tools/UpdateTodoListCompatTool/prompt.ts diff --git a/bun.lock b/bun.lock index 1db06dce0..bdd32d46e 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 7c1ef7b54..dfb066fac 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index c31d600b3..f4004a11b 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -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' diff --git a/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/UpdateTodoListCompatTool.ts b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/UpdateTodoListCompatTool.ts new file mode 100644 index 000000000..58fd8bffb --- /dev/null +++ b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/UpdateTodoListCompatTool.ts @@ -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 + +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 + +export type Output = z.infer + +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() + + 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) diff --git a/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/__tests__/UpdateTodoListCompatTool.test.ts b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/__tests__/UpdateTodoListCompatTool.test.ts new file mode 100644 index 000000000..4c6d33890 --- /dev/null +++ b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/__tests__/UpdateTodoListCompatTool.test.ts @@ -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') + }) +}) diff --git a/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/constants.ts b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/constants.ts new file mode 100644 index 000000000..c6e2d6ee0 --- /dev/null +++ b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/constants.ts @@ -0,0 +1 @@ +export const UPDATE_TODO_LIST_COMPAT_TOOL_NAME = 'update_todo_list' diff --git a/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/prompt.ts b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/prompt.ts new file mode 100644 index 000000000..eea57a5f0 --- /dev/null +++ b/packages/builtin-tools/src/tools/UpdateTodoListCompatTool/prompt.ts @@ -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.` diff --git a/src/__tests__/queryMissingToolUseRecovery.test.ts b/src/__tests__/queryMissingToolUseRecovery.test.ts index 2cefda122..a2f43fc12 100644 --- a/src/__tests__/queryMissingToolUseRecovery.test.ts +++ b/src/__tests__/queryMissingToolUseRecovery.test.ts @@ -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', ) diff --git a/src/costrict/provider/index.test.ts b/src/costrict/provider/index.test.ts index 18386f643..7db543bc2 100644 --- a/src/costrict/provider/index.test.ts +++ b/src/costrict/provider/index.test.ts @@ -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() + }) }) diff --git a/src/costrict/provider/index.ts b/src/costrict/provider/index.ts index f3857c0dd..670dc5e4e 100644 --- a/src/costrict/provider/index.ts +++ b/src/costrict/provider/index.ts @@ -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 { - return value !== null && typeof value === 'object' + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isPlainRecord(value: unknown): value is Record { + return isRecord(value) +} + +function tryParsePartialToolInput( + rawInput: string, +): Record | 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 + 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 = {} + const toolCallStates = new Map() 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).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 } diff --git a/src/query.ts b/src/query.ts index 348d3426e..f3fe7914d 100644 --- a/src/query.ts +++ b/src/query.ts @@ -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 } } diff --git a/src/query/transitions.ts b/src/query/transitions.ts index 56a7f327c..a293994dd 100644 --- a/src/query/transitions.ts +++ b/src/query/transitions.ts @@ -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' } diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index fd6453ab1..932abbef4 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -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: `${invalidToolCallError}`, + is_error: true, + tool_use_id: block.id, + }, + ], + toolUseResult: invalidToolCallError, + sourceToolAssistantUUID: assistantMessage.uuid, + }), + ], + }) + return + } if (!toolDefinition) { this.tools.push({ id: block.id, diff --git a/src/services/tools/__tests__/StreamingToolExecutor.test.ts b/src/services/tools/__tests__/StreamingToolExecutor.test.ts index 74eb3cd16..9b3f324c2 100644 --- a/src/services/tools/__tests__/StreamingToolExecutor.test.ts +++ b/src/services/tools/__tests__/StreamingToolExecutor.test.ts @@ -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', + ) + }) +}) diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index a9e61f7f6..6e9854e1e 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -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 { 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: `${invalidToolCallError}`, + 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) diff --git a/src/tools.ts b/src/tools.ts index c7112adb3..5880d9f04 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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] : []), diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index 6b833b679..4742aea20 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -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(() => diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 6b0429934..844dc9021 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -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 { 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>( + block: T, +): Omit { + 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, + ) // 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 & { 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 } 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), }, ] })