diff --git a/src/llm/BUILD b/src/llm/BUILD index b3525b17ad..a896b009b1 100644 --- a/src/llm/BUILD +++ b/src/llm/BUILD @@ -156,6 +156,7 @@ ovms_cc_library( "io_processing/input_processors/chat_template_adapter.hpp", "io_processing/chat_template/caps.hpp", "io_processing/input_processors/empty_content_array_normalization_processor.hpp", + "io_processing/input_processors/empty_tool_calls_array_removing_processor.hpp", "io_processing/input_processors/raw_prompt_extractor.hpp", "io_processing/input_processors/text_content_normalization_processor.hpp", "io_processing/input_processors/tokenization_processor.hpp"], @@ -163,6 +164,7 @@ ovms_cc_library( "io_processing/input_processors/chat_template_processor.cpp", "io_processing/input_processors/chat_template_adapter.cpp", "io_processing/input_processors/empty_content_array_normalization_processor.cpp", + "io_processing/input_processors/empty_tool_calls_array_removing_processor.cpp", "io_processing/input_processors/text_content_normalization_processor.cpp", "io_processing/input_processors/tokenization_processor.cpp"], deps = [ diff --git a/src/llm/io_processing/input_processor.cpp b/src/llm/io_processing/input_processor.cpp index 504fdb51b0..80f775cd1a 100644 --- a/src/llm/io_processing/input_processor.cpp +++ b/src/llm/io_processing/input_processor.cpp @@ -24,6 +24,7 @@ #include "../../logging.hpp" #include "input_processors/chat_template_processor.hpp" #include "input_processors/empty_content_array_normalization_processor.hpp" +#include "input_processors/empty_tool_calls_array_removing_processor.hpp" #include "input_processors/image_decoding_processor.hpp" #include "input_processors/chat_template_adapter.hpp" #include "input_processors/raw_prompt_extractor.hpp" @@ -41,6 +42,7 @@ InputProcessor::InputProcessor(InputProcessorContext& context, if (isChatPath) { // Normalize empty content arrays to null before any content-aware processor runs. processors.emplace_back(std::make_unique()); + processors.emplace_back(std::make_unique()); // Flatten text-only content arrays for both LM and VLM. Arrays that contain // images (or other modalities) are left untouched for ImageDecodingProcessor. diff --git a/src/llm/io_processing/input_processors/empty_tool_calls_array_removing_processor.cpp b/src/llm/io_processing/input_processors/empty_tool_calls_array_removing_processor.cpp new file mode 100644 index 0000000000..d13462ca4e --- /dev/null +++ b/src/llm/io_processing/input_processors/empty_tool_calls_array_removing_processor.cpp @@ -0,0 +1,42 @@ +//***************************************************************************** +// Copyright 2026 Intel Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//***************************************************************************** + +#include "empty_tool_calls_array_removing_processor.hpp" + +#include + +namespace ovms { + +absl::Status EmptyToolCallsArrayRemovingProcessor::process(InputRequest& req) { + if (!std::holds_alternative(req.input)) { + return absl::Status(absl::StatusCode::kInternal, + "EmptyToolCallsArrayRemovingProcessor received input that is not a ChatHistory"); + } + ov::genai::ChatHistory& chatHistory = std::get(req.input); + for (size_t i = 0; i < chatHistory.size(); i++) { + if (!chatHistory[i].contains("tool_calls")) { + continue; + } + + const auto toolCalls = chatHistory[i]["tool_calls"]; + if (toolCalls.is_array() && toolCalls.size() == 0) { + chatHistory[i].erase("tool_calls"); + } + } + return absl::OkStatus(); +} + +} // namespace ovms diff --git a/src/llm/io_processing/input_processors/empty_tool_calls_array_removing_processor.hpp b/src/llm/io_processing/input_processors/empty_tool_calls_array_removing_processor.hpp new file mode 100644 index 0000000000..96ca8dae52 --- /dev/null +++ b/src/llm/io_processing/input_processors/empty_tool_calls_array_removing_processor.hpp @@ -0,0 +1,30 @@ +//***************************************************************************** +// Copyright 2026 Intel Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//***************************************************************************** +#pragma once + +#include "../base_input_processor.hpp" + +namespace ovms { + +// Removes empty tool_calls arrays ("tool_calls": []) from ChatHistory messages. +// Runs for all chat paths (LM and VLM) and must execute before ChatTemplateProcessor +// so downstream processors and chat templates do not render an empty tool_calls list. +class EmptyToolCallsArrayRemovingProcessor : public BaseInputProcessor { +public: + absl::Status process(InputRequest& req) override; +}; + +} // namespace ovms diff --git a/src/test/llm/input_processing/empty_tool_calls_array_removing_processor_test.cpp b/src/test/llm/input_processing/empty_tool_calls_array_removing_processor_test.cpp new file mode 100644 index 0000000000..5b06f9fd8c --- /dev/null +++ b/src/test/llm/input_processing/empty_tool_calls_array_removing_processor_test.cpp @@ -0,0 +1,73 @@ +//***************************************************************************** +// Copyright 2026 Intel Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//***************************************************************************** +#include + +#include +#include + +#include "../../../llm/io_processing/input_processors/empty_tool_calls_array_removing_processor.hpp" +#include "../../../llm/io_processing/input_request.hpp" + +using namespace ovms; + +// Helpers ---------------------------------------------------------------- + +static InputRequest makeChatRequest(ov::genai::ChatHistory chatHistory) { + InputRequest req; + req.input = std::move(chatHistory); + return req; +} + +// Tests ------------------------------------------------------------------ + +TEST(EmptyToolCallsArrayRemovingProcessorTest, EmptyToolCallsArrayRemoved) { + ov::genai::ChatHistory history; + ov::AnyMap msg = {{"role", std::string("user")}}; + msg["content"] = ov::genai::JsonContainer::from_json_string("\"What is the weather in Szczecin?\""); + msg["tool_calls"] = ov::genai::JsonContainer::from_json_string("[]"); + history.push_back(msg); + + InputRequest req = makeChatRequest(history); + EmptyToolCallsArrayRemovingProcessor processor; + const auto status = processor.process(req); + + EXPECT_TRUE(status.ok()); + const auto& result = std::get(req.input); + EXPECT_FALSE(result[0].contains("tool_calls")); + EXPECT_TRUE(result[0]["content"].is_string()); + EXPECT_EQ(result[0]["content"].as_string().value_or(""), "What is the weather in Szczecin?"); +} + +TEST(EmptyToolCallsArrayRemovingProcessorTest, NonEmptyArrayPreserved) { + ov::genai::ChatHistory history; + ov::AnyMap msg = {{"role", std::string("assistant")}}; + msg["tool_calls"] = ov::genai::JsonContainer::from_json_string( + R"([{"name":"get_weather","parameters":{"city":"Szczecin"}}])"); + + history.push_back(msg); + + InputRequest req = makeChatRequest(history); + EmptyToolCallsArrayRemovingProcessor processor; + const auto status = processor.process(req); + + EXPECT_TRUE(status.ok()); + const auto& result = std::get(req.input); + ASSERT_TRUE(result[0]["tool_calls"].is_array()); + EXPECT_EQ(result[0]["tool_calls"].size(), 1u); + EXPECT_EQ(result[0]["tool_calls"][0]["name"].as_string().value_or(""), "get_weather"); + ASSERT_TRUE(result[0]["tool_calls"][0].contains("parameters")); + EXPECT_EQ(result[0]["tool_calls"][0]["parameters"]["city"].as_string().value_or(""), "Szczecin"); +}