diff --git a/src/services/aiService.ts b/src/services/aiService.ts index fe15e1ca2..eeca8cc94 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -3,6 +3,7 @@ import * as os from "os"; import { EventEmitter } from "events"; import { convertToModelMessages, type LanguageModel } from "ai"; import { applyToolOutputRedaction } from "@/utils/messages/applyToolOutputRedaction"; +import { sanitizeToolInputs } from "@/utils/messages/sanitizeToolInput"; import type { Result } from "@/types/result"; import { Ok, Err } from "@/types/result"; import type { WorkspaceMetadata } from "@/types/workspace"; @@ -461,10 +462,16 @@ export class AIService extends EventEmitter { const redactedForProvider = applyToolOutputRedaction(messagesWithModeContext); log.debug_obj(`${workspaceId}/2a_redacted_messages.json`, redactedForProvider); + // Sanitize tool inputs to ensure they are valid objects (not strings or arrays) + // This fixes cases where corrupted data in history has malformed tool inputs + // that would cause API errors like "Input should be a valid dictionary" + const sanitizedMessages = sanitizeToolInputs(redactedForProvider); + log.debug_obj(`${workspaceId}/2b_sanitized_messages.json`, sanitizedMessages); + // Convert CmuxMessage to ModelMessage format using Vercel AI SDK utility // Type assertion needed because CmuxMessage has custom tool parts for interrupted tools // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - const modelMessages = convertToModelMessages(redactedForProvider as any); + const modelMessages = convertToModelMessages(sanitizedMessages as any); log.debug_obj(`${workspaceId}/2_model_messages.json`, modelMessages); // Apply ModelMessage transforms based on provider requirements diff --git a/src/utils/messages/sanitizeToolInput.test.ts b/src/utils/messages/sanitizeToolInput.test.ts new file mode 100644 index 000000000..54b46e62a --- /dev/null +++ b/src/utils/messages/sanitizeToolInput.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from "@jest/globals"; +import type { CmuxMessage } from "@/types/message"; +import { sanitizeToolInputs } from "./sanitizeToolInput"; + +describe("sanitizeToolInputs", () => { + it("should handle the actual malformed message from httpjail-coder workspace", () => { + // This is the actual problematic message that caused the bug + const problematicMessage: CmuxMessage = { + id: "assistant-1761527027508-karjrpf3g", + role: "assistant", + metadata: { + historySequence: 1, + timestamp: 1761527027508, + partial: true, + }, + parts: [ + { + type: "text", + text: "I'll explore this repository.", + }, + { + type: "dynamic-tool", + toolCallId: "toolu_01DXeXp8oArG4PzT9rk4hz5c", + toolName: "bash", + state: "output-available", + // THIS IS THE MALFORMED INPUT - string instead of object + input: '{"script" timeout_secs="10": "ls"}', + output: { + error: "Invalid input for tool bash: JSON parsing failed", + }, + }, + ], + }; + + const sanitized = sanitizeToolInputs([problematicMessage]); + const sanitizedTool = sanitized[0].parts[1]; + + if (sanitizedTool.type === "dynamic-tool") { + // Should be converted to empty object + expect(sanitizedTool.input).toEqual({}); + } + }); + + it("should convert string inputs to empty objects", () => { + const messages: CmuxMessage[] = [ + { + id: "test-1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "toolu_01test", + toolName: "bash", + state: "output-available", + input: "not an object", + output: { error: "Invalid input" }, + }, + ], + metadata: { timestamp: Date.now(), historySequence: 1 }, + }, + ]; + + const sanitized = sanitizeToolInputs(messages); + expect(sanitized[0].parts[0]).toMatchObject({ + type: "dynamic-tool", + input: {}, // Should be converted to empty object + }); + }); + + it("should keep valid object inputs unchanged", () => { + const messages: CmuxMessage[] = [ + { + id: "test-2", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "toolu_02test", + toolName: "bash", + state: "output-available", + input: { script: "ls", timeout_secs: 10 }, + output: { success: true }, + }, + ], + metadata: { timestamp: Date.now(), historySequence: 2 }, + }, + ]; + + const sanitized = sanitizeToolInputs(messages); + expect(sanitized[0].parts[0]).toMatchObject({ + type: "dynamic-tool", + input: { script: "ls", timeout_secs: 10 }, + }); + }); + + it("should not modify non-assistant messages", () => { + const messages: CmuxMessage[] = [ + { + id: "test-3", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: Date.now(), historySequence: 3 }, + }, + ]; + + const sanitized = sanitizeToolInputs(messages); + expect(sanitized).toEqual(messages); + }); + + it("should handle messages with multiple parts", () => { + const messages: CmuxMessage[] = [ + { + id: "test-4", + role: "assistant", + parts: [ + { type: "text", text: "Let me run this command" }, + { + type: "dynamic-tool", + toolCallId: "toolu_04test", + toolName: "bash", + state: "output-available", + input: "malformed", + output: { error: "bad" }, + }, + { type: "text", text: "Done" }, + ], + metadata: { timestamp: Date.now(), historySequence: 4 }, + }, + ]; + + const sanitized = sanitizeToolInputs(messages); + expect(sanitized[0].parts[1]).toMatchObject({ + type: "dynamic-tool", + input: {}, + }); + // Other parts should be unchanged + expect(sanitized[0].parts[0]).toEqual({ type: "text", text: "Let me run this command" }); + expect(sanitized[0].parts[2]).toEqual({ type: "text", text: "Done" }); + }); + + it("should handle null input", () => { + const messages: CmuxMessage[] = [ + { + id: "test-null", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "toolu_null", + toolName: "bash", + state: "output-available", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: null as any, + output: { error: "Invalid" }, + }, + ], + metadata: { timestamp: Date.now(), historySequence: 1 }, + }, + ]; + + const sanitized = sanitizeToolInputs(messages); + const toolPart = sanitized[0].parts[0]; + if (toolPart.type === "dynamic-tool") { + expect(toolPart.input).toEqual({}); + } + }); + + it("should handle array input", () => { + const messages: CmuxMessage[] = [ + { + id: "test-array", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "toolu_array", + toolName: "bash", + state: "output-available", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: ["not", "valid"] as any, + output: { error: "Invalid" }, + }, + ], + metadata: { timestamp: Date.now(), historySequence: 1 }, + }, + ]; + + const sanitized = sanitizeToolInputs(messages); + const toolPart = sanitized[0].parts[0]; + if (toolPart.type === "dynamic-tool") { + expect(toolPart.input).toEqual({}); + } + }); +}); diff --git a/src/utils/messages/sanitizeToolInput.ts b/src/utils/messages/sanitizeToolInput.ts new file mode 100644 index 000000000..bf9ca48a2 --- /dev/null +++ b/src/utils/messages/sanitizeToolInput.ts @@ -0,0 +1,57 @@ +import type { CmuxMessage, CmuxToolPart } from "@/types/message"; + +/** + * Sanitizes tool inputs in messages to ensure they are valid objects. + * + * The Anthropic API (and other LLM APIs) require tool inputs to be objects/dictionaries. + * However, if the model generates malformed JSON or if we have corrupted data in history, + * the input field might be a string instead of an object. + * + * This causes API errors like: "Input should be a valid dictionary" + * + * This function ensures all tool inputs are objects by converting non-object inputs + * to empty objects. This allows the conversation to continue even with corrupted history. + * + * @param messages - Messages to sanitize + * @returns New array with sanitized messages (original messages are not modified) + */ +export function sanitizeToolInputs(messages: CmuxMessage[]): CmuxMessage[] { + return messages.map((msg) => { + // Only process assistant messages with tool parts + if (msg.role !== "assistant") { + return msg; + } + + // Check if any parts need sanitization + const needsSanitization = msg.parts.some( + (part) => + part.type === "dynamic-tool" && + (typeof part.input !== "object" || part.input === null || Array.isArray(part.input)) + ); + + if (!needsSanitization) { + return msg; + } + + // Create new message with sanitized parts + return { + ...msg, + parts: msg.parts.map((part): typeof part => { + if (part.type !== "dynamic-tool") { + return part; + } + + // Sanitize the input if it's not a valid object + if (typeof part.input !== "object" || part.input === null || Array.isArray(part.input)) { + const sanitized: CmuxToolPart = { + ...part, + input: {}, // Replace with empty object + }; + return sanitized; + } + + return part; + }), + }; + }); +}