Commit 1b55c515da44

Vincent Demeester <vincent@sbr.pm>
2026-03-31 15:41:37
fix(pi): escape control chars in vertex-claude JSON parsing
Anthropic Vertex API emits literal tab characters inside tool call argument strings when the model copies tab-indented code (Go, Makefiles, Emacs Lisp). JSON.parse rejects these with 'Bad control character in string literal'. Added escapeControlCharsInJsonStrings() to sanitize bare control chars inside JSON string values before parsing.
1 parent f78db0e
Changed files (1)
dots
pi
agent
extensions
vertex-claude
dots/pi/agent/extensions/vertex-claude/index.ts
@@ -344,16 +344,63 @@ export function mapStopReason(reason: string): StopReason {
 	}
 }
 
+// Escape control characters that are invalid inside JSON string literals.
+// The Anthropic API sometimes emits literal tabs/newlines inside tool call
+// argument strings (e.g. when the model copies Go or Makefile indentation).
+// JSON.parse rejects these with "Bad control character in string literal".
+function escapeControlCharsInJsonStrings(json: string): string {
+	let result = "";
+	let inString = false;
+	let escape = false;
+	for (let i = 0; i < json.length; i++) {
+		const ch = json[i];
+		if (escape) {
+			result += ch;
+			escape = false;
+			continue;
+		}
+		if (ch === "\\" && inString) {
+			result += ch;
+			escape = true;
+			continue;
+		}
+		if (ch === '"') {
+			inString = !inString;
+			result += ch;
+			continue;
+		}
+		if (inString) {
+			const code = ch.charCodeAt(0);
+			if (code < 0x20) {
+				// Replace control chars with their JSON escape sequences
+				switch (code) {
+					case 0x09: result += "\\t"; break;
+					case 0x0a: result += "\\n"; break;
+					case 0x0d: result += "\\r"; break;
+					case 0x08: result += "\\b"; break;
+					case 0x0c: result += "\\f"; break;
+					default:   result += "\\u" + code.toString(16).padStart(4, "0"); break;
+				}
+				continue;
+			}
+		}
+		result += ch;
+	}
+	return result;
+}
+
 // Streaming JSON parser for tool arguments
 export function parseStreamingJson(partialJson: string): Record<string, any> {
 	if (!partialJson || partialJson.trim() === "") {
 		return {};
 	}
+	// Escape bare control characters that the model may emit inside strings
+	const sanitized = escapeControlCharsInJsonStrings(partialJson);
 	try {
-		return JSON.parse(partialJson);
+		return JSON.parse(sanitized);
 	} catch {
 		try {
-			return partialParse(partialJson) ?? {};
+			return partialParse(sanitized) ?? {};
 		} catch {
 			return {};
 		}