Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ bool Qwen3CoderToolParserImpl::parseUntilStateChange(ToolCalls_t& toolCalls) {
case State::InsideFunctionName: {
DEFINE_TAG_POSITION_AND_BREAK_IF_NOT_FOUND(Qwen3CoderToolParser::XML_TAG_END);
this->currentFunction.name = streamContent.substr(this->lastProcessedPosition, pos - this->lastProcessedPosition);
// Some models quote the tag attribute (<function="name">); the quotes are delimiters, not part of the name.
trimSurroundingQuotes(this->currentFunction.name);
this->lastProcessedPosition = pos + Qwen3CoderToolParser::XML_TAG_END.length();
this->currentState = State::InsideFunction;
break;
Expand All @@ -175,6 +177,8 @@ bool Qwen3CoderToolParserImpl::parseUntilStateChange(ToolCalls_t& toolCalls) {
case State::InsideParameterName: {
DEFINE_TAG_POSITION_AND_BREAK_IF_NOT_FOUND(Qwen3CoderToolParser::XML_TAG_END);
this->currentParameterName = streamContent.substr(this->lastProcessedPosition, pos - this->lastProcessedPosition);
// Some models quote the tag attribute (<parameter="name">); the quotes are delimiters, not part of the name.
trimSurroundingQuotes(this->currentParameterName);
this->lastProcessedPosition = pos + Qwen3CoderToolParser::XML_TAG_END.length();
this->currentState = State::InsideParameter;
break;
Expand Down
15 changes: 15 additions & 0 deletions src/llm/io_processing/utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ void trimNewline(std::string& str) {
}
}

void trimSurroundingQuotes(std::string& str) {
if (str.size() < 2) {
return;
}
const char quote = str.front();
if ((quote != '"' && quote != '\'') || str.back() != quote) {
return;
}
// A quote in the middle means the character belongs to the value, not to a delimiter pair.
if (str.find(quote, 1) != str.size() - 1) {
return;
}
str = str.substr(1, str.size() - 2);
}

const char* jsonTypeOf(const rapidjson::Value& val) {
if (val.IsObject())
return "object";
Expand Down
6 changes: 6 additions & 0 deletions src/llm/io_processing/utils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ void writeArgumentOfAnyType(const rapidjson::Value& arg, rapidjson::Writer<rapid
// Trims a single leading and a single trailing '\n' from str (in place).
void trimNewline(std::string& str);

// Trims one wrapping pair of quotes (either " or ') from an XML-style tag attribute value
// (in place). Only a clean wrapping pair is treated as a delimiter: the value is left
// untouched unless the same quote character is both its first and its last character and
// does not occur anywhere in between.
void trimSurroundingQuotes(std::string& str);

// Returns a human-readable name of the JSON value type (for tracing).
const char* jsonTypeOf(const rapidjson::Value& val);

Expand Down
83 changes: 83 additions & 0 deletions src/test/llm/io_processing_utils_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,86 @@ TEST(FindInStringTest, SingleQuoteClosedWithSpaceBeforeDelimiter) {
EXPECT_NE(pos, std::string::npos);
EXPECT_EQ(input[pos], ',');
}

// ── trimSurroundingQuotes: tag-attribute quote normalization (issue #4487) ───

TEST(TrimSurroundingQuotesTest, RemovesWrappingDoubleQuotes) {
std::string name = R"("command")";
trimSurroundingQuotes(name);
EXPECT_EQ(name, "command");
}

TEST(TrimSurroundingQuotesTest, RemovesWrappingSingleQuotes) {
std::string name = "'command'";
trimSurroundingQuotes(name);
EXPECT_EQ(name, "command");
}

TEST(TrimSurroundingQuotesTest, LeavesUnquotedNameByteIdentical) {
std::string name = "command";
trimSurroundingQuotes(name);
EXPECT_EQ(name, "command");
}

TEST(TrimSurroundingQuotesTest, RemovesOnlyOneLevelOfQuoting) {
// The inner pair is part of the value, so there is no unambiguous delimiter pair.
std::string name = R"(""command"")";
trimSurroundingQuotes(name);
EXPECT_EQ(name, R"(""command"")");
}

TEST(TrimSurroundingQuotesTest, KeepsOtherQuoteCharacterInsideThePair) {
std::string name = R"("it's")";
trimSurroundingQuotes(name);
EXPECT_EQ(name, "it's");
}

TEST(TrimSurroundingQuotesTest, LeavesInternalQuoteUntouched) {
// A quote between the ends is a regular character, not a delimiter.
std::string name = R"(arg"1)";
trimSurroundingQuotes(name);
EXPECT_EQ(name, R"(arg"1)");
}

TEST(TrimSurroundingQuotesTest, DoesNotJoinTwoSeparatelyQuotedTokens) {
// Stripping here would silently merge two quoted tokens into one value.
std::string name = R"("a" "b")";
trimSurroundingQuotes(name);
EXPECT_EQ(name, R"("a" "b")");
}

TEST(TrimSurroundingQuotesTest, LeavesUnmatchedLeadingQuote) {
// Deliberately out of scope: an unpaired quote is not a delimiter pair, and the parser
// cannot tell a dropped closing quote from a value that starts with a quote.
std::string name = R"("command)";
trimSurroundingQuotes(name);
EXPECT_EQ(name, R"("command)");
}

TEST(TrimSurroundingQuotesTest, LeavesUnmatchedTrailingQuote) {
std::string name = R"(command")";
trimSurroundingQuotes(name);
EXPECT_EQ(name, R"(command")");
}

TEST(TrimSurroundingQuotesTest, LeavesMismatchedQuoteCharacters) {
std::string name = R"("command')";
trimSurroundingQuotes(name);
EXPECT_EQ(name, R"("command')");
}

TEST(TrimSurroundingQuotesTest, LeavesEmptyAndSingleCharacterInputUntouched) {
std::string empty;
trimSurroundingQuotes(empty);
EXPECT_EQ(empty, "");
std::string lone = R"(")";
trimSurroundingQuotes(lone);
EXPECT_EQ(lone, R"(")");
}

TEST(TrimSurroundingQuotesTest, EmptyQuotedValueBecomesEmpty) {
// Matches what an unquoted empty attribute (<parameter=>) already yields today.
std::string name = R"("")";
trimSurroundingQuotes(name);
EXPECT_EQ(name, "");
}
Loading