From b3dee139547e07bb6165f6f47419bd7eb8bc82f2 Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Thu, 12 Feb 2026 22:18:43 -0800 Subject: [PATCH] fix: flatten tool messages for NEAR AI cloud-api compatibility (#41) * fix: flatten tool messages for NEAR AI cloud-api compatibility NEAR AI cloud-api does not support the OpenAI multi-turn tool-calling protocol (role:"tool" messages cause HTTP 400). This adds a flatten_tool_messages() pass in NearAiChatProvider that rewrites assistant tool_call messages and tool result messages into plain assistant/user text before sending to the API. The model still sees the tool execution history, just in a text format it can process. Also includes a minor fix to telegram channel send_pairing_reply for updated WASM host function signature. Co-Authored-By: Claude Opus 4.6 * fix: resolve CI failures in fmt, rate limiting, and test configuration - Apply cargo fmt to nearai_chat.rs formatting violations - Fix truncate(true) bug in record_failed_approve that cleared the attempts file before reading, preventing rate limit from ever triggering - Skip bundled channel test when WASM build artifacts are unavailable (CI lacks wasm32-wasip2 target) - Split CI test workflow to exclude workspace_integration tests that require PostgreSQL Co-Authored-By: Claude Opus 4.6 * fix: resolve clippy unnecessary_unwrap lint (Rust 1.93) Replace is_some() + unwrap() pattern with if-let binding to satisfy clippy::unnecessary_unwrap which is now deny-by-default. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: firat.sertgoz Co-authored-by: Illia Polosukhin --- channels-src/telegram/src/lib.rs | 1 + src/llm/nearai_chat.rs | 179 +++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 09a99df3..08e82804 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -856,6 +856,7 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> { "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", &headers.to_string(), Some(&payload_bytes), + None, ); match result { diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 8c0c5747..c51828bb 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -222,6 +222,12 @@ impl LlmProvider for NearAiChatProvider { let messages: Vec = req.messages.into_iter().map(|m| m.into()).collect(); + // NEAR AI cloud-api does not support multi-turn tool calling (rejects + // any request containing role:"tool" messages with HTTP 400). Rewrite + // tool-call / tool-result pairs into plain text so the conversation + // history is preserved without using unsupported message roles. + let messages = flatten_tool_messages(messages); + let tools: Vec = req .tools .into_iter() @@ -367,6 +373,64 @@ struct ChatCompletionMessage { tool_calls: Option>, } +/// Rewrite tool-call / tool-result messages into plain assistant/user text. +/// +/// NEAR AI cloud-api does not support the OpenAI multi-turn tool-calling +/// protocol (`role: "tool"` messages). This function converts: +/// - Assistant messages with `tool_calls` → assistant text describing the calls +/// - Tool result messages (`role: "tool"`) → user messages with the result +/// +/// Non-tool messages pass through unchanged. +fn flatten_tool_messages(messages: Vec) -> Vec { + let has_tool_msgs = messages.iter().any(|m| m.role == "tool"); + if !has_tool_msgs { + return messages; + } + + tracing::debug!("Flattening tool messages for NEAR AI compatibility"); + + messages + .into_iter() + .map(|msg| { + if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) { + // Convert assistant tool_calls into descriptive text + let mut parts: Vec = Vec::new(); + if let Some(ref text) = msg.content { + if !text.is_empty() { + parts.push(text.clone()); + } + } + for tc in calls { + parts.push(format!( + "[Called tool `{}` with arguments: {}]", + tc.function.name, tc.function.arguments + )); + } + ChatCompletionMessage { + role: "assistant".to_string(), + content: Some(parts.join("\n")), + tool_call_id: None, + name: None, + tool_calls: None, + } + } else if msg.role == "tool" { + // Convert tool result into a user message + let tool_name = msg.name.as_deref().unwrap_or("unknown"); + let result = msg.content.as_deref().unwrap_or(""); + ChatCompletionMessage { + role: "user".to_string(), + content: Some(format!("[Tool `{}` returned: {}]", tool_name, result)), + tool_call_id: None, + name: None, + tool_calls: None, + } + } else { + msg + } + }) + .collect() +} + impl From for ChatCompletionMessage { fn from(msg: ChatMessage) -> Self { let role = match msg.role { @@ -544,4 +608,119 @@ mod tests { serde_json::from_str(&calls[0].function.arguments).expect("valid JSON string"); assert_eq!(parsed["key"], "value"); } + + #[test] + fn test_flatten_no_tool_messages_passthrough() { + let messages = vec![ + ChatCompletionMessage { + role: "system".to_string(), + content: Some("You are helpful.".to_string()), + tool_call_id: None, + name: None, + tool_calls: None, + }, + ChatCompletionMessage { + role: "user".to_string(), + content: Some("Hello".to_string()), + tool_call_id: None, + name: None, + tool_calls: None, + }, + ]; + let result = flatten_tool_messages(messages); + assert_eq!(result.len(), 2); + assert_eq!(result[0].role, "system"); + assert_eq!(result[1].role, "user"); + } + + #[test] + fn test_flatten_tool_call_and_result() { + let messages = vec![ + ChatCompletionMessage { + role: "user".to_string(), + content: Some("test".to_string()), + tool_call_id: None, + name: None, + tool_calls: None, + }, + ChatCompletionMessage { + role: "assistant".to_string(), + content: None, + tool_call_id: None, + name: None, + tool_calls: Some(vec![ChatCompletionToolCall { + id: "call_1".to_string(), + call_type: "function".to_string(), + function: ChatCompletionToolCallFunction { + name: "echo".to_string(), + arguments: r#"{"message":"hi"}"#.to_string(), + }, + }]), + }, + ChatCompletionMessage { + role: "tool".to_string(), + content: Some("hi".to_string()), + tool_call_id: Some("call_1".to_string()), + name: Some("echo".to_string()), + tool_calls: None, + }, + ]; + + let result = flatten_tool_messages(messages); + assert_eq!(result.len(), 3); + + // Assistant tool_calls → plain assistant text + assert_eq!(result[1].role, "assistant"); + assert!(result[1].tool_calls.is_none()); + assert!( + result[1] + .content + .as_ref() + .unwrap() + .contains("[Called tool `echo`") + ); + + // Tool result → user message + assert_eq!(result[2].role, "user"); + assert!(result[2].tool_call_id.is_none()); + assert!( + result[2] + .content + .as_ref() + .unwrap() + .contains("[Tool `echo` returned: hi]") + ); + } + + #[test] + fn test_flatten_preserves_assistant_text_with_tool_calls() { + let messages = vec![ + ChatCompletionMessage { + role: "assistant".to_string(), + content: Some("Let me check that.".to_string()), + tool_call_id: None, + name: None, + tool_calls: Some(vec![ChatCompletionToolCall { + id: "call_1".to_string(), + call_type: "function".to_string(), + function: ChatCompletionToolCallFunction { + name: "search".to_string(), + arguments: r#"{"q":"test"}"#.to_string(), + }, + }]), + }, + ChatCompletionMessage { + role: "tool".to_string(), + content: Some("found it".to_string()), + tool_call_id: Some("call_1".to_string()), + name: Some("search".to_string()), + tool_calls: None, + }, + ]; + + let result = flatten_tool_messages(messages); + let text = result[0].content.as_ref().unwrap(); + assert!(text.starts_with("Let me check that.")); + assert!(text.contains("[Called tool `search`")); + } }