Commit c90aa996b for llama.cpp
commit c90aa996bab649751c065a7a27b77f53f5141e66
Author: Vladimir Sotnikov <bubert9@gmail.com>
Date: Tue Nov 10 09:56:53 2026 +0200
common : escape control characters in tool argument strings (#31937)
normalize_quotes_to_json copies raw control characters verbatim while
converting pythonic tool arguments to JSON, so calls like
python(code='a<LF>b') produce invalid JSON in the arguments returned to
clients; replaying that history then fails hard. Escape C0 control
characters inside quoted strings via escape_json_string_inner.
diff --git a/common/chat-peg-parser.cpp b/common/chat-peg-parser.cpp
index 21620a7a4..4440b2ebd 100644
--- a/common/chat-peg-parser.cpp
+++ b/common/chat-peg-parser.cpp
@@ -131,6 +131,12 @@ static std::string normalize_quotes_to_json(const std::string & input) {
continue;
}
+ // Models can emit raw control characters inside string literals; escape them to keep the JSON valid.
+ if ((in_single_quoted || in_double_quoted) && static_cast<unsigned char>(c) < 0x20) {
+ result += escape_json_string_inner(std::string(1, c));
+ continue;
+ }
+
// Handle quote characters
if (c == '"') {
if (in_single_quoted) {
diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp
index 57955b983..a583604a7 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -2160,6 +2160,12 @@ static void test_lfm2_parser(const std::string & template_path, bool detailed_de
})
.run();
+ // Raw control characters in pythonic string arguments must be escaped into valid JSON.
+ tst.test("<|tool_call_start|>[python(code='a\nb')]<|tool_call_end|>")
+ .tools({ python_tool })
+ .expect_tool_calls({ { "python", R"#({"code": "a\nb"})#", "" } })
+ .run();
+
// String escape sequences decode to their actual characters (newline + tab here),
// so a "write a two line file" style call produces real line breaks, not literal "\n".
tst.test("<|tool_call_start|>[python(code=\"First line\\nSecond line\\tindented\")]<|tool_call_end|>")