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:
parent
602efce18a
commit
416e2767ed
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
36
src/query.ts
36
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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user