( contentBlocks: BetaMessage['content'], tools: Tools, agentId?: AgentId, )
| 2981 | // Sometimes the API returns empty messages (eg. "\n\n"). We need to filter these out, |
| 2982 | // otherwise they will give an API error when we send them to the API next time we call query(). |
| 2983 | export function normalizeContentFromAPI( |
| 2984 | contentBlocks: BetaMessage['content'], |
| 2985 | tools: Tools, |
| 2986 | agentId?: AgentId, |
| 2987 | ): BetaMessage['content'] { |
| 2988 | if (!contentBlocks) { |
| 2989 | return [] |
| 2990 | } |
| 2991 | return contentBlocks.map(contentBlock => { |
| 2992 | switch (contentBlock.type) { |
| 2993 | case 'tool_use': { |
| 2994 | if ( |
| 2995 | typeof contentBlock.input !== 'string' && |
| 2996 | !isObject(contentBlock.input) |
| 2997 | ) { |
| 2998 | // we stream tool use inputs as strings, but when we fall back, they're objects |
| 2999 | throw new Error('Tool use input must be a string or object') |
| 3000 | } |
| 3001 | |
| 3002 | // With fine-grained streaming on, we are getting a stringied JSON back from the API. |
| 3003 | // The API has strange behaviour, where it returns nested stringified JSONs, and so |
| 3004 | // we need to recursively parse these. If the top-level value returned from the API is |
| 3005 | // an empty string, this should become an empty object (nested values should be empty string). |
| 3006 | // TODO: This needs patching as recursive fields can still be stringified |
| 3007 | let normalizedInput: unknown |
| 3008 | if (typeof contentBlock.input === 'string') { |
| 3009 | const parsed = safeParseJSON(contentBlock.input) |
| 3010 | if (parsed === null && contentBlock.input.length > 0) { |
| 3011 | // TET/FC-v3 diagnostic: the streamed tool input JSON failed to |
| 3012 | // parse. We fall back to {} which means downstream validation |
| 3013 | // sees empty input. The raw prefix goes to debug log only — no |
| 3014 | // PII-tagged proto column exists for it yet. |
| 3015 | logEvent('tengu_tool_input_json_parse_fail', { |
| 3016 | toolName: sanitizeToolNameForAnalytics(contentBlock.name), |
| 3017 | inputLen: contentBlock.input.length, |
| 3018 | }) |
| 3019 | if (process.env.USER_TYPE === 'ant') { |
| 3020 | logForDebugging( |
| 3021 | `tool input JSON parse fail: ${contentBlock.input.slice(0, 200)}`, |
| 3022 | { level: 'warn' }, |
| 3023 | ) |
| 3024 | } |
| 3025 | } |
| 3026 | normalizedInput = parsed ?? {} |
| 3027 | } else { |
| 3028 | normalizedInput = contentBlock.input |
| 3029 | } |
| 3030 | |
| 3031 | // Then apply tool-specific corrections |
| 3032 | if (typeof normalizedInput === 'object' && normalizedInput !== null) { |
| 3033 | const tool = findToolByName(tools, contentBlock.name) |
| 3034 | if (tool) { |
| 3035 | try { |
| 3036 | normalizedInput = normalizeToolInput( |
| 3037 | tool, |
| 3038 | normalizedInput as { [key: string]: unknown }, |
| 3039 | agentId, |
| 3040 | ) |
no test coverage detected