From 4f08b994c7789a1afe6476eb3bbd9962c847cea8 Mon Sep 17 00:00:00 2001 From: Nikketryhard Date: Sun, 15 Feb 2026 00:42:43 -0600 Subject: [PATCH] fix: include tool results in conversation context When OpenCode sends follow-up messages with tool results, include the full conversation (user message, assistant tool calls, and tool results) in the text sent to the model. Previously only the user message was extracted, causing the model to never see tool results and call the same tool repeatedly in an infinite loop. Also add tool_calls and tool_call_id fields to CompletionMessage. --- src/api/completions.rs | 100 ++++++++++++++++++++++++++++++++++++----- src/api/types.rs | 5 +++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/api/completions.rs b/src/api/completions.rs index 0e09b47..c2924bf 100644 --- a/src/api/completions.rs +++ b/src/api/completions.rs @@ -18,20 +18,26 @@ use super::AppState; // ─── Input extraction ──────────────────────────────────────────────────────── /// Extract user text from Chat Completions messages array. +/// +/// When tool results are present, builds the full conversation including +/// tool call results so the model can continue after tool use. fn extract_chat_input(messages: &[CompletionMessage]) -> String { + let has_tool_results = messages.iter().any(|m| m.role == "tool"); + + if has_tool_results { + // Build full conversation context including tool results + return build_conversation_with_tools(messages); + } + + // Simple path: no tools, just extract system + last user message let mut system_parts = Vec::new(); let mut user_parts = Vec::new(); for msg in messages { - let text = match &msg.content { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Array(arr) => arr - .iter() - .filter_map(|item| item["text"].as_str()) - .collect::>() - .join("\n"), - _ => continue, - }; + let text = extract_message_text(&msg.content); + if text.is_empty() { + continue; + } match msg.role.as_str() { "system" | "developer" => system_parts.push(text), "user" => user_parts.push(text), @@ -44,13 +50,87 @@ fn extract_chat_input(messages: &[CompletionMessage]) -> String { result.push_str(&system_parts.join("\n")); result.push_str("\n\n"); } - // Use the last user message if let Some(last) = user_parts.last() { result.push_str(last); } result.trim().to_string() } +/// Extract text content from a message's content field (string or array). +fn extract_message_text(content: &serde_json::Value) -> String { + match content { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|item| item["text"].as_str()) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// Build conversation text that includes tool call results. +/// +/// Format: +/// [system prompt] +/// [user message] +/// [assistant called tool X with args Y] +/// [tool result: Z] +/// [user followup if any] +fn build_conversation_with_tools(messages: &[CompletionMessage]) -> String { + let mut parts = Vec::new(); + + for msg in messages { + match msg.role.as_str() { + "system" | "developer" => { + let text = extract_message_text(&msg.content); + if !text.is_empty() { + parts.push(text); + } + } + "user" => { + let text = extract_message_text(&msg.content); + if !text.is_empty() { + parts.push(text); + } + } + "assistant" => { + // Include assistant text if any + let text = extract_message_text(&msg.content); + if !text.is_empty() { + parts.push(text); + } + // Include tool calls as context + if let Some(ref tool_calls) = msg.tool_calls { + for tc in tool_calls { + if let Some(func) = tc.get("function") { + let name = func["name"].as_str().unwrap_or("unknown"); + let args = func["arguments"].as_str().unwrap_or("{}"); + parts.push(format!( + "[Tool call: {}({})]", + name, args + )); + } + } + } + } + "tool" => { + let text = extract_message_text(&msg.content); + let tool_id = msg.tool_call_id.as_deref().unwrap_or("unknown"); + if !text.is_empty() { + parts.push(format!( + "[Tool result ({})]:\n{}", + tool_id, text + )); + } + } + _ => {} + } + } + + parts.join("\n\n") +} + // ─── Handler ───────────────────────────────────────────────────────────────── /// POST /v1/chat/completions — OpenAI Chat Completions API compatibility shim. diff --git a/src/api/types.rs b/src/api/types.rs index 87c429f..8bdb016 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -58,7 +58,12 @@ pub(crate) struct CompletionRequest { #[derive(Deserialize)] pub(crate) struct CompletionMessage { pub role: String, + #[serde(default)] pub content: serde_json::Value, + /// Tool calls made by the assistant (for assistant messages) + pub tool_calls: Option>, + /// Tool call ID this message is responding to (for tool messages) + pub tool_call_id: Option, } fn default_timeout() -> u64 {