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 =