fix(query): surface missing task tool call failures

## 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 错误
This commit is contained in:
IronRookieCoder 2026-05-18 14:50:56 +08:00
parent 602efce18a
commit 416e2767ed
3 changed files with 144 additions and 2 deletions

View File

@ -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',
)
})
})

View File

@ -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,

View File

@ -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 =