( contentBlocks: BetaMessage['content'], tools: Tools, agentId?: AgentId, )
| 2649 | // Sometimes the API returns empty messages (eg. "\n\n"). We need to filter these out, |
| 2650 | // otherwise they will give an API error when we send them to the API next time we call query(). |
| 2651 | export function normalizeContentFromAPI( |
| 2652 | contentBlocks: BetaMessage['content'], |
| 2653 | tools: Tools, |
| 2654 | agentId?: AgentId, |
| 2655 | ): BetaMessage['content'] { |
| 2656 | if (!contentBlocks) { |
| 2657 | return [] |
| 2658 | } |
| 2659 | return contentBlocks.map(contentBlock => { |
| 2660 | switch (contentBlock.type) { |
| 2661 | case 'tool_use': { |
| 2662 | if ( |
| 2663 | typeof contentBlock.input !== 'string' && |
| 2664 | !isObject(contentBlock.input) |
| 2665 | ) { |
| 2666 | // we stream tool use inputs as strings, but when we fall back, they're objects |
| 2667 | throw new Error('Tool use input must be a string or object') |
| 2668 | } |
| 2669 | |
| 2670 | // With fine-grained streaming on, we are getting a stringied JSON back from the API. |
| 2671 | // The API has strange behaviour, where it returns nested stringified JSONs, and so |
| 2672 | // we need to recursively parse these. If the top-level value returned from the API is |
| 2673 | // an empty string, this should become an empty object (nested values should be empty string). |
| 2674 | // TODO: This needs patching as recursive fields can still be stringified |
| 2675 | let normalizedInput: unknown |
| 2676 | if (typeof contentBlock.input === 'string') { |
| 2677 | const parsed = safeParseJSON(contentBlock.input) |
| 2678 | if (parsed === null && contentBlock.input.length > 0) { |
| 2679 | // TET/FC-v3 diagnostic: the streamed tool input JSON failed to |
| 2680 | // parse. We fall back to {} which means downstream validation |
| 2681 | // sees empty input. The raw prefix goes to debug log only — no |
| 2682 | // PII-tagged proto column exists for it yet. |
| 2683 | logEvent('tengu_tool_input_json_parse_fail', { |
| 2684 | toolName: sanitizeToolNameForAnalytics(contentBlock.name), |
| 2685 | inputLen: contentBlock.input.length, |
| 2686 | }) |
| 2687 | if (process.env.USER_TYPE === 'ant') { |
| 2688 | logForDebugging( |
| 2689 | `tool input JSON parse fail: ${contentBlock.input.slice(0, 200)}`, |
| 2690 | { level: 'warn' }, |
| 2691 | ) |
| 2692 | } |
| 2693 | } |
| 2694 | normalizedInput = parsed ?? {} |
| 2695 | } else { |
| 2696 | normalizedInput = contentBlock.input |
| 2697 | } |
| 2698 | |
| 2699 | // Then apply tool-specific corrections |
| 2700 | if (typeof normalizedInput === 'object' && normalizedInput !== null) { |
| 2701 | const tool = findToolByName(tools, contentBlock.name) |
| 2702 | if (tool) { |
| 2703 | try { |
| 2704 | normalizedInput = normalizeToolInput( |
| 2705 | tool, |
| 2706 | normalizedInput as { [key: string]: unknown }, |
| 2707 | agentId, |
| 2708 | ) |
no test coverage detected