From 416e2767ed3725076f867099146335dd51a424a0 Mon Sep 17 00:00:00 2001 From: IronRookieCoder Date: Mon, 18 May 2026 14:50:56 +0800 Subject: [PATCH] fix(query): surface missing task tool call failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug 详情 GLM-5 返回 `stop_reason=tool_use` 但没有携带真实任务工具调用时,任务列表流程会停在“准备查看/更新任务”的文本状态,用户无法判断任务是卡住还是已结束。 ## 根因 原有缺失工具调用恢复逻辑会尝试追加一次隐式恢复提示;任务相关场景下如果模型没有发出 `TaskList`、`TaskUpdate` 等实际工具调用,缺少用户可见的失败提示。 ## 修复方案 对任务相关的缺失工具调用直接输出英文错误,并保留普通缺失工具调用的一次恢复机会;同时把合成缺失工具结果占位改为明确英文失败文案。 ## 变更要点 - 新增任务相关 `stop_reason=tool_use` 但无工具调用的检测和失败返回 - 新增通用缺失工具调用恢复失败时的英文错误提示 - 将合成缺失工具结果文案从内部占位改为用户可理解的失败说明 - 增加 query 缺失工具调用恢复路径测试 ## 自测 - `bun test src/__tests__/queryMissingToolUseRecovery.test.ts` - `bun test src/utils/__tests__/messages.test.ts` - `bun run typecheck` 未完全通过,剩余错误来自仓库既有问题;本次修改相关文件无 TypeScript 错误 --- .../queryMissingToolUseRecovery.test.ts | 108 +++++++++++++++++- src/query.ts | 36 ++++++ src/utils/messages.ts | 2 +- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/__tests__/queryMissingToolUseRecovery.test.ts b/src/__tests__/queryMissingToolUseRecovery.test.ts index 4dab1c2a1..2cefda122 100644 --- a/src/__tests__/queryMissingToolUseRecovery.test.ts +++ b/src/__tests__/queryMissingToolUseRecovery.test.ts @@ -99,7 +99,7 @@ describe('query missing tool_use recovery', () => { observedPrompts.push(messages) if (callCount === 1) { yield createAssistantMessage( - [{ type: 'text', text: '现在调用 TaskUpdate 标记完成。' }], + [{ type: 'text', text: '现在调用工具继续处理。' }], 'tool_use', ) return @@ -141,4 +141,110 @@ describe('query missing tool_use recovery', () => { 'Your previous response ended with stop_reason=tool_use', ) }) + + test('surfaces an error immediately for missing task tool use', async () => { + const toolUseContext = createToolUseContext() + + let callCount = 0 + const yielded: unknown[] = [] + const deps = { + uuid: () => 'query-chain-id', + microcompact: async (messages: unknown[]) => ({ messages }), + autocompact: async () => ({ + compactionResult: undefined, + consecutiveFailures: 0, + }), + callModel: async function* () { + callCount += 1 + yield createAssistantMessage( + [{ type: 'text', text: '让我查看任务列表确认状态更新成功:' }], + 'tool_use', + ) + }, + } + + const generator = query({ + messages: [ + createUserMessage({ + content: '使用 TaskUpdate 更新任务状态', + }), + ], + 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) { + yielded.push(next.value) + next = await generator.next() + } + + expect(next.value.reason).toBe('model_error') + expect(callCount).toBe(1) + expect(JSON.stringify(yielded)).toContain( + 'Task tool call failed: the model ended with stop_reason=tool_use', + ) + }) + + test('surfaces an error when generic missing tool_use recovery fails', async () => { + const toolUseContext = createToolUseContext() + + let callCount = 0 + const yielded: unknown[] = [] + const deps = { + uuid: () => 'query-chain-id', + microcompact: async (messages: unknown[]) => ({ messages }), + autocompact: async () => ({ + compactionResult: undefined, + consecutiveFailures: 0, + }), + callModel: async function* () { + callCount += 1 + yield createAssistantMessage( + [{ type: 'text', text: '现在调用工具继续处理。' }], + 'tool_use', + ) + }, + } + + const generator = query({ + messages: [ + createUserMessage({ + content: '使用工具继续处理', + }), + ], + 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) { + yielded.push(next.value) + next = await generator.next() + } + + expect(next.value.reason).toBe('model_error') + expect(callCount).toBe(2) + expect(JSON.stringify(yielded)).toContain( + 'Model response error: the model indicated it wanted to call a tool', + ) + }) }) diff --git a/src/query.ts b/src/query.ts index 79c63c251..348d3426e 100644 --- a/src/query.ts +++ b/src/query.ts @@ -205,8 +205,26 @@ function isMissingToolUseResponse(msg: Message | undefined): boolean { return !content.some(block => block.type === 'tool_use') } +function isTaskRelatedMissingToolUseResponse( + msg: Message | undefined, +): 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) +} + 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 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.' export type QueryParams = { messages: Message[] @@ -1371,6 +1389,14 @@ 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' @@ -1401,6 +1427,16 @@ async function* queryLoop( state = next continue } + if ( + isMissingToolUseResponse(lastMessage) && + state.transition?.reason === 'missing_tool_use_recovery' + ) { + const error = new Error(MISSING_TOOL_USE_FAILURE_MESSAGE) + yield createAssistantAPIErrorMessage({ + content: MISSING_TOOL_USE_FAILURE_MESSAGE, + }) + return { reason: 'model_error', error } + } const stopHookResult = yield* handleStopHooks( messagesForQuery, diff --git a/src/utils/messages.ts b/src/utils/messages.ts index dc583e06c..aab561801 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -395,7 +395,7 @@ export const NO_RESPONSE_REQUESTED = 'No response requested.' // reject any payload containing it — placeholder satisfies pairing structurally // but the content is fake, which poisons training data if submitted. export const SYNTHETIC_TOOL_RESULT_PLACEHOLDER = - '[Tool result missing due to internal error]' + 'Tool execution failed: the model requested a tool call, but no tool result was recorded. The requested task action did not complete successfully.' // Prefix used by UI to detect classifier denials and render them concisely const AUTO_MODE_REJECTION_PREFIX =