Merge pull request #122 from IronRookieCoder/fix/task-exception-status-hint

Fix/task exception status hint
This commit is contained in:
linkai0924 2026-05-18 19:09:34 +08:00 committed by GitHub
commit e901c66cd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 419 additions and 343 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -390,6 +390,80 @@ describe('adaptOpenAIStreamToAnthropic', () => {
)
})
test('keeps the initial tool name when later argument chunks omit it', async () => {
const events = await collectEvents([
makeChunk({
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: 'call_task_update',
type: 'function',
function: { name: 'TaskUpdate', arguments: '' },
},
],
},
finish_reason: null,
},
],
}),
makeChunk({
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
type: 'function',
function: { arguments: '{"status": ' },
},
],
},
finish_reason: null,
},
],
}),
makeChunk({
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
type: 'function',
function: { arguments: 'in_progress' },
},
],
},
finish_reason: null,
},
],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
}),
])
const blockStarts = events.filter(
e => e.type === 'content_block_start',
) as any[]
expect(blockStarts).toHaveLength(1)
expect(blockStarts[0].content_block.name).toBe('TaskUpdate')
const jsonDeltas = events.filter(
e =>
e.type === 'content_block_delta' && e.delta.type === 'input_json_delta',
) as any[]
expect(jsonDeltas.map(e => e.delta.partial_json).join('')).toBe(
'{"status": in_progress',
)
})
test('maps finish_reason stop to end_turn', async () => {
const events = await collectEvents([
makeChunk({

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

@ -54,6 +54,8 @@ import type { Options } from '../claude.js'
import { randomUUID } from 'crypto'
import {
createAssistantAPIErrorMessage,
createAssistantMessage,
InvalidToolCallResponseError,
createUserMessage,
normalizeContentFromAPI,
} from '../../../utils/messages.js'
@ -166,23 +168,32 @@ function assembleFinalAssistantOutputs(params: {
.filter(Boolean)
if (allBlocks.length > 0) {
outputs.push({
message: {
...partialMessage,
content: normalizeContentFromAPI(
allBlocks,
tools,
agentId as AgentId | undefined,
),
usage,
stop_reason: stopReason,
stop_sequence: null,
},
requestId: undefined,
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
} as AssistantMessage)
try {
outputs.push({
message: {
...partialMessage,
content: normalizeContentFromAPI(
allBlocks,
tools,
agentId as AgentId | undefined,
),
usage,
stop_reason: stopReason,
stop_sequence: null,
},
requestId: undefined,
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
} as AssistantMessage)
} catch (error) {
if (!(error instanceof InvalidToolCallResponseError)) throw error
outputs.push(
createAssistantMessage({
content: error.message,
}),
)
}
}
if (stopReason === 'max_tokens') {

View File

@ -206,87 +206,8 @@ describe('createToolResultStopMessage', () => {
})
})
describe('normalizeContentFromAPI task tool input repair', () => {
test('repairs unquoted TaskUpdate status and preserves taskId', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: '{"status": in_progresss", "taskId": "1"}',
} as any,
],
[TaskUpdateTool] as any,
)
expect((content[0] as any).input).toEqual({
status: 'in_progress',
taskId: '1',
})
})
test('repairs misspelled completed TaskUpdate status', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: '{"status": completedd", "taskId": "2"}',
} as any,
],
[TaskUpdateTool] as any,
)
expect((content[0] as any).input).toEqual({
status: 'completed',
taskId: '2',
})
})
test('repairs common TaskUpdate aliases and loose scalar values', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input:
"{'status': done, 'task_id': 2, 'addBlockedBy': [1, '3'], 'active_form': 'Running checks'}",
} as any,
],
[TaskUpdateTool] as any,
)
expect((content[0] as any).input).toEqual({
status: 'completed',
taskId: '2',
addBlockedBy: ['1', '3'],
activeForm: 'Running checks',
})
})
test('repairs unterminated TaskUpdate string values', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: '{"taskId": "4", "subject": "Run final verification',
} as any,
],
[TaskUpdateTool] as any,
)
expect((content[0] as any).input).toEqual({
taskId: '4',
subject: 'Run final verification',
})
})
test('does not repair malformed non-task tool input', () => {
describe('normalizeContentFromAPI tool input validation', () => {
test('normalizes valid tool input objects', () => {
const content = normalizeContentFromAPI(
[
{
@ -298,69 +219,96 @@ describe('normalizeContentFromAPI task tool input repair', () => {
{
type: 'tool_use',
id: 'toolu_2',
name: 'Bash',
input: '{"command": ls"}',
name: 'TaskUpdate',
input: '{"status":"completed","taskId":"1"}',
} as any,
],
[TaskGetTool] as any,
[TaskGetTool, TaskUpdateTool] as any,
)
expect((content[0] as any).input).toEqual({ taskId: '1' })
expect((content[1] as any).input).toEqual({})
expect((content[1] as any).input).toEqual({
status: 'completed',
taskId: '1',
})
})
test('does not repair task inputs missing required fields', () => {
test('rejects malformed JSON tool inputs', () => {
for (const input of [
'{"status": in_progresss", "taskId": "1"}',
'{"status": completedd", "taskId": "2"}',
'in_progress',
'',
]) {
expect(() =>
normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input,
} as any,
],
[TaskUpdateTool] as any,
),
).toThrow(
'Model response error: invalid tool call arguments for TaskUpdate. The tool call was not executed.',
)
}
})
test('rejects non-object JSON tool inputs', () => {
for (const input of ['null', '[]', '"in_progress"', '1']) {
expect(() =>
normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input,
} as any,
],
[TaskUpdateTool] as any,
),
).toThrow(
'Model response error: invalid tool call arguments for TaskUpdate. The tool call was not executed.',
)
}
})
test('rejects tool calls without a final name', () => {
expect(() =>
normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: '',
input: '{"taskId":"1"}',
} as any,
],
[TaskGetTool] as any,
),
).toThrow(
'Model response error: invalid tool call without a tool name. The tool call was not executed.',
)
})
test('preserves valid empty object input', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskCreate',
input: '{"subject": "missing description"',
} as any,
{
type: 'tool_use',
id: 'toolu_2',
name: 'TaskUpdate',
input: '{"status": completedd"}',
name: 'TaskList',
input: '{}',
} as any,
],
[] as any,
)
expect((content[0] as any).input).toEqual({})
expect((content[1] as any).input).toEqual({})
})
test('does not repair TaskUpdate into a no-op when status is invalid', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: '{"status": unknown_state", "taskId": "2"}',
} as any,
],
[TaskUpdateTool] as any,
)
expect((content[0] as any).input).toEqual({})
})
test('does not repair TaskUpdate with only taskId', () => {
const content = normalizeContentFromAPI(
[
{
type: 'tool_use',
id: 'toolu_1',
name: 'TaskUpdate',
input: '{"taskId": "2"',
} as any,
],
[TaskUpdateTool] as any,
)
expect((content[0] as any).input).toEqual({})
})
})

View File

@ -142,8 +142,6 @@ import {
} from '@claude-code-best/builtin-tools/tools/FileReadTool/FileReadTool.js'
import { SEND_MESSAGE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SendMessageTool/constants.js'
import { TASK_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/constants.js'
import { TASK_GET_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskGetTool/constants.js'
import { TASK_LIST_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskListTool/constants.js'
import { TASK_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskOutputTool/constants.js'
import { TASK_UPDATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskUpdateTool/constants.js'
import type { PermissionMode } from '../types/permissions.js'
@ -180,153 +178,21 @@ const MEMORY_CORRECTION_HINT =
"\n\nNote: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions."
const TOOL_REFERENCE_TURN_BOUNDARY = 'Tool loaded.'
const TASK_TOOL_NAMES = new Set([
TASK_CREATE_TOOL_NAME,
TASK_GET_TOOL_NAME,
TASK_LIST_TOOL_NAME,
TASK_UPDATE_TOOL_NAME,
])
const TASK_STATUS_VALUES = ['pending', 'in_progress', 'completed', 'deleted']
function normalizeLooseTaskStatus(value: string): string {
const key = value.trim().toLowerCase().replace(/[\s-]+/g, '_')
if (key === 'open' || key === 'todo') return 'pending'
if (
key === 'started' ||
key === 'active' ||
key === 'inprogress' ||
key.startsWith('in_progress')
) {
return 'in_progress'
export class InvalidToolCallResponseError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidToolCallResponseError'
}
if (
key === 'complete' ||
key === 'done' ||
key === 'resolved' ||
key === 'closed' ||
key.startsWith('completed') ||
key.startsWith('complete')
) {
return 'completed'
}
if (key === 'delete' || key === 'removed' || key.startsWith('deleted')) {
return 'deleted'
}
return value
}
function extractLooseTaskStringField(raw: string, key: string): string | null {
const quoted = new RegExp(`["']${key}["']\\s*:\\s*["']([^"']*)`).exec(raw)
if (quoted) return quoted[1] ?? ''
const bare = new RegExp(`["']${key}["']\\s*:\\s*([^,}\\n\\r]+)`).exec(raw)
if (!bare) return null
return (bare[1] ?? '')
.trim()
.replace(/^["']/, '')
.replace(/["']$/, '')
function isPlainToolInput(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function extractLooseTaskStringArray(raw: string, key: string): string[] | null {
const match = new RegExp(`["']${key}["']\\s*:\\s*\\[([^\\]]*)\\]`).exec(raw)
if (!match) return null
return (match[1] ?? '')
.split(',')
.map(item =>
item
.trim()
.replace(/^["']/, '')
.replace(/["']$/, ''),
)
.filter(Boolean)
}
function repairTaskToolInput(toolName: string, rawInput: string): unknown {
if (!TASK_TOOL_NAMES.has(toolName)) return null
const repaired: Record<string, unknown> = {}
if (toolName === TASK_LIST_TOOL_NAME) {
return rawInput.trim() === '' || rawInput.trim() === '{' ? {} : null
}
for (const key of ['taskId', 'task_id', 'id']) {
const value = extractLooseTaskStringField(rawInput, key)
if (value !== null) {
repaired.taskId = value
break
}
}
if (toolName === TASK_UPDATE_TOOL_NAME) {
const status = extractLooseTaskStringField(rawInput, 'status')
if (status !== null) {
const normalizedStatus = normalizeLooseTaskStatus(status)
if (TASK_STATUS_VALUES.includes(normalizedStatus)) {
repaired.status = normalizedStatus
} else {
return null
}
}
for (const key of ['subject', 'description', 'activeForm', 'owner']) {
const value = extractLooseTaskStringField(rawInput, key)
if (value !== null) repaired[key] = value
}
const activeForm = extractLooseTaskStringField(rawInput, 'active_form')
if (activeForm !== null && repaired.activeForm === undefined) {
repaired.activeForm = activeForm
}
for (const [sourceKey, targetKey] of [
['addBlocks', 'addBlocks'],
['add_blocks', 'addBlocks'],
['blocks', 'addBlocks'],
['addBlockedBy', 'addBlockedBy'],
['add_blocked_by', 'addBlockedBy'],
['blockedBy', 'addBlockedBy'],
['blocked_by', 'addBlockedBy'],
] as const) {
const value = extractLooseTaskStringArray(rawInput, sourceKey)
if (value !== null && repaired[targetKey] === undefined) {
repaired[targetKey] = value
}
}
} else if (toolName === TASK_CREATE_TOOL_NAME) {
for (const key of ['subject', 'description', 'activeForm']) {
const value = extractLooseTaskStringField(rawInput, key)
if (value !== null) repaired[key] = value
}
const activeForm = extractLooseTaskStringField(rawInput, 'active_form')
if (activeForm !== null && repaired.activeForm === undefined) {
repaired.activeForm = activeForm
}
}
if (
(toolName === TASK_GET_TOOL_NAME || toolName === TASK_UPDATE_TOOL_NAME) &&
typeof repaired.taskId !== 'string'
) {
return null
}
if (
toolName === TASK_UPDATE_TOOL_NAME &&
!Object.keys(repaired).some(key => key !== 'taskId')
) {
return null
}
if (
toolName === TASK_CREATE_TOOL_NAME &&
(typeof repaired.subject !== 'string' ||
typeof repaired.description !== 'string')
) {
return null
}
return Object.keys(repaired).length > 0 ? repaired : null
function createInvalidToolArgumentsError(toolName: string): InvalidToolCallResponseError {
return new InvalidToolCallResponseError(
`Model response error: invalid tool call arguments for ${toolName}. The tool call was not executed.`,
)
}
/**
@ -395,7 +261,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 =
@ -3118,6 +2984,12 @@ export function normalizeContentFromAPI(
return contentBlocks.map(contentBlock => {
switch (contentBlock.type) {
case 'tool_use': {
if (typeof contentBlock.name !== 'string' || contentBlock.name === '') {
throw new InvalidToolCallResponseError(
'Model response error: invalid tool call without a tool name. The tool call was not executed.',
)
}
if (
typeof contentBlock.input !== 'string' &&
!isObject(contentBlock.input)
@ -3134,11 +3006,10 @@ export function normalizeContentFromAPI(
let normalizedInput: unknown
if (typeof contentBlock.input === 'string') {
const parsed = safeParseJSON(contentBlock.input)
if (parsed === null && contentBlock.input.length > 0) {
if (parsed === null) {
// TET/FC-v3 diagnostic: the streamed tool input JSON failed to
// parse. We fall back to {} which means downstream validation
// sees empty input. The raw prefix goes to debug log only — no
// PII-tagged proto column exists for it yet.
// 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,
@ -3149,12 +3020,16 @@ export function normalizeContentFromAPI(
{ level: 'warn' },
)
}
throw createInvalidToolArgumentsError(contentBlock.name)
}
normalizedInput =
parsed ??
repairTaskToolInput(contentBlock.name, contentBlock.input) ??
{}
if (!isPlainToolInput(parsed)) {
throw createInvalidToolArgumentsError(contentBlock.name)
}
normalizedInput = parsed
} else {
if (!isPlainToolInput(contentBlock.input)) {
throw createInvalidToolArgumentsError(contentBlock.name)
}
normalizedInput = contentBlock.input
}