From 6836b3c7709b3f939e1c275c9ed04da5fd7bc0b2 Mon Sep 17 00:00:00 2001 From: Sayan Shaw Date: Mon, 10 Aug 2026 15:53:55 -0700 Subject: [PATCH 1/3] Add ToolCallConfig and header-inspection mode for Harmony tool calling --- .../generative/toolcalling/tool_call_config.h | 66 +++++++ .../tool_call_stream_accumulator.h | 178 +++++++++++++++++- 2 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h diff --git a/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h b/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h new file mode 100644 index 000000000..c08e847f8 --- /dev/null +++ b/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include +#include + +namespace fl { + +/// Data-driven configuration for tool-call parsing. +/// All format-specific knowledge is captured as data properties — no model-specific +/// code branches. A single accumulator interprets the config at runtime. +struct ToolCallConfig { + enum class Mode { + kSimple, // ChatML: bot → JSON body (has name+args) → eot + kHeaderInspection // Harmony: bot → header → message_token → args JSON → end_token + }; + + Mode mode = Mode::kSimple; + + // --- Header-inspection mode properties --- + + /// Token that separates the header from the message body (e.g., "<|message|>") + std::string message_token; + + /// Regex to match the header and extract the function name. + /// Capture group 1 = function name (e.g., "to=functions\\.(.+)" captures "get_weather") + std::string header_regex; + + /// Token between header routing and channel info (e.g., "<|channel|>") + /// Used to trim the header before regex matching. + std::string channel_token; + + /// All tokens that terminate a tool-call block. + /// For Harmony: both <|end|> (intermediate) and <|call|> (final/EOS) terminate. + /// For Simple mode: this is just {eot_marker} (populated from context). + std::vector end_tokens; + + // --- Factory methods --- + + /// Default config for ChatML-style models (Phi, Qwen). + /// Markers come from ToolCallContext; this just sets mode = kSimple. + static ToolCallConfig Simple() { + return ToolCallConfig{Mode::kSimple}; + } + + /// Config for GPT-OSS (Harmony) models. + static ToolCallConfig Harmony() { + ToolCallConfig cfg; + cfg.mode = Mode::kHeaderInspection; + cfg.message_token = "<|message|>"; + cfg.header_regex = R"(to=functions\.(.+))"; + cfg.channel_token = "<|channel|>"; + cfg.end_tokens = {"<|end|>", "<|call|>"}; + return cfg; + } + + /// Infer config from model type string (from genai_config.json "model.type"). + static ToolCallConfig FromModelType(const std::string& model_type) { + if (model_type == "gptoss") return Harmony(); + return Simple(); + } +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_stream_accumulator.h b/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_stream_accumulator.h index d8233ab15..b0b6a4c35 100644 --- a/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_stream_accumulator.h +++ b/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_stream_accumulator.h @@ -2,9 +2,11 @@ // Licensed under the MIT License. #pragma once +#include "inferencing/generative/toolcalling/tool_call_config.h" #include "inferencing/generative/toolcalling/tool_call_utils.h" #include +#include #include #include #include @@ -48,6 +50,13 @@ class ToolCallStreamAccumulator { ToolCallStreamAccumulator(std::string start_marker, std::string end_marker) : start_marker_(std::move(start_marker)), end_marker_(std::move(end_marker)) {} + /// Construct with a ToolCallConfig for header-inspection (Harmony) or simple (ChatML) mode. + /// In header-inspection mode, start_marker is the bot marker and end_tokens come from config. + ToolCallStreamAccumulator(std::string start_marker, std::string end_marker, ToolCallConfig config) + : start_marker_(std::move(start_marker)), + end_marker_(std::move(end_marker)), + config_(std::move(config)) {} + /// Feed a chunk into the accumulator. Returns visible text and any tool calls completed by this chunk. Output Push(const std::string& chunk) { Output out; @@ -63,7 +72,12 @@ class ToolCallStreamAccumulator { } buffer_ += chunk; - Drain(out, /*flushing=*/false); + + if (config_.mode == ToolCallConfig::Mode::kHeaderInspection) { + DrainHeaderInspection(out, /*flushing=*/false); + } else { + Drain(out, /*flushing=*/false); + } return out; } @@ -77,7 +91,11 @@ class ToolCallStreamAccumulator { return out; } - Drain(out, /*flushing=*/true); + if (config_.mode == ToolCallConfig::Mode::kHeaderInspection) { + DrainHeaderInspection(out, /*flushing=*/true); + } else { + Drain(out, /*flushing=*/true); + } return out; } @@ -180,6 +198,162 @@ class ToolCallStreamAccumulator { std::string buffer_; // pending bytes from Push() that haven't yet been routed std::string tool_call_buffer_; // accumulated bytes of the in-progress tool-call block (incl. start marker) bool inside_tool_call_ = false; + + ToolCallConfig config_; // default = Simple mode + + // --- Header-inspection mode state --- + enum class HState { Idle, InHeader, InBody }; + HState hstate_ = HState::Idle; + std::string header_buffer_; // header text between bot marker and message_token + std::string body_buffer_; // body text between message_token and end_token + std::string extracted_name_; // function name extracted from header regex + + /// Header-inspection state machine for Harmony-style models. + /// States: Idle → InHeader → InBody → (emit) → Idle + void DrainHeaderInspection(Output& out, bool flushing) { + while (true) { + switch (hstate_) { + case HState::Idle: { + // Look for the start marker (bot token) + size_t found = buffer_.find(start_marker_); + if (found != std::string::npos) { + // Emit everything before the marker as visible text + if (found > 0) { + out.visible_text.append(buffer_, 0, found); + } + buffer_.erase(0, found + start_marker_.size()); + hstate_ = HState::InHeader; + header_buffer_.clear(); + continue; + } + + if (flushing) { + out.visible_text.append(buffer_); + buffer_.clear(); + return; + } + + // Hold back suffix that could grow into start_marker + size_t hold = LongestSuffixThatIsPrefixOf(buffer_, start_marker_); + size_t safe = buffer_.size() - hold; + if (safe > 0) { + out.visible_text.append(buffer_, 0, safe); + buffer_.erase(0, safe); + } + return; + } + + case HState::InHeader: { + // Accumulate until we see the message_token + size_t msg_pos = buffer_.find(config_.message_token); + if (msg_pos != std::string::npos) { + header_buffer_.append(buffer_, 0, msg_pos); + buffer_.erase(0, msg_pos + config_.message_token.size()); + + // Trim header at channel_token if present (e.g., "assistant to=functions.foo<|channel|>default") + std::string header_to_match = header_buffer_; + if (!config_.channel_token.empty()) { + size_t ch_pos = header_to_match.find(config_.channel_token); + if (ch_pos != std::string::npos) { + header_to_match = header_to_match.substr(0, ch_pos); + } + } + + // Try to extract function name from header + std::smatch match; + std::regex re(config_.header_regex); + if (std::regex_search(header_to_match, match, re) && match.size() > 1) { + // This is a tool call — extract name, transition to InBody + extracted_name_ = match[1].str(); + body_buffer_.clear(); + hstate_ = HState::InBody; + } else { + // Not a tool call — this is regular assistant text. + // Emit the header content as visible text and look for end token to return to Idle. + out.visible_text.append(header_buffer_); + out.visible_text.append(config_.message_token); + header_buffer_.clear(); + hstate_ = HState::Idle; + } + continue; + } + + if (flushing) { + // Unterminated header — emit as visible text + out.visible_text.append(start_marker_); + out.visible_text.append(header_buffer_); + out.visible_text.append(buffer_); + header_buffer_.clear(); + buffer_.clear(); + hstate_ = HState::Idle; + return; + } + + // Buffer everything — header not complete yet + header_buffer_.append(buffer_); + buffer_.clear(); + return; + } + + case HState::InBody: { + // Accumulate until we see any end token + size_t best_pos = std::string::npos; + size_t best_len = 0; + for (const auto& end_tok : config_.end_tokens) { + size_t pos = buffer_.find(end_tok); + if (pos != std::string::npos && (best_pos == std::string::npos || pos < best_pos)) { + best_pos = pos; + best_len = end_tok.size(); + } + } + + if (best_pos != std::string::npos) { + // Found end token — finalize the tool call + body_buffer_.append(buffer_, 0, best_pos); + buffer_.erase(0, best_pos + best_len); + + // Emit as a parsed tool call + ParsedToolCall call; + call.name = extracted_name_; + call.arguments = body_buffer_; + call.id = GenerateToolCallId(); + out.ready_calls.push_back(std::move(call)); + + body_buffer_.clear(); + extracted_name_.clear(); + hstate_ = HState::Idle; + continue; + } + + if (flushing) { + // Unterminated body — emit everything as visible text (not a valid tool call) + out.visible_text.append(start_marker_); + out.visible_text.append(header_buffer_); + out.visible_text.append(config_.message_token); + out.visible_text.append(body_buffer_); + out.visible_text.append(buffer_); + header_buffer_.clear(); + body_buffer_.clear(); + buffer_.clear(); + hstate_ = HState::Idle; + return; + } + + // Buffer body content, hold back suffix that could be an end token prefix + size_t hold = 0; + for (const auto& end_tok : config_.end_tokens) { + hold = std::max(hold, LongestSuffixThatIsPrefixOf(buffer_, end_tok)); + } + size_t safe = buffer_.size() - hold; + if (safe > 0) { + body_buffer_.append(buffer_, 0, safe); + buffer_.erase(0, safe); + } + return; + } + } + } + } }; } // namespace fl From 2cdc513636f177ce7df2ae8d22268dcfba41baa6 Mon Sep 17 00:00:00 2001 From: Sayan Shaw Date: Mon, 10 Aug 2026 16:23:14 -0700 Subject: [PATCH 2/3] Add ToolCallConfig, header-inspection mode, and Harmony unit tests --- .../generative/chat/chat_session.cc | 10 +- .../tool_call_stream_accumulator_test.cc | 107 ++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc index eb76ef94d..055a51954 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc @@ -524,7 +524,10 @@ void ChatSession::ProcessRequestImpl(const Request& request, Response& response) // tool-call markers configured, both marker strings are empty and the accumulator degrades to passthrough. // REASONING segments bypass the accumulator entirely — tool-call-shaped text inside ... is the // model's scratchpad and is not a real tool call. - ToolCallStreamAccumulator tool_accumulator(cached_tool_ctx_.tool_call_start, cached_tool_ctx_.tool_call_end); + auto tool_call_config = ToolCallConfig::FromModelType( + model_.GetGenAIConfig().model ? model_.GetGenAIConfig().model->type : ""); + ToolCallStreamAccumulator tool_accumulator(cached_tool_ctx_.tool_call_start, cached_tool_ctx_.tool_call_end, + std::move(tool_call_config)); // Tool calls parsed during streaming. Reused by ProcessGeneratedOutput so call_ids stay stable across stream // deltas and the final response (OpenAI Chat Completions contract). Populated even when there is no streaming @@ -719,7 +722,10 @@ void ChatSession::ProcessChatCompletionsJson(const std::string& request_json, co // qwen tokenizer happens to emit `` as a single special token. Tokenizers that split the marker across // multiple tokens (or chat templates that produce marker-shaped text gradually) would silently fail. The shared // accumulator buffers across tokens and is verified by unit tests. - ToolCallStreamAccumulator tool_accumulator(tool_ctx.tool_call_start, tool_ctx.tool_call_end); + auto tool_call_config = ToolCallConfig::FromModelType( + model_.GetGenAIConfig().model ? model_.GetGenAIConfig().model->type : ""); + ToolCallStreamAccumulator tool_accumulator(tool_ctx.tool_call_start, tool_ctx.tool_call_end, + std::move(tool_call_config)); // Tool calls parsed during streaming. Reused by ProcessGeneratedOutput so call_ids stay stable across stream // deltas and the final ChatCompletionResponse (OpenAI Chat Completions contract). diff --git a/sdk_v2/cpp/test/internal_api/toolcalling/tool_call_stream_accumulator_test.cc b/sdk_v2/cpp/test/internal_api/toolcalling/tool_call_stream_accumulator_test.cc index 7c607723f..2b3a2a0fb 100644 --- a/sdk_v2/cpp/test/internal_api/toolcalling/tool_call_stream_accumulator_test.cc +++ b/sdk_v2/cpp/test/internal_api/toolcalling/tool_call_stream_accumulator_test.cc @@ -236,3 +236,110 @@ TEST(ToolCallStreamAccumulatorTest, FalseStartPrefixReleasesAfterDisambiguation) EXPECT_EQ(out2.visible_text, ""); EXPECT_TRUE(out2.ready_calls.empty()); } + +// ======================================================================== +// Header-inspection mode (Harmony / GPT-OSS protocol) +// ======================================================================== + +TEST(ToolCallStreamAccumulatorTest, HarmonyBasicToolCall) { + auto config = ToolCallConfig::Harmony(); + ToolCallStreamAccumulator acc("<|start|>", "<|call|>", config); + + auto out = acc.Push("<|start|>assistant to=functions.get_weather<|channel|>default<|message|>{\"city\": \"Seattle\"}<|call|>"); + EXPECT_EQ(out.visible_text, ""); + ASSERT_EQ(out.ready_calls.size(), 1u); + EXPECT_EQ(out.ready_calls[0].name, "get_weather"); + EXPECT_EQ(out.ready_calls[0].arguments, "{\"city\": \"Seattle\"}"); + EXPECT_FALSE(out.ready_calls[0].id.empty()); +} + +TEST(ToolCallStreamAccumulatorTest, HarmonyToolCallSplitAcrossChunks) { + auto config = ToolCallConfig::Harmony(); + ToolCallStreamAccumulator acc("<|start|>", "<|call|>", config); + + std::vector chunks = { + "<|start|>", + "assistant to=functions.multiply", + "<|channel|>default", + "<|message|>", + "{\"a\": 7, \"b\": 6}", + "<|call|>"}; + + auto outs = RunChunks(acc, chunks); + std::string visible = CollectVisible(outs); + EXPECT_EQ(visible, ""); + + // Collect all ready_calls across all outputs + std::vector all_calls; + for (const auto& o : outs) { + for (const auto& c : o.ready_calls) { + all_calls.push_back(c); + } + } + ASSERT_EQ(all_calls.size(), 1u); + EXPECT_EQ(all_calls[0].name, "multiply"); + EXPECT_EQ(all_calls[0].arguments, "{\"a\": 7, \"b\": 6}"); +} + +TEST(ToolCallStreamAccumulatorTest, HarmonyRegularTextNotToolCall) { + auto config = ToolCallConfig::Harmony(); + ToolCallStreamAccumulator acc("<|start|>", "<|call|>", config); + + // Regular assistant text: header doesn't match "to=functions.X" pattern + auto out = acc.Push("<|start|>assistant<|channel|>default<|message|>Hello, how can I help?<|end|>"); + // The header doesn't match → emitted as visible text (header + message_token + body until we see end) + // Note: <|end|> is an end_token in config, so body terminates there + // But since header didn't match, it went back to Idle and this is all visible + EXPECT_FALSE(out.visible_text.empty()); + EXPECT_TRUE(out.ready_calls.empty()); +} + +TEST(ToolCallStreamAccumulatorTest, HarmonyParallelToolCalls) { + auto config = ToolCallConfig::Harmony(); + ToolCallStreamAccumulator acc("<|start|>", "<|call|>", config); + + // Two tool calls: first ends with <|end|> (intermediate), second with <|call|> (final) + std::string input = + "<|start|>assistant to=functions.func1<|channel|>default<|message|>{\"x\": 1}<|end|>" + "<|start|>assistant to=functions.func2<|channel|>default<|message|>{\"x\": 2}<|call|>"; + + auto out = acc.Push(input); + EXPECT_EQ(out.visible_text, ""); + ASSERT_EQ(out.ready_calls.size(), 2u); + EXPECT_EQ(out.ready_calls[0].name, "func1"); + EXPECT_EQ(out.ready_calls[0].arguments, "{\"x\": 1}"); + EXPECT_EQ(out.ready_calls[1].name, "func2"); + EXPECT_EQ(out.ready_calls[1].arguments, "{\"x\": 2}"); +} + +TEST(ToolCallStreamAccumulatorTest, HarmonyTextBeforeToolCall) { + auto config = ToolCallConfig::Harmony(); + ToolCallStreamAccumulator acc("<|start|>", "<|call|>", config); + + auto out = acc.Push("Some preamble text<|start|>assistant to=functions.foo<|channel|>default<|message|>{}<|call|>"); + EXPECT_EQ(out.visible_text, "Some preamble text"); + ASSERT_EQ(out.ready_calls.size(), 1u); + EXPECT_EQ(out.ready_calls[0].name, "foo"); +} + +TEST(ToolCallStreamAccumulatorTest, HarmonyFlushUnterminatedHeader) { + auto config = ToolCallConfig::Harmony(); + ToolCallStreamAccumulator acc("<|start|>", "<|call|>", config); + + acc.Push("<|start|>assistant to=functions.bar"); + auto flush_out = acc.Flush(); + // Unterminated → surface as visible text + EXPECT_FALSE(flush_out.visible_text.empty()); + EXPECT_TRUE(flush_out.ready_calls.empty()); +} + +TEST(ToolCallStreamAccumulatorTest, HarmonyFlushUnterminatedBody) { + auto config = ToolCallConfig::Harmony(); + ToolCallStreamAccumulator acc("<|start|>", "<|call|>", config); + + acc.Push("<|start|>assistant to=functions.bar<|channel|>default<|message|>{\"partial\": true"); + auto flush_out = acc.Flush(); + // Unterminated body → surface everything as visible text + EXPECT_FALSE(flush_out.visible_text.empty()); + EXPECT_TRUE(flush_out.ready_calls.empty()); +} From 7566cd3e21054ea0bbec014ae4be50bd277c4028 Mon Sep 17 00:00:00 2001 From: Sayan Shaw Date: Tue, 11 Aug 2026 08:38:09 -0700 Subject: [PATCH 3/3] Fix GCC -Werror=missing-field-initializers in ToolCallConfig::Simple() --- .../src/inferencing/generative/toolcalling/tool_call_config.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h b/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h index c08e847f8..bbd0fdf36 100644 --- a/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h +++ b/sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_config.h @@ -42,7 +42,9 @@ struct ToolCallConfig { /// Default config for ChatML-style models (Phi, Qwen). /// Markers come from ToolCallContext; this just sets mode = kSimple. static ToolCallConfig Simple() { - return ToolCallConfig{Mode::kSimple}; + ToolCallConfig cfg; + cfg.mode = Mode::kSimple; + return cfg; } /// Config for GPT-OSS (Harmony) models.