fix(openai): reject invalid streamed tool arguments
## Bug 详情
OpenAI 兼容流返回工具调用时,模型可能输出无法解析为 JSON object 的 arguments,例如 TaskUpdate 参数片段最终拼成非法 JSON。此前这类返回会被任务工具宽松修复或降级为 {},用户看不到模型返回不合法,工具也可能进入错误执行路径。
## 根因
normalizeContentFromAPI 在解析 tool_use.input 失败后会尝试 repairTaskToolInput,并在无法修复时回退为空对象;OpenAI 组装层没有把非法工具调用转换成用户可见提示。
## 修复方案
将工具调用参数作为模型边界输入严格校验:最终工具名必须存在,工具参数必须解析为 JSON object。非法响应抛出 InvalidToolCallResponseError,并在 OpenAI 组装层转换为普通英文 assistant 提示,工具不再执行。
## 变更要点
- 移除任务工具参数的宽松修复和 {} 降级逻辑
- 新增非法工具调用参数和缺失工具名的显式错误
- OpenAI 组装层捕获非法工具调用并显示普通英文提示
- 增加流式分片省略 name 时保留初始工具名的测试
## 自测
- bun test src/utils/__tests__/messages.test.ts
- bun test packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts
- git diff --check
- bun run typecheck 仍失败,失败项为仓库既有类型问题,未涉及本次改动文件
This commit is contained in:
parent
416e2767ed
commit
61610d08cd
|
|
@ -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 () => {
|
test('maps finish_reason stop to end_turn', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ import type { Options } from '../claude.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import {
|
import {
|
||||||
createAssistantAPIErrorMessage,
|
createAssistantAPIErrorMessage,
|
||||||
|
createAssistantMessage,
|
||||||
|
InvalidToolCallResponseError,
|
||||||
createUserMessage,
|
createUserMessage,
|
||||||
normalizeContentFromAPI,
|
normalizeContentFromAPI,
|
||||||
} from '../../../utils/messages.js'
|
} from '../../../utils/messages.js'
|
||||||
|
|
@ -166,23 +168,32 @@ function assembleFinalAssistantOutputs(params: {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
if (allBlocks.length > 0) {
|
if (allBlocks.length > 0) {
|
||||||
outputs.push({
|
try {
|
||||||
message: {
|
outputs.push({
|
||||||
...partialMessage,
|
message: {
|
||||||
content: normalizeContentFromAPI(
|
...partialMessage,
|
||||||
allBlocks,
|
content: normalizeContentFromAPI(
|
||||||
tools,
|
allBlocks,
|
||||||
agentId as AgentId | undefined,
|
tools,
|
||||||
),
|
agentId as AgentId | undefined,
|
||||||
usage,
|
),
|
||||||
stop_reason: stopReason,
|
usage,
|
||||||
stop_sequence: null,
|
stop_reason: stopReason,
|
||||||
},
|
stop_sequence: null,
|
||||||
requestId: undefined,
|
},
|
||||||
type: 'assistant',
|
requestId: undefined,
|
||||||
uuid: randomUUID(),
|
type: 'assistant',
|
||||||
timestamp: new Date().toISOString(),
|
uuid: randomUUID(),
|
||||||
} as AssistantMessage)
|
timestamp: new Date().toISOString(),
|
||||||
|
} as AssistantMessage)
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof InvalidToolCallResponseError)) throw error
|
||||||
|
outputs.push(
|
||||||
|
createAssistantMessage({
|
||||||
|
content: error.message,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stopReason === 'max_tokens') {
|
if (stopReason === 'max_tokens') {
|
||||||
|
|
|
||||||
|
|
@ -206,87 +206,8 @@ describe('createToolResultStopMessage', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('normalizeContentFromAPI task tool input repair', () => {
|
describe('normalizeContentFromAPI tool input validation', () => {
|
||||||
test('repairs unquoted TaskUpdate status and preserves taskId', () => {
|
test('normalizes valid tool input objects', () => {
|
||||||
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', () => {
|
|
||||||
const content = normalizeContentFromAPI(
|
const content = normalizeContentFromAPI(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -298,69 +219,96 @@ describe('normalizeContentFromAPI task tool input repair', () => {
|
||||||
{
|
{
|
||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
id: 'toolu_2',
|
id: 'toolu_2',
|
||||||
name: 'Bash',
|
name: 'TaskUpdate',
|
||||||
input: '{"command": ls"}',
|
input: '{"status":"completed","taskId":"1"}',
|
||||||
} as any,
|
} as any,
|
||||||
],
|
],
|
||||||
[TaskGetTool] as any,
|
[TaskGetTool, TaskUpdateTool] as any,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect((content[0] as any).input).toEqual({ taskId: '1' })
|
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(
|
const content = normalizeContentFromAPI(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
id: 'toolu_1',
|
id: 'toolu_1',
|
||||||
name: 'TaskCreate',
|
name: 'TaskList',
|
||||||
input: '{"subject": "missing description"',
|
input: '{}',
|
||||||
} as any,
|
|
||||||
{
|
|
||||||
type: 'tool_use',
|
|
||||||
id: 'toolu_2',
|
|
||||||
name: 'TaskUpdate',
|
|
||||||
input: '{"status": completedd"}',
|
|
||||||
} as any,
|
} as any,
|
||||||
],
|
],
|
||||||
[] 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({})
|
expect((content[0] as any).input).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,6 @@ import {
|
||||||
} from '@claude-code-best/builtin-tools/tools/FileReadTool/FileReadTool.js'
|
} 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 { 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_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_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 { TASK_UPDATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskUpdateTool/constants.js'
|
||||||
import type { PermissionMode } from '../types/permissions.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."
|
"\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 TOOL_REFERENCE_TURN_BOUNDARY = 'Tool loaded.'
|
||||||
const TASK_TOOL_NAMES = new Set([
|
export class InvalidToolCallResponseError extends Error {
|
||||||
TASK_CREATE_TOOL_NAME,
|
constructor(message: string) {
|
||||||
TASK_GET_TOOL_NAME,
|
super(message)
|
||||||
TASK_LIST_TOOL_NAME,
|
this.name = 'InvalidToolCallResponseError'
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
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 {
|
function isPlainToolInput(value: unknown): value is Record<string, unknown> {
|
||||||
const quoted = new RegExp(`["']${key}["']\\s*:\\s*["']([^"']*)`).exec(raw)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
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 extractLooseTaskStringArray(raw: string, key: string): string[] | null {
|
function createInvalidToolArgumentsError(toolName: string): InvalidToolCallResponseError {
|
||||||
const match = new RegExp(`["']${key}["']\\s*:\\s*\\[([^\\]]*)\\]`).exec(raw)
|
return new InvalidToolCallResponseError(
|
||||||
if (!match) return null
|
`Model response error: invalid tool call arguments for ${toolName}. The tool call was not executed.`,
|
||||||
|
)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -3118,6 +2984,12 @@ export function normalizeContentFromAPI(
|
||||||
return contentBlocks.map(contentBlock => {
|
return contentBlocks.map(contentBlock => {
|
||||||
switch (contentBlock.type) {
|
switch (contentBlock.type) {
|
||||||
case 'tool_use': {
|
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 (
|
if (
|
||||||
typeof contentBlock.input !== 'string' &&
|
typeof contentBlock.input !== 'string' &&
|
||||||
!isObject(contentBlock.input)
|
!isObject(contentBlock.input)
|
||||||
|
|
@ -3134,11 +3006,10 @@ export function normalizeContentFromAPI(
|
||||||
let normalizedInput: unknown
|
let normalizedInput: unknown
|
||||||
if (typeof contentBlock.input === 'string') {
|
if (typeof contentBlock.input === 'string') {
|
||||||
const parsed = safeParseJSON(contentBlock.input)
|
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
|
// TET/FC-v3 diagnostic: the streamed tool input JSON failed to
|
||||||
// parse. We fall back to {} which means downstream validation
|
// parse. The raw prefix goes to debug log only — no PII-tagged
|
||||||
// sees empty input. The raw prefix goes to debug log only — no
|
// proto column exists for it yet.
|
||||||
// PII-tagged proto column exists for it yet.
|
|
||||||
logEvent('tengu_tool_input_json_parse_fail', {
|
logEvent('tengu_tool_input_json_parse_fail', {
|
||||||
toolName: sanitizeToolNameForAnalytics(contentBlock.name),
|
toolName: sanitizeToolNameForAnalytics(contentBlock.name),
|
||||||
inputLen: contentBlock.input.length,
|
inputLen: contentBlock.input.length,
|
||||||
|
|
@ -3149,12 +3020,16 @@ export function normalizeContentFromAPI(
|
||||||
{ level: 'warn' },
|
{ level: 'warn' },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
throw createInvalidToolArgumentsError(contentBlock.name)
|
||||||
}
|
}
|
||||||
normalizedInput =
|
if (!isPlainToolInput(parsed)) {
|
||||||
parsed ??
|
throw createInvalidToolArgumentsError(contentBlock.name)
|
||||||
repairTaskToolInput(contentBlock.name, contentBlock.input) ??
|
}
|
||||||
{}
|
normalizedInput = parsed
|
||||||
} else {
|
} else {
|
||||||
|
if (!isPlainToolInput(contentBlock.input)) {
|
||||||
|
throw createInvalidToolArgumentsError(contentBlock.name)
|
||||||
|
}
|
||||||
normalizedInput = contentBlock.input
|
normalizedInput = contentBlock.input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user