diff --git a/src/llm/io_processing/gemma4/gemma4_tool_parser.cpp b/src/llm/io_processing/gemma4/gemma4_tool_parser.cpp index 612a84a382..f41a0d4f13 100644 --- a/src/llm/io_processing/gemma4/gemma4_tool_parser.cpp +++ b/src/llm/io_processing/gemma4/gemma4_tool_parser.cpp @@ -364,7 +364,9 @@ std::optional Gemma4ToolParser::parseChunk(const std::string& chunk, cons return ToolCallDelta{toolCallIndex, generateRandomId(), this->toolCall.name, ""}; } if (this->currentState == State::ToolCallEnded) { - return wrapDeltaArgs(this->toolCall.arguments, toolCallIndex); + auto delta = wrapDeltaArgs(this->toolCall.arguments, toolCallIndex); + this->toolCall = ToolCall{}; + return delta; } if (this->currentState == State::Content) { size_t contentEnd = this->streamingContent.find(TOOL_CALL_START_TAG, this->streamingPosition); diff --git a/src/llm/io_processing/lfm2/lfm2_tool_parser.cpp b/src/llm/io_processing/lfm2/lfm2_tool_parser.cpp index df15b95720..f53de068cb 100644 --- a/src/llm/io_processing/lfm2/lfm2_tool_parser.cpp +++ b/src/llm/io_processing/lfm2/lfm2_tool_parser.cpp @@ -339,7 +339,9 @@ std::optional Lfm2ToolParser::parseChunk(const std::string& chunk, return ToolCallDelta{this->toolCallIndex, generateRandomId(), this->toolCall.name, ""}; } if (this->currentState == Lfm2ParseState::ToolCallEnded) { - return wrapDeltaArgs(this->toolCall.arguments, this->toolCallIndex); + auto delta = wrapDeltaArgs(this->toolCall.arguments, this->toolCallIndex); + this->toolCall = ToolCall{}; + return delta; } if (this->currentState == Lfm2ParseState::Content) { const std::string& startTag = parsingConfig.startTags[0]; diff --git a/src/llm/io_processing/minicpm5/minicpm5_tool_parser.cpp b/src/llm/io_processing/minicpm5/minicpm5_tool_parser.cpp index 36258dd6ec..8e86c335cb 100644 --- a/src/llm/io_processing/minicpm5/minicpm5_tool_parser.cpp +++ b/src/llm/io_processing/minicpm5/minicpm5_tool_parser.cpp @@ -131,6 +131,19 @@ void Minicpm5ToolParserImpl::addParameterToCurrentFunctionDoc(std::string& param } Status Minicpm5ToolParserImpl::removeToolCallsFromContentIfNeeded(std::string& outContent) { + // Generation can be truncated mid-tool-call (max_tokens hit, or eos suppressed) so an opening + // "" close. That leaves begin with more + // entries than end. The unterminated call is always the most recent one (top of the begin + // stack), so drop it -- erasing from its start to end-of-content -- rather than bailing and + // leaving every (including completed) block in the content returned to the user. + while (toolCallPositions.begin.size() > toolCallPositions.end.size()) { + auto posBegin = toolCallPositions.begin.top(); + toolCallPositions.begin.pop(); + if (posBegin <= outContent.size()) { + SPDLOG_TRACE("Minicpm5: removing unterminated tool call from outContent begin:{} to end", posBegin); + outContent.erase(posBegin); + } + } if (toolCallPositions.begin.size() != toolCallPositions.end.size()) { SPDLOG_DEBUG("Minicpm5: mismatched tool tags, begin: {}, end: {}", toolCallPositions.begin.size(), toolCallPositions.end.size()); @@ -283,6 +296,38 @@ std::optional Minicpm5ToolParserImpl::parseChunk(const std::string& return std::nullopt; } +std::optional Minicpm5ToolParserImpl::finalizeOnGenerationEnd() { + if (this->currentState == State::Content || + this->currentState == State::InsideFunctionName) { + // No usable function name was ever captured -- nothing to recover. Still clear the + // dangling partial state so it doesn't look like a call is still in flight. + resetParsingState(); + return std::nullopt; + } + if (this->currentState == State::InsideParamName) { + // Drop the incomplete parameter name; close the function with whatever was captured before it. + this->currentState = State::InsideFunction; + } + if (this->currentState == State::InsideParam) { + this->streamContent += Minicpm5ToolParser::PARAM_END_TAG; + } + if (this->currentState == State::InsideParam || this->currentState == State::InsideFunction) { + this->streamContent += Minicpm5ToolParser::FUNCTION_END_TAG; + } + + ToolCalls_t toolCalls; + while (parseUntilStateChange(toolCalls)) { + } + // Generation has ended: nothing more will ever be parsed from streamContent, so leave the + // parser in the same clean state a normal completion would (toolCallPositions is kept -- + // removeToolCallsFromContentIfNeeded() still needs it afterward). + resetParsingState(); + if (!toolCalls.empty()) { + return std::move(toolCalls); + } + return std::nullopt; +} + std::optional Minicpm5ToolParserImpl::getCurrentFunctionName() const { if (this->currentFunction.name.empty()) return std::nullopt; @@ -361,11 +406,20 @@ std::optional Minicpm5ToolParser::sendFirstDeltaIfNeeded(const std::strin std::optional Minicpm5ToolParser::parseChunk( const std::string& newChunk, const std::vector& /*tokens*/, - ov::genai::GenerationFinishReason /*finishReason*/) { + ov::genai::GenerationFinishReason finishReason) { SPDLOG_DEBUG("Minicpm5ToolParser: chunk: '{}'", newChunk); - if (newChunk.empty()) + if (newChunk.empty() && finishReason == ov::genai::GenerationFinishReason::NONE) return std::nullopt; - auto toolCallsOpt = this->streamParser.parseChunk(newChunk); + std::optional toolCallsOpt; + if (!newChunk.empty()) { + toolCallsOpt = this->streamParser.parseChunk(newChunk); + } + + // If no complete tool calls were returned yet and generation has ended, finalize the current + // tool call in progress to recover any remaining data (for example if arguments were not closed properly). + if (!toolCallsOpt.has_value() && finishReason != ov::genai::GenerationFinishReason::NONE) { + toolCallsOpt = this->streamParser.finalizeOnGenerationEnd(); + } if (toolCallsOpt.has_value()) { return this->sendFullDelta(toolCallsOpt.value()); } diff --git a/src/llm/io_processing/minicpm5/minicpm5_tool_parser.hpp b/src/llm/io_processing/minicpm5/minicpm5_tool_parser.hpp index bbc8e56750..b34846b1a2 100644 --- a/src/llm/io_processing/minicpm5/minicpm5_tool_parser.hpp +++ b/src/llm/io_processing/minicpm5/minicpm5_tool_parser.hpp @@ -81,16 +81,18 @@ struct Minicpm5ToolParserImpl { */ std::optional parseChunk(const std::string& chunk); + // Called once generation has stopped and parseChunk() produced nothing new: synthesizes + // the closing tags still missing for whatever tool call is in flight (using only data + // already captured) so it can be recovered instead of silently dropped. An incomplete + // name/attribute can't be recovered and returns nullopt. + std::optional finalizeOnGenerationEnd(); + std::optional getCurrentFunctionName() const; Status removeToolCallsFromContentIfNeeded(std::string& outContent); void reset() { - currentState = State::Content; - currentFunction.clear(); - currentParameterName.clear(); - streamContent.clear(); - lastProcessedPosition = 0; + resetParsingState(); toolCallPositions = ToolCallPositions{}; } @@ -100,6 +102,15 @@ struct Minicpm5ToolParserImpl { private: const ToolsParameterTypeMap_t& toolsParametersTypeMap; const bool removeNewlineAroundParameters = true; + // Resets everything except toolCallPositions, which removeToolCallsFromContentIfNeeded() + // still needs to consult after generation ends. + void resetParsingState() { + currentState = State::Content; + currentFunction.clear(); + currentParameterName.clear(); + streamContent.clear(); + lastProcessedPosition = 0; + } State currentState = State::Content; Minicpm5Functool currentFunction; std::string currentParameterName; diff --git a/src/llm/io_processing/onyx/onyx_tool_parser.cpp b/src/llm/io_processing/onyx/onyx_tool_parser.cpp index 11adfb90ab..1d672507a6 100644 --- a/src/llm/io_processing/onyx/onyx_tool_parser.cpp +++ b/src/llm/io_processing/onyx/onyx_tool_parser.cpp @@ -208,6 +208,40 @@ std::optional OnyxToolParserImpl::parseChunk(const std::string& chu return std::nullopt; } +std::optional OnyxToolParserImpl::finalizeOnGenerationEnd() { + if (this->currentState == State::Content || + this->currentState == State::InsideToolCall || + this->currentState == State::InsideFunctionName) { + // No usable function name was ever captured -- nothing to recover. Still clear the + // dangling partial state so it doesn't look like a call is still in flight. + resetParsingState(); + return std::nullopt; + } + if (this->currentState == State::InsideParameterName) { + // Drop the incomplete parameter name; close the function with whatever was captured before it. + this->currentState = State::InsideFunction; + } + if (this->currentState == State::InsideParameter) { + this->streamContent += OnyxToolParser::PARAMETER_END_TAG; + } + if (this->currentState == State::InsideParameter || this->currentState == State::InsideFunction) { + this->streamContent += OnyxToolParser::FUNCTION_END_TAG; + } + this->streamContent += OnyxToolParser::TOOL_END_TAG; + + ToolCalls_t toolCalls; + while (parseUntilStateChange(toolCalls)) { + } + // Generation has ended: nothing more will ever be parsed from streamContent, so leave the + // parser in the same clean state a normal completion would (toolCallPositions is kept -- + // removeToolCallsFromContentIfNeeded() still needs it afterward). + resetParsingState(); + if (!toolCalls.empty()) { + return std::move(toolCalls); + } + return std::nullopt; +} + std::optional OnyxToolParserImpl::getCurrentFunctionName() const { if (this->currentFunction.name.empty()) { return std::nullopt; @@ -296,10 +330,20 @@ std::optional OnyxToolParser::parseChunk(const std::string& newChunk, con // streamParser returns assembled toolCalls once a call closes (""); // until then, if the function name is already known, send its first delta once. SPDLOG_DEBUG("Chunk: '{}', finishReason: {}", newChunk, static_cast(finishReason)); - if (newChunk.empty()) { + if (newChunk.empty() && finishReason == ov::genai::GenerationFinishReason::NONE) { return std::nullopt; } - auto toolCallsOpt = this->streamParser.parseChunk(newChunk); + std::optional toolCallsOpt; + if (!newChunk.empty()) { + toolCallsOpt = this->streamParser.parseChunk(newChunk); + } + + // If no complete tool calls were returned yet and generation has ended, finalize the current + // tool call in progress to recover any remaining data (for example if arguments were not closed properly). + if (!toolCallsOpt.has_value() && finishReason != ov::genai::GenerationFinishReason::NONE) { + toolCallsOpt = this->streamParser.finalizeOnGenerationEnd(); + } + if (toolCallsOpt.has_value()) { return this->sendFullDelta(toolCallsOpt.value()); } diff --git a/src/llm/io_processing/onyx/onyx_tool_parser.hpp b/src/llm/io_processing/onyx/onyx_tool_parser.hpp index 34b8c1c6fd..a33d12e961 100644 --- a/src/llm/io_processing/onyx/onyx_tool_parser.hpp +++ b/src/llm/io_processing/onyx/onyx_tool_parser.hpp @@ -90,14 +90,15 @@ struct OnyxToolParserImpl { // Return all tool calls fully closed ("" seen) in the aggregated // content so far that were not returned before -- nullopt if none completed yet. std::optional parseChunk(const std::string& chunk); + // Called once generation has stopped and parseChunk() produced nothing new: synthesizes + // the closing tags still missing for whatever tool call is in flight (using only data + // already captured) so it can be recovered instead of silently dropped. An incomplete + // name/attribute can't be recovered and returns nullopt. + std::optional finalizeOnGenerationEnd(); std::optional getCurrentFunctionName() const; Status removeToolCallsFromContentIfNeeded(std::string& outContent); void reset() { - currentState = State::Content; - currentFunction.clear(); - currentParameterName.clear(); - streamContent.clear(); - lastProcessedPosition = 0; + resetParsingState(); toolCallPositions = ToolCallPositions{}; } State getCurrentState() const { @@ -112,6 +113,15 @@ struct OnyxToolParserImpl { // Onyx renders parameter values tight ("...\">VALUE"), so unlike // qwen3coder there is no surrounding-newline convention to trim. const bool removeNewlineAroundParameters = false; + // Resets everything except toolCallPositions, which removeToolCallsFromContentIfNeeded() + // still needs to consult after generation ends. + void resetParsingState() { + currentState = State::Content; + currentFunction.clear(); + currentParameterName.clear(); + streamContent.clear(); + lastProcessedPosition = 0; + } State currentState = State::Content; OnyxFunctool currentFunction; std::string currentParameterName; diff --git a/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp b/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp index 5efec57872..eb501855a8 100644 --- a/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp +++ b/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp @@ -37,6 +37,19 @@ const std::string Qwen3CoderToolParser::FUNCTION_END_TAG = ""; const std::string Qwen3CoderToolParser::TOOL_END_TAG = ""; Status Qwen3CoderToolParserImpl::removeToolCallsFromContentIfNeeded(std::string& outContent) { + // Generation can be truncated mid-tool-call (max_tokens hit, or eos suppressed) so an opening + // ""/" toolCallPositions.end.size()) { + auto posBegin = toolCallPositions.begin.top(); + toolCallPositions.begin.pop(); + if (posBegin <= outContent.size()) { + SPDLOG_TRACE("Removing unterminated tool call from outContent begin:{} to end", posBegin); + outContent.erase(posBegin); + } + } if (toolCallPositions.begin.size() != toolCallPositions.end.size()) { SPDLOG_DEBUG("Mismatched tool tags, begin: {}, end: {}", toolCallPositions.begin.size(), toolCallPositions.end.size()); return Status(StatusCode::INTERNAL_ERROR, "Mismatched tool tags"); @@ -213,6 +226,40 @@ std::optional Qwen3CoderToolParserImpl::parseChunk(const std::strin return std::nullopt; } +std::optional Qwen3CoderToolParserImpl::finalizeOnGenerationEnd() { + if (this->currentState == State::Content || + this->currentState == State::InsideToolCall || + this->currentState == State::InsideFunctionName) { + // No usable function name was ever captured -- nothing to recover. Still clear the + // dangling partial state so it doesn't look like a call is still in flight. + resetParsingState(); + return std::nullopt; + } + if (this->currentState == State::InsideParameterName) { + // Drop the incomplete parameter name; close the function with whatever was captured before it. + this->currentState = State::InsideFunction; + } + if (this->currentState == State::InsideParameter) { + this->streamContent += Qwen3CoderToolParser::PARAMETER_END_TAG; + } + if (this->currentState == State::InsideParameter || this->currentState == State::InsideFunction) { + this->streamContent += Qwen3CoderToolParser::FUNCTION_END_TAG; + } + this->streamContent += Qwen3CoderToolParser::TOOL_END_TAG; + + ToolCalls_t toolCalls; + while (parseUntilStateChange(toolCalls)) { + } + // Generation has ended: nothing more will ever be parsed from streamContent, so leave the + // parser in the same clean state a normal completion would (toolCallPositions is kept -- + // removeToolCallsFromContentIfNeeded() still needs it afterward). + resetParsingState(); + if (!toolCalls.empty()) { + return std::move(toolCalls); + } + return std::nullopt; +} + Qwen3CoderToolParser::Qwen3CoderToolParser(ov::genai::Tokenizer& tokenizer, const ToolsSchemas_t& toolSchemas, std::optional configOverride) : BaseOutputParser(tokenizer, [&]() { @@ -262,10 +309,19 @@ std::optional Qwen3CoderToolParser::parseChunk(const std::string& newChun // if toolCalls is not returned, but we are insideFunction state, we need to return the first delta with function name once // otherwise nullopt SPDLOG_DEBUG("Chunk: '{}', finishReason: {}", newChunk, static_cast(finishReason)); - if (newChunk.empty()) { + if (newChunk.empty() && finishReason == ov::genai::GenerationFinishReason::NONE) { return std::nullopt; } - auto toolCallsOpt = this->streamParser.parseChunk(newChunk); + std::optional toolCallsOpt; + if (!newChunk.empty()) { + toolCallsOpt = this->streamParser.parseChunk(newChunk); + } + + // If no complete tool calls were returned yet and generation has ended, finalize the current + // tool call in progress to recover any remaining data (for example if arguments were not closed properly). + if (!toolCallsOpt.has_value() && finishReason != ov::genai::GenerationFinishReason::NONE) { + toolCallsOpt = this->streamParser.finalizeOnGenerationEnd(); + } if (toolCallsOpt.has_value()) { return this->sendFullDelta(toolCallsOpt.value()); } diff --git a/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.hpp b/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.hpp index 73eaf4d301..d37aa352e6 100644 --- a/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.hpp +++ b/src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.hpp @@ -86,14 +86,15 @@ C->ITC->IFN->IF->IPN->IP->AF->C * that were not returned before */ std::optional parseChunk(const std::string& chunk); + // Called once generation has stopped and parseChunk() produced nothing new: synthesizes + // the closing tags still missing for whatever tool call is in flight (using only data + // already captured) so it can be recovered instead of silently dropped. An incomplete + // name/attribute can't be recovered and returns nullopt. + std::optional finalizeOnGenerationEnd(); std::optional getCurrentFunctionName() const; Status removeToolCallsFromContentIfNeeded(std::string& outContent); void reset() { - currentState = State::Content; - currentFunction.clear(); - currentParameterName.clear(); - streamContent.clear(); - lastProcessedPosition = 0; + resetParsingState(); toolCallPositions = ToolCallPositions{}; } State getCurrentState() const { @@ -106,6 +107,15 @@ C->ITC->IFN->IF->IPN->IP->AF->C private: const ToolsParameterTypeMap_t& toolsParametersTypeMap; const bool removeNewlineAroundParameters = true; + // Resets everything except toolCallPositions, which removeToolCallsFromContentIfNeeded() + // still needs to consult after generation ends. + void resetParsingState() { + currentState = State::Content; + currentFunction.clear(); + currentParameterName.clear(); + streamContent.clear(); + lastProcessedPosition = 0; + } State currentState = State::Content; Functool currentFunction; std::string currentParameterName; diff --git a/src/test/llm/output_parsers/gemma4_output_parser_test.cpp b/src/test/llm/output_parsers/gemma4_output_parser_test.cpp index 738e7c5bf1..11974174e3 100644 --- a/src/test/llm/output_parsers/gemma4_output_parser_test.cpp +++ b/src/test/llm/output_parsers/gemma4_output_parser_test.cpp @@ -601,6 +601,23 @@ TEST_F(Gemma4OutputParserTest, StreamingWithToolResponseTokenAtTheEndOfGeneratio assertStreamingVec(chunkToDeltaVec); } +// Model omits the "" end tag entirely and jumps straight to a stray +// "<|tool_response>" before stopping. Arguments must be emitted exactly once +// (regression test: the finish-flush fallback used to blindly re-emit them). +TEST_F(Gemma4OutputParserTest, StreamingWithMissingEndTagBeforeStop) { + std::vector>> chunkToDeltaVec{ + {"<|tool_call>", ov::genai::GenerationFinishReason::NONE, std::nullopt}, + {"call:", ov::genai::GenerationFinishReason::NONE, std::nullopt}, + {"ls", ov::genai::GenerationFinishReason::NONE, std::nullopt}, + {"{a:", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"tool_calls":[{"id":"XXXXXXXXX","type":"function","index":0,"function":{"name":"ls"}}]}})"}, + {"true}", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":true}"}}]}})"}, + {"<|tool_response>", ov::genai::GenerationFinishReason::NONE, std::nullopt}, + {"", ov::genai::GenerationFinishReason::STOP, std::nullopt}, + }; + + assertStreamingVec(chunkToDeltaVec); +} + TEST_F(Gemma4OutputParserTest, StreamingContentWithTurnTokenAtTheEndOfGeneration) { std::vector>> chunkToDeltaVec{ {"This is", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"content":"This is"}})"}, diff --git a/src/test/llm/output_parsers/lfm2_output_parser_test.cpp b/src/test/llm/output_parsers/lfm2_output_parser_test.cpp index 96663ff147..984b9fff9b 100644 --- a/src/test/llm/output_parsers/lfm2_output_parser_test.cpp +++ b/src/test/llm/output_parsers/lfm2_output_parser_test.cpp @@ -512,6 +512,22 @@ TEST_F(LFM2OutputParserTest, StreamingWithToolCallAndFinishReason) { assertStreamingVec(chunkToDeltaVec); } +// Model never emits "]", a separator, or "<|tool_call_end|>" before stopping. +// Arguments must be emitted exactly once (regression test: the finish-flush +// fallback used to blindly re-emit them). +TEST_F(LFM2OutputParserTest, StreamingWithMissingEndTagBeforeStop) { + std::vector>> chunkToDeltaVec{ + {"<|tool_call_start|>", ov::genai::GenerationFinishReason::NONE, std::nullopt}, + {"[", ov::genai::GenerationFinishReason::NONE, std::nullopt}, + {"dummy", ov::genai::GenerationFinishReason::NONE, std::nullopt}, + {"(a", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"tool_calls":[{"id":"XXXXXXXXX","type":"function","index":0,"function":{"name":"dummy"}}]}})"}, + {"=true)", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":true}"}}]}})"}, + {"", ov::genai::GenerationFinishReason::STOP, std::nullopt}, + }; + + assertStreamingVec(chunkToDeltaVec); +} + TEST_F(LFM2OutputParserTest, StreamingWithToolCallAndEOSToken) { std::vector>> chunkToDeltaVec{ {"JUST_SOME_STRING_BEFORE_SPECIAL_STARTING_TAG", ov::genai::GenerationFinishReason::NONE, R"({"delta":{"content":"JUST_SOME_STRING_BEFORE_SPECIAL_STARTING_TAG"}})"}, diff --git a/src/test/llm/output_parsers/minicpm5_output_parser_test.cpp b/src/test/llm/output_parsers/minicpm5_output_parser_test.cpp index ed223b1010..0e60e02446 100644 --- a/src/test/llm/output_parsers/minicpm5_output_parser_test.cpp +++ b/src/test/llm/output_parsers/minicpm5_output_parser_test.cpp @@ -345,6 +345,39 @@ TEST_F(Minicpm5OutputParserTest, ParseContentAroundToolCall) { EXPECT_NE(parsedOutput.content, "Let me check the weather. Done."); } +// ============================================================================= +// Recovery when generation stops before the tool call's closing tags ever arrive +// (max_tokens truncation, or the model just omits them). Regression tests for +// Minicpm5ToolParserImpl::finalizeOnGenerationEnd(). +// ============================================================================= +TEST_F(Minicpm5OutputParserTest, ToolCallRecoveredWhenStoppedMidParameterValue) { + const std::string input = R"(Ber)"; + ParsedOutput parsedOutput = generateParsedOutput(input); + + ASSERT_EQ(parsedOutput.toolCalls.size(), 1u); + EXPECT_EQ(parsedOutput.toolCalls[0].name, "get_weather"); + EXPECT_EQ(parsedOutput.toolCalls[0].arguments, R"({"city":"Ber"})"); +} + +TEST_F(Minicpm5OutputParserTest, ToolCallRecoveredWithoutDanglingParameterName) { + // Stops mid parameter NAME -- that one incomplete parameter can't be recovered, but the + // function name was already known, so the call itself is still recovered without it. + const std::string input = R"(Berlin)"; @@ -529,6 +562,19 @@ TEST(Minicpm5ToolParserImplTest, UnterminatedFunctionNoCall) { EXPECT_FALSE(callsOpt.has_value() && !callsOpt.value().empty()); } +TEST(Minicpm5ToolParserImplTest, UnterminatedFunctionContentCleanedNotError) { + // Truncated mid-parameter-value, no closing tags at all: removeToolCallsFromContentIfNeeded() + // must trim the dangling fragment instead of returning INTERNAL_ERROR (begin/end mismatch). + const std::string input = + R"(Berlin)"; + auto content = input; + Minicpm5ToolParserImpl parser(minicpm5TypeMap); + parser.parseChunk(content); + auto status = parser.removeToolCallsFromContentIfNeeded(content); + EXPECT_TRUE(status.ok()) << status.string(); + EXPECT_EQ(content.find(")"; diff --git a/src/test/llm/output_parsers/onyx_output_parser_test.cpp b/src/test/llm/output_parsers/onyx_output_parser_test.cpp index d3e1ad3bfa..c9bed81fb7 100644 --- a/src/test/llm/output_parsers/onyx_output_parser_test.cpp +++ b/src/test/llm/output_parsers/onyx_output_parser_test.cpp @@ -746,6 +746,52 @@ TEST_F(OnyxOutputParserTest, UnaryTwoSequentialToolCalls) { EXPECT_EQ(parsedOutput.toolCalls[1].arguments, R"({"city":"SF"})"); } +// ============================================================================= +// Recovery when generation stops before the tool call's closing tags ever arrive +// (max_tokens truncation, or the model just omits them). Regression tests for +// OnyxToolParserImpl::finalizeOnGenerationEnd(). +// ============================================================================= +TEST_F(OnyxOutputParserTest, ToolCallRecoveredWhenStoppedMidParameterValue) { + ParsedOutput parsedOutput = generateParsedOutput( + " to=get_weather<|message|>\n\nPar"); + + ASSERT_EQ(parsedOutput.toolCalls.size(), 1); + EXPECT_EQ(parsedOutput.toolCalls[0].name, "get_weather"); + EXPECT_EQ(parsedOutput.toolCalls[0].arguments, R"({"location":"Par"})"); +} + +TEST_F(OnyxOutputParserTest, ToolCallRecoveredWhenStoppedAfterInnerCloseTag) { + // "" seen but not the outer "". + ParsedOutput parsedOutput = generateParsedOutput( + " to=get_weather<|message|>\n\n" + "Paris\n\n"); + + ASSERT_EQ(parsedOutput.toolCalls.size(), 1); + EXPECT_EQ(parsedOutput.toolCalls[0].name, "get_weather"); + EXPECT_EQ(parsedOutput.toolCalls[0].arguments, R"({"location":"Paris"})"); +} + +TEST_F(OnyxOutputParserTest, ToolCallRecoveredWithoutDanglingParameterName) { + // Stops mid parameter NAME ("loc" instead of "location") -- that one incomplete + // parameter can't be recovered, but the function name was already known, so the + // call itself is still recovered, just without that parameter. + ParsedOutput parsedOutput = generateParsedOutput( + " to=get_weather<|message|>\n\n\n" seen but not the outer "". + std::string input = "\n\nvalue1\n\n"; + auto [generatedTensor, generatedTokens, parsedOutput] = generateParsedOutput(input); + + ASSERT_EQ(parsedOutput.toolCalls.size(), 1); + EXPECT_EQ(parsedOutput.toolCalls[0].name, "string_tool"); + EXPECT_EQ(parsedOutput.toolCalls[0].arguments, R"({"arg1":"value1"})"); +} + +TEST_F(Qwen3CoderOutputParserTest, ToolCallRecoveredWithoutDanglingParameterName) { + // Stops mid parameter NAME -- that one incomplete parameter can't be recovered, but the + // function name was already known, so the call itself is still recovered without it. + std::string input = "\n\n @@ -221,6 +265,20 @@ value1 EXPECT_EQ(parser.getLastProcessedPosition(), input.find("") + std::string("").size()); EXPECT_EQ(content, "\n"); } + +TEST_F(Qwen3CoderOutputParserTest, TestJustParserImplUnterminatedToolCallContentCleanedNotError) { + // Truncated mid-parameter-value, no closing tags at all: removeToolCallsFromContentIfNeeded() + // must trim the dangling fragment instead of returning INTERNAL_ERROR (begin/end mismatch). + std::string input = "\n\nval"; + auto content = input; + ovms::Qwen3CoderToolParserImpl parser(toolsParametersTypeMap); + auto callsOpt = parser.parseChunk(content); + ASSERT_FALSE(callsOpt.has_value()); + auto status = parser.removeToolCallsFromContentIfNeeded(content); + EXPECT_TRUE(status.ok()) << status.string(); + EXPECT_EQ(content.find(""), std::string::npos) << content; +} + TEST_F(Qwen3CoderOutputParserTest, TestJustParserImplUnaryWithNoToolCall) { std::string input = R"(Unexpected void found. Philosophical crisis imminent.)"; const std::string expectedContent = input;