Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
194 changes: 194 additions & 0 deletions src/utils/messages/sanitizeToolInput.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
}
});
});
57 changes: 57 additions & 0 deletions src/utils/messages/sanitizeToolInput.ts
Original file line number Diff line number Diff line change
@@ -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;
}),
};
});
}