diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 13304e911d..5f1a7fcf63 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -154,6 +154,8 @@ impl> ForgeAp .on_toolcall_end(tracing_handler.clone()) .on_end(tracing_handler.and(title_handler)); + let tool_search = forge_config.tool_search; + let orch = Orchestrator::new( services.clone(), conversation, @@ -163,7 +165,8 @@ impl> ForgeAp .error_tracker(ToolErrorTracker::new(max_tool_failure_per_turn)) .tool_definitions(tool_definitions) .models(models) - .hook(Arc::new(hook)); + .hook(Arc::new(hook)) + .tool_search(tool_search); // Create and return the stream let stream = MpscStream::spawn( diff --git a/crates/forge_app/src/dto/anthropic/request.rs b/crates/forge_app/src/dto/anthropic/request.rs index f6c34c6f7c..81ed2ab964 100644 --- a/crates/forge_app/src/dto/anthropic/request.rs +++ b/crates/forge_app/src/dto/anthropic/request.rs @@ -280,6 +280,10 @@ impl TryFrom for Message { ContextMessage::Tool(tool_result) => { Message { role: Role::User, content: vec![tool_result.try_into()?] } } + ContextMessage::ToolSearchOutput(_) => { + // Tool search output is OpenAI Responses API specific - skip for Anthropic + Message { role: Role::User, content: vec![] } + } ContextMessage::Image(img) => { Message { content: vec![Content::from(img)], role: Role::User } } diff --git a/crates/forge_app/src/dto/anthropic/response.rs b/crates/forge_app/src/dto/anthropic/response.rs index ba32540fe7..6e37b14899 100644 --- a/crates/forge_app/src/dto/anthropic/response.rs +++ b/crates/forge_app/src/dto/anthropic/response.rs @@ -394,6 +394,7 @@ impl TryFrom for ChatCompletionMessage { serde_json::to_string(&input)? }, thought_signature: None, + namespace: None, }) } ContentBlock::InputJsonDelta { partial_json } => { @@ -402,6 +403,7 @@ impl TryFrom for ChatCompletionMessage { name: None, arguments_part: partial_json, thought_signature: None, + namespace: None, }) } }; diff --git a/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs b/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs index 4b771b547d..0fc14b4c96 100644 --- a/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs +++ b/crates/forge_app/src/dto/anthropic/transforms/auth_system_message.rs @@ -91,6 +91,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; Request::try_from(context).unwrap() diff --git a/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs b/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs index 7380824cb4..0598d20045 100644 --- a/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs +++ b/crates/forge_app/src/dto/anthropic/transforms/set_cache.rs @@ -102,6 +102,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = Request::try_from(context).expect("Failed to convert context to request"); @@ -241,6 +242,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = Request::try_from(context).expect("Failed to convert context to request"); diff --git a/crates/forge_app/src/dto/google/request.rs b/crates/forge_app/src/dto/google/request.rs index c148e28414..38993fba1d 100644 --- a/crates/forge_app/src/dto/google/request.rs +++ b/crates/forge_app/src/dto/google/request.rs @@ -474,6 +474,10 @@ impl From for Content { match message { ContextMessage::Text(text_message) => Content::from(text_message), ContextMessage::Tool(tool_result) => Content::from(tool_result), + ContextMessage::ToolSearchOutput(_) => { + // Tool search output is OpenAI Responses API specific - skip for Google + Content { role: None, parts: vec![] } + } ContextMessage::Image(image) => Content::from(image), } } @@ -576,6 +580,7 @@ mod tests { r#"{"file_path":"test.rs","old_string":"foo","new_string":"bar"}"#, ), thought_signature: None, + namespace: None, }; // Convert to Google Part @@ -615,12 +620,14 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_json(r#"{"path":"file1.rs"}"#), thought_signature: None, + namespace: None, }, ToolCallFull { name: ToolName::new("remove"), call_id: None, arguments: ToolCallArguments::from_json(r#"{"path":"file2.rs"}"#), thought_signature: None, + namespace: None, }, ]; diff --git a/crates/forge_app/src/dto/google/response.rs b/crates/forge_app/src/dto/google/response.rs index 404f2342fc..1ef48ea5ab 100644 --- a/crates/forge_app/src/dto/google/response.rs +++ b/crates/forge_app/src/dto/google/response.rs @@ -431,6 +431,7 @@ impl TryFrom for ChatCompletionMessage { name: Some(ToolName::new(function_call.name)), arguments_part: serde_json::to_string(&function_call.args)?, thought_signature, + namespace: None, }, ), ), diff --git a/crates/forge_app/src/dto/openai/request.rs b/crates/forge_app/src/dto/openai/request.rs index 309aeaca09..bbd4bba748 100644 --- a/crates/forge_app/src/dto/openai/request.rs +++ b/crates/forge_app/src/dto/openai/request.rs @@ -504,6 +504,21 @@ impl From for Message { extra_content: None, } } + ContextMessage::ToolSearchOutput(_) => { + // Tool search output is OpenAI Responses API specific - skip for OpenAI + Message { + role: Role::User, + content: None, + name: None, + tool_call_id: None, + tool_calls: None, + reasoning_details: None, + reasoning_text: None, + reasoning_opaque: None, + reasoning_content: None, + extra_content: None, + } + } } } } @@ -741,6 +756,7 @@ mod tests { name: ToolName::new("test_tool"), arguments: serde_json::json!({"key": "value"}).into(), thought_signature: None, + namespace: None, }; let assistant_message = ContextMessage::Text( diff --git a/crates/forge_app/src/dto/openai/response.rs b/crates/forge_app/src/dto/openai/response.rs index 90829302f4..3d71f40116 100644 --- a/crates/forge_app/src/dto/openai/response.rs +++ b/crates/forge_app/src/dto/openai/response.rs @@ -372,6 +372,7 @@ impl TryFrom for ChatCompletionMessage { &tool_call.function.arguments, )?, thought_signature, + namespace: None, }); } } @@ -433,6 +434,7 @@ impl TryFrom for ChatCompletionMessage { name: tool_call.function.name.clone(), arguments_part: tool_call.function.arguments.clone(), thought_signature, + namespace: None, }); } } diff --git a/crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs b/crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs index dd789072d9..6f1ac8b452 100644 --- a/crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs +++ b/crates/forge_app/src/dto/openai/transformers/drop_tool_call.rs @@ -47,6 +47,7 @@ mod tests { name: ToolName::new("test_tool"), arguments: serde_json::json!({"key": "value"}).into(), thought_signature: None, + namespace: None, }; let tool_result = ToolResult::new(ToolName::new("test_tool")) @@ -72,6 +73,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = Request::from(context); diff --git a/crates/forge_app/src/dto/openai/transformers/set_cache.rs b/crates/forge_app/src/dto/openai/transformers/set_cache.rs index 61052c3d3c..25fd0d6ed4 100644 --- a/crates/forge_app/src/dto/openai/transformers/set_cache.rs +++ b/crates/forge_app/src/dto/openai/transformers/set_cache.rs @@ -88,6 +88,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = Request::from(context); diff --git a/crates/forge_app/src/hooks/doom_loop.rs b/crates/forge_app/src/hooks/doom_loop.rs index 3515b74e7b..73df95a406 100644 --- a/crates/forge_app/src/hooks/doom_loop.rs +++ b/crates/forge_app/src/hooks/doom_loop.rs @@ -269,6 +269,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, + response_items: None, } } @@ -405,6 +406,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, + response_items: None, }; let user_msg = TextMessage { @@ -417,6 +419,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, + response_items: None, }; let assistant_msg_2 = TextMessage { @@ -429,6 +432,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, + response_items: None, }; let messages = [ diff --git a/crates/forge_app/src/hooks/tracing.rs b/crates/forge_app/src/hooks/tracing.rs index 94755f2b2c..8fb51b99b2 100644 --- a/crates/forge_app/src/hooks/tracing.rs +++ b/crates/forge_app/src/hooks/tracing.rs @@ -205,6 +205,8 @@ mod tests { usage: Default::default(), finish_reason: None, phase: None, + tool_search_output: None, + response_items: None, }; let event = EventData::new(test_agent(), test_model_id(), ResponsePayload::new(message)); @@ -221,6 +223,7 @@ mod tests { call_id: Some(ToolCallId::new("test-id")), arguments: serde_json::json!({"key": "value"}).into(), thought_signature: None, + namespace: None, }; let result = ToolResult::new(ToolName::from("test-tool")) .call_id(ToolCallId::new("test-id")) diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index b5435c4afb..0317cd0ea7 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -24,6 +24,10 @@ pub struct Orchestrator { agent: Agent, error_tracker: ToolErrorTracker, hook: Arc, + /// Whether tool_search API is enabled for deferred tool loading. + /// When `true`, MCP tools are sent with `defer_loading: true` and a + /// `tool_search` tool is injected so the model can discover them on demand. + tool_search: Option, config: forge_config::ForgeConfig, } @@ -44,6 +48,7 @@ impl> Orc models: Default::default(), error_tracker: Default::default(), hook: Arc::new(Hook::default()), + tool_search: Default::default(), } } @@ -195,10 +200,13 @@ impl> Orc async fn execute_chat_turn( &self, model_id: &ModelId, - context: Context, + mut context: Context, reasoning_supported: bool, ) -> anyhow::Result { let tool_supported = self.is_tool_supported()?; + // Propagate tool_search config to the context so the repository layer + // can decide whether to apply deferred tool loading. + context.tool_search = self.tool_search; let mut transformers = DefaultTransformation::default() .pipe(SortTools::new(self.agent.tool_order())) .pipe(NormalizeToolCallArguments::new()) @@ -310,8 +318,11 @@ impl> Orc // Turn is completed, if finish_reason is 'stop'. Gemini models return stop as // finish reason with tool calls. - is_complete = - message.finish_reason == Some(FinishReason::Stop) && message.tool_calls.is_empty(); + // When tool_search_output is present, the model discovered tools via search + // and needs another turn to actually use them — do NOT mark as complete. + is_complete = message.finish_reason == Some(FinishReason::Stop) + && message.tool_calls.is_empty() + && message.tool_search_output.is_none(); // Should yield if a tool is asking for a follow-up should_yield = is_complete @@ -356,6 +367,8 @@ impl> Orc message.usage, tool_call_records, message.phase, + message.tool_search_output.clone(), + message.response_items.clone(), ); if self.error_tracker.limit_reached() { diff --git a/crates/forge_app/src/user_prompt.rs b/crates/forge_app/src/user_prompt.rs index 382ba8e765..f6c403a78f 100644 --- a/crates/forge_app/src/user_prompt.rs +++ b/crates/forge_app/src/user_prompt.rs @@ -77,6 +77,7 @@ impl UserPromptGenerator { model: Some(self.agent.model.clone()), droppable: true, // Droppable so it can be removed during context compression phase: None, + response_items: None, }; context = context.add_message(ContextMessage::Text(todo_message)); } @@ -123,6 +124,7 @@ impl UserPromptGenerator { model: Some(self.agent.model.clone()), droppable: true, // Piped input is droppable phase: None, + response_items: None, }; context = context.add_message(ContextMessage::Text(piped_message)); } @@ -200,6 +202,7 @@ impl UserPromptGenerator { model: Some(self.agent.model.clone()), droppable: false, phase: None, + response_items: None, }; context = context.add_message(ContextMessage::Text(message)); } diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index f807226064..69b6882f81 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -23,6 +23,7 @@ restricted = false sem_search_top_k = 10 services_url = "https://api.forgecode.dev/" tool_supported = true +tool_search = false tool_timeout_secs = 300 top_k = 30 top_p = 0.8 diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 0f189de0e3..cb98848290 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -253,6 +253,14 @@ pub struct ForgeConfig { #[serde(default)] pub tool_supported: bool, + /// Whether server-side tool search is enabled for models that support + /// deferred tool loading (e.g. GPT-5.4). When enabled, MCP tools are + /// sent with `defer_loading: true` and a `tool_search` tool is injected + /// so the API can discover them on demand. Defaults to `false`; set to + /// `true` to enable. + #[serde(default)] + pub tool_search: bool, + /// Reasoning configuration applied to all agents; controls effort level, /// token budget, and visibility of the model's thinking process. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/forge_domain/src/compact/summary.rs b/crates/forge_domain/src/compact/summary.rs index 3416dfdba8..358a198068 100644 --- a/crates/forge_domain/src/compact/summary.rs +++ b/crates/forge_domain/src/compact/summary.rs @@ -275,6 +275,9 @@ impl From<&Context> for ContextSummary { } } ContextMessage::Image(_) => {} + ContextMessage::ToolSearchOutput(_) => { + // Tool search output is not included in the summary + } } } @@ -900,6 +903,7 @@ mod tests { call_id: Some(ToolCallId::new("call_1")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug report"}"#), thought_signature: None, + namespace: None, }; let actual = extract_tool_info(&fixture, &[]); @@ -992,6 +996,7 @@ mod tests { r#"{"path": "/test", "pattern": "pattern"}"#, ), thought_signature: None, + namespace: None, }], )]); @@ -1469,6 +1474,7 @@ mod tests { r#"{"title": "Bug report", "body": "Description"}"#, ), thought_signature: None, + namespace: None, }], )]); @@ -1498,6 +1504,7 @@ mod tests { call_id: Some(ToolCallId::new("call_1")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug"}"#), thought_signature: None, + namespace: None, }], ), tool_result("mcp_github_create_issue", "call_1", false), @@ -1528,6 +1535,7 @@ mod tests { call_id: Some(ToolCallId::new("call_1")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug"}"#), thought_signature: None, + namespace: None, }, ToolCallFull { name: ToolName::new("mcp_slack_post_message"), @@ -1536,6 +1544,7 @@ mod tests { r##"{"channel": "#dev", "text": "Hello"}"##, ), thought_signature: None, + namespace: None, }, ], )]); @@ -1571,6 +1580,7 @@ mod tests { call_id: Some(ToolCallId::new("call_2")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug"}"#), thought_signature: None, + namespace: None, }, ToolCatalog::tool_call_write("/test/output.txt", "result").call_id("call_3"), ], diff --git a/crates/forge_domain/src/context.rs b/crates/forge_domain/src/context.rs index 664000e1eb..dfd4e7685c 100644 --- a/crates/forge_domain/src/context.rs +++ b/crates/forge_domain/src/context.rs @@ -7,7 +7,8 @@ use forge_template::Element; use serde::{Deserialize, Serialize}; use tracing::debug; -use super::{ToolCallFull, ToolResult}; +use super::{ToolCallFull, ToolResult, ToolSearchOutput}; +use crate::ResponseOutputItem; /// Helper function for serde to skip serializing false boolean values fn is_false(value: &bool) -> bool { @@ -42,6 +43,7 @@ pub enum ContextMessage { Text(TextMessage), Tool(ToolResult), Image(Image), + ToolSearchOutput(ToolSearchOutput), } /// Creates a filtered version of ToolOutput that excludes base64 images to @@ -72,6 +74,7 @@ impl ContextMessage { ContextMessage::Text(text_message) => Some(&text_message.content), ContextMessage::Tool(_) => None, ContextMessage::Image(_) => None, + ContextMessage::ToolSearchOutput(_) => None, } } @@ -82,6 +85,7 @@ impl ContextMessage { ContextMessage::Text(text_message) => text_message.raw_content.as_ref(), ContextMessage::Tool(_) => None, ContextMessage::Image(_) => None, + ContextMessage::ToolSearchOutput(_) => None, } } @@ -104,6 +108,12 @@ impl ContextMessage { _ => 0, }) .sum(), + ContextMessage::ToolSearchOutput(tool_search) => { + // Approximate tokens for tool search output based on JSON representation + serde_json::to_string(&tool_search.tools) + .map(|s| s.chars().count()) + .unwrap_or(0) + } _ => 0, }; @@ -156,6 +166,21 @@ impl ContextMessage { .render() } ContextMessage::Image(_) => Element::new("image").attr("path", "[base64 URL]").render(), + ContextMessage::ToolSearchOutput(tool_search) => Element::new("message") + .attr("role", "assistant") + .append( + Element::new("tool_search_output") + .attr( + "call_id", + tool_search + .call_id + .as_ref() + .map(|id| id.as_str()) + .unwrap_or("null"), + ) + .cdata(serde_json::to_string(&tool_search.tools).unwrap()), + ) + .render(), } } @@ -170,6 +195,7 @@ impl ContextMessage { model, droppable: false, phase: None, + response_items: None, } .into() } @@ -185,6 +211,7 @@ impl ContextMessage { reasoning_details: None, droppable: false, phase: None, + response_items: None, } .into() } @@ -207,6 +234,7 @@ impl ContextMessage { model: None, droppable: false, phase: None, + response_items: None, } .into() } @@ -220,6 +248,7 @@ impl ContextMessage { ContextMessage::Text(message) => message.role == role, ContextMessage::Tool(_) => false, ContextMessage::Image(_) => Role::User == role, + ContextMessage::ToolSearchOutput(_) => false, } } @@ -228,6 +257,7 @@ impl ContextMessage { ContextMessage::Text(message) => message.droppable, ContextMessage::Tool(_) => false, ContextMessage::Image(_) => false, + ContextMessage::ToolSearchOutput(_) => false, } } @@ -236,6 +266,7 @@ impl ContextMessage { ContextMessage::Text(_) => false, ContextMessage::Tool(_) => true, ContextMessage::Image(_) => false, + ContextMessage::ToolSearchOutput(_) => false, } } @@ -244,6 +275,7 @@ impl ContextMessage { ContextMessage::Text(message) => message.tool_calls.is_some(), ContextMessage::Tool(_) => false, ContextMessage::Image(_) => false, + ContextMessage::ToolSearchOutput(_) => false, } } @@ -252,6 +284,7 @@ impl ContextMessage { ContextMessage::Text(message) => message.reasoning_details.is_some(), ContextMessage::Tool(_) => false, ContextMessage::Image(_) => false, + ContextMessage::ToolSearchOutput(_) => false, } } @@ -262,6 +295,15 @@ impl ContextMessage { _ => None, } } + + /// Returns the tool search output if this message is a ToolSearchOutput + /// variant + pub fn as_tool_search_output(&self) -> Option<&ToolSearchOutput> { + match self { + ContextMessage::ToolSearchOutput(output) => Some(output), + _ => None, + } + } } fn tool_call_content_char_count(text_message: &TextMessage) -> usize { @@ -319,6 +361,12 @@ pub struct TextMessage { /// requests. #[serde(default, skip_serializing_if = "Option::is_none")] pub phase: Option, + /// Ordered output items from the Responses API, preserving the exact + /// interleaving of reasoning, tool_search, and function_call items. + /// When present, the serializer emits these items directly instead of + /// the bundled reasoning_details/tool_calls fields. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_items: Option>, } impl TextMessage { @@ -334,6 +382,7 @@ impl TextMessage { reasoning_details: None, droppable: false, phase: None, + response_items: None, } } @@ -356,6 +405,7 @@ impl TextMessage { model, droppable: false, phase: None, + response_items: None, } } } @@ -431,6 +481,12 @@ pub struct Context { /// Response format for structured output (JSON schema) #[serde(default, skip_serializing_if = "Option::is_none")] pub response_format: Option, + /// Whether server-side tool search with deferred tool loading is enabled. + /// When `true` and the model supports it, MCP tools are deferred and a + /// `tool_search` tool is injected. When `false`, all tools are sent + /// eagerly. Defaults to `None` (treated as disabled). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_search: Option, } impl Context { @@ -570,6 +626,8 @@ impl Context { usage: Usage, tool_records: Vec<(ToolCallFull, ToolResult)>, phase: Option, + tool_search_output: Option, + response_items: Option>, ) -> Self { // Convert flat reasoning string to reasoning_details only when no structured // reasoning_details are present. When reasoning_details already exists it @@ -586,7 +644,15 @@ impl Context { (None, None) => None, }; - // Adding tool calls + // Append tool search output AFTER the assistant message. + // Although the server discovers deferred tools before the model's + // tool call, the from_domain conversion re-orders items to match + // the API's expected input order: + // The API expects tool_search items BEFORE the assistant's reasoning and + // function calls. Insert ToolSearchOutput first so from_domain emits: + // tool_search_call -> tool_search_output -> reasoning -> function_calls + let mut ctx = self; + let mut message: MessageEntry = ContextMessage::assistant( content, thought_signature, @@ -600,9 +666,11 @@ impl Context { ) .into(); - // Set phase on the assistant TextMessage if provided + // Set phase and response_items on the assistant TextMessage if provided + let response_items_present = response_items.is_some(); if let ContextMessage::Text(ref mut text_msg) = message.message { text_msg.phase = phase; + text_msg.response_items = response_items; } let tool_results = tool_records @@ -610,8 +678,21 @@ impl Context { .map(|record| record.1.clone()) .collect::>(); - self.add_entry(message.usage(usage)) - .add_tool_results(tool_results) + ctx = ctx.add_entry(message.usage(usage)); + + // Add ToolSearchOutput as a separate context message ONLY when response_items + // is not present (backward compatibility with non-Responses API providers). + // When response_items IS present, tool_search data is already inline in the + // ordered items list and doesn't need a separate context message. + if let Some(tool_search_output) = tool_search_output { + if response_items_present { + // response_items already contains the tool_search data inline + } else { + ctx = ctx.add_message(ContextMessage::ToolSearchOutput(tool_search_output)); + } + } + + ctx.add_tool_results(tool_results) } /// Returns the token count for context @@ -1331,12 +1412,14 @@ mod tests { name: crate::ToolName::new("tool1"), arguments: serde_json::json!({"arg": "value"}).into(), thought_signature: None, + namespace: None, }, ToolCallFull { call_id: Some(crate::ToolCallId::new("call2")), name: crate::ToolName::new("tool2"), arguments: serde_json::json!({"arg": "value"}).into(), thought_signature: None, + namespace: None, }, ]), )) @@ -1452,12 +1535,14 @@ mod tests { name: crate::ToolName::new("fs_search"), arguments: serde_json::json!({"query": "test"}).into(), thought_signature: None, + namespace: None, }, ToolCallFull { call_id: Some(crate::ToolCallId::new("call2")), name: crate::ToolName::new("calculate"), arguments: serde_json::json!({"expression": "2+2"}).into(), thought_signature: None, + namespace: None, }, ]; let fixture = @@ -1692,6 +1777,8 @@ mod tests { Usage::default(), vec![], None, + None, + None, // response_items ); // Extract the stored reasoning_details from the assistant message. diff --git a/crates/forge_domain/src/conversation_html.rs b/crates/forge_domain/src/conversation_html.rs index aec2a1e905..e029029672 100644 --- a/crates/forge_domain/src/conversation_html.rs +++ b/crates/forge_domain/src/conversation_html.rs @@ -361,6 +361,62 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { message_elm }; + // Add tool search info from response_items if present + let message_elm = + if let Some(response_items) = &content_message.response_items { + let tso_items: Vec<_> = response_items + .iter() + .filter_map(|item| { + if let crate::ResponseOutputItem::ToolSearchOutput(tso) = + item + { + Some(tso) + } else { + None + } + }) + .collect(); + if !tso_items.is_empty() { + message_elm.append(tso_items.iter().map(|tso| { + let tool_names: Vec<&str> = tso + .tools + .iter() + .filter_map(|t| { + t.get("name").and_then(|n| n.as_str()) + }) + .collect(); + let label = format!( + "Tool Search: loaded {} tool{}", + tool_names.len(), + if tool_names.len() == 1 { "" } else { "s" } + ); + Element::new("details.tool-search-info") + .append( + Element::new("summary") + .append(Element::new("em").text(&label)) + .append(if !tool_names.is_empty() { + Element::new("span").text(format!( + " ({})", + tool_names.join(", ") + )) + } else { + Element::new("span") + }), + ) + .append(Element::new("div.main-content").append( + Element::new("pre").text( + serde_json::to_string_pretty(&tso.tools) + .unwrap_or_default(), + ), + )) + })) + } else { + message_elm + } + } else { + message_elm + }; + // Add main content let message_elm = message_elm.append( Element::new("div.main-content") @@ -437,7 +493,7 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { Element::new("div.agent-conversation") .append( Element::new("p") - .append(Element::new("strong").text("🤖 Agent Conversation: ")) + .append(Element::new("strong").text("Agent Conversation: ")) .append( Element::new("a") .attr("href", format!("#{}", anchor_id)) @@ -458,6 +514,36 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { .append(Element::new("strong").text("Image Attachment")) .append(Element::new("img").attr("src", image.url())) } + ContextMessage::ToolSearchOutput(tso) => { + let tool_names: Vec<&str> = tso.tools.iter().filter_map(|t| { + t.get("name").and_then(|n| n.as_str()) + }).collect(); + let label = format!( + "Tool Search: loaded {} tool{}", + tool_names.len(), + if tool_names.len() == 1 { "" } else { "s" } + ); + + Element::new("details.tool-search-info") + .append( + Element::new("summary") + .append(Element::new("em").text(&label)) + .append(if !tool_names.is_empty() { + Element::new("span").text( + format!(" ({})", tool_names.join(", ")) + ) + } else { + Element::new("span") + }) + ) + .append( + Element::new("div.main-content") + .append(Element::new("pre").text( + serde_json::to_string_pretty(&tso.tools) + .unwrap_or_default() + )) + ) + } } }), ); diff --git a/crates/forge_domain/src/conversation_style.css b/crates/forge_domain/src/conversation_style.css index 5aea1b1aa0..9a90ee3ee6 100644 --- a/crates/forge_domain/src/conversation_style.css +++ b/crates/forge_domain/src/conversation_style.css @@ -119,6 +119,28 @@ td { display: inline-block; } +.tool-search-info { + margin-top: 8px; + padding: 6px 10px; + background-color: #e8f4e8; + border-left: 3px solid #4caf50; + font-size: 0.9em; + color: #2e7d32; +} + +.tool-search-info summary { + cursor: pointer; +} + +.tool-search-info pre { + background-color: #f5f5f5; + color: #333; + padding: 8px; + border-radius: 4px; + overflow-x: auto; + margin-top: 6px; +} + img { max-width: 100%; } diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index 47579d7a43..d5cfecf53c 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -641,6 +641,8 @@ mod tests { usage: crate::Usage::default(), finish_reason: None, phase: None, + tool_search_output: None, + response_items: None, }), )), LifecycleEvent::ToolcallStart(EventData::new( diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index 5db0a8553b..46ee2467ac 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -34,6 +34,7 @@ mod policies; mod provider; mod reasoning; mod repo; +mod response_item; mod result_stream_ext; mod session_metrics; mod shell; @@ -46,6 +47,7 @@ mod template; mod tools; mod tool_order; +mod tool_search; mod top_k; mod top_p; mod transformer; @@ -89,6 +91,7 @@ pub use policies::*; pub use provider::*; pub use reasoning::*; pub use repo::*; +pub use response_item::*; pub use result_stream_ext::*; pub use session_metrics::*; pub use shell::*; @@ -99,6 +102,7 @@ pub use system_context::*; pub use temperature::*; pub use template::*; pub use tool_order::*; +pub use tool_search::*; pub use tools::*; pub use top_k::*; pub use top_p::*; diff --git a/crates/forge_domain/src/message.rs b/crates/forge_domain/src/message.rs index 38440ef061..4327efe288 100644 --- a/crates/forge_domain/src/message.rs +++ b/crates/forge_domain/src/message.rs @@ -3,9 +3,10 @@ use derive_setters::Setters; use serde::{Deserialize, Serialize}; use strum_macros::{EnumString, IntoStaticStr}; -use super::{ToolCall, ToolCallFull}; +use super::{ToolCall, ToolCallFull, ToolSearchOutput}; use crate::TokenCount; use crate::reasoning::{Reasoning, ReasoningFull}; +use crate::response_item::ResponseOutputItem; /// Labels an assistant message as intermediate commentary or the final answer. /// @@ -94,6 +95,14 @@ pub struct ChatCompletionMessage { /// Phase label for assistant messages (e.g. `Commentary` or `FinalAnswer`). /// Preserved from the response and replayed back on subsequent requests. pub phase: Option, + /// Tool search output from deferred tool discovery. + /// When present, this should be converted to + /// ContextMessage::ToolSearchOutput instead of a regular assistant + /// message. + pub tool_search_output: Option, + /// Ordered output items from the Responses API, preserving the exact + /// interleaving of reasoning, tool_search, and function_call items. + pub response_items: Option>, } impl From for ChatCompletionMessage { @@ -214,7 +223,7 @@ impl ChatCompletionMessage { /// Represents a complete message from the LLM provider with all content /// collected This is typically used after processing a stream of /// ChatCompletionMessage -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct ChatCompletionMessageFull { pub content: String, pub thought_signature: Option, @@ -226,6 +235,11 @@ pub struct ChatCompletionMessageFull { /// Phase label for the assistant message (e.g. `Commentary` or /// `FinalAnswer`). pub phase: Option, + /// Tool search output from the OpenAI Responses API tool search feature + pub tool_search_output: Option, + /// Ordered output items from the Responses API, preserving the exact + /// interleaving of reasoning, tool_search, and function_call items. + pub response_items: Option>, } #[cfg(test)] diff --git a/crates/forge_domain/src/message_pattern.rs b/crates/forge_domain/src/message_pattern.rs index b9b9248a3d..dbb90e6220 100644 --- a/crates/forge_domain/src/message_pattern.rs +++ b/crates/forge_domain/src/message_pattern.rs @@ -68,6 +68,7 @@ impl MessagePattern { call_id: Some(ToolCallId::new("call_123")), arguments: json!({"path": "/test/path"}).into(), thought_signature: None, + namespace: None, }; let tool_result = ToolResult::new(ToolName::new("read")) diff --git a/crates/forge_domain/src/response_item.rs b/crates/forge_domain/src/response_item.rs new file mode 100644 index 0000000000..547ad3c566 --- /dev/null +++ b/crates/forge_domain/src/response_item.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +use crate::reasoning::ReasoningFull; +use crate::tool_search::ToolSearchOutput; +use crate::{ToolCallArguments, ToolCallId, ToolName}; + +/// An ordered output item from the Responses API. +/// +/// Items are recorded in the exact order they arrive from the API stream, +/// preserving the interleaving of reasoning, tool_search, and function_call +/// items. When present on a `TextMessage`, the serializer emits these items +/// directly instead of the bundled reasoning_details/tool_calls fields. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseOutputItem { + /// Model reasoning item (may appear multiple times per turn) + Reasoning(ReasoningFull), + /// Server-side tool search call (captured as raw JSON for exact replay) + ToolSearchCall(serde_json::Value), + /// Server-side tool search output with discovered tools + ToolSearchOutput(ToolSearchOutput), + /// A function call made by the model + FunctionCall { + id: String, + call_id: ToolCallId, + name: ToolName, + arguments: ToolCallArguments, + #[serde(skip_serializing_if = "Option::is_none")] + namespace: Option, + }, + /// Text content emitted by the model + Text(String), +} diff --git a/crates/forge_domain/src/result_stream_ext.rs b/crates/forge_domain/src/result_stream_ext.rs index 9250ff0adc..898457e194 100644 --- a/crates/forge_domain/src/result_stream_ext.rs +++ b/crates/forge_domain/src/result_stream_ext.rs @@ -259,11 +259,25 @@ impl ResultStreamExt for crate::BoxStream for crate::BoxStream, + /// The status of the tool search operation + pub status: ToolSearchStatus, + /// The execution type (server or client) + pub execution: ToolSearchExecution, + /// The discovered tools as JSON values + #[setters(skip)] + pub tools: Vec, + /// The raw tool_search_call item from the API response. + /// Stored as JSON so it can be replayed verbatim in subsequent requests. + /// The API expects this to appear before the tool_search_output in input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_search_call: Option, +} + +impl ToolSearchOutput { + /// Creates a new ToolSearchOutput with server execution defaults + pub fn new(call_id: Option>) -> Self { + Self { + call_id: call_id.map(|id| id.into()), + status: ToolSearchStatus::Completed, + execution: ToolSearchExecution::Server, + tools: Vec::new(), + tool_search_call: None, + } + } + + /// Adds a discovered tool to the output + pub fn add_tool(mut self, tool: impl Serialize) -> anyhow::Result { + let tool_json = serde_json::to_value(tool)?; + self.tools.push(tool_json); + Ok(self) + } +} + +/// Status of a tool search operation +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ToolSearchStatus { + /// The tool search is still in progress + InProgress, + /// The tool search completed successfully + #[default] + Completed, + /// The tool search was incomplete + Incomplete, +} + +/// Execution type for tool search +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ToolSearchExecution { + /// Server-side execution + #[default] + Server, + /// Client-side execution + Client, +} diff --git a/crates/forge_domain/src/tools/call/parser.rs b/crates/forge_domain/src/tools/call/parser.rs index 899c0c3b24..36ac888425 100644 --- a/crates/forge_domain/src/tools/call/parser.rs +++ b/crates/forge_domain/src/tools/call/parser.rs @@ -85,6 +85,7 @@ impl From for ToolCallFull { call_id: None, arguments: ToolCallArguments::from_parameters(value.args), thought_signature: None, + namespace: None, } } } @@ -172,6 +173,7 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_parameters(self.args.clone()), thought_signature: None, + namespace: None, } } } @@ -223,6 +225,7 @@ mod tests { call_id: None, arguments: json!({"path":"/a/b/c.txt"}).into(), thought_signature: None, + namespace: None, }]; assert_eq!(action, expected); } @@ -379,6 +382,7 @@ mod tests { call_id: None, arguments: json!({"path":"/test/path","regex":"test"}).into(), thought_signature: None, + namespace: None, }]; assert_eq!(action, expected); } @@ -398,6 +402,7 @@ mod tests { call_id: None, arguments: json!({"p1":"\nabc\n"}).into(), thought_signature: None, + namespace: None, }]; assert_eq!(action, expected); } diff --git a/crates/forge_domain/src/tools/call/tool_call.rs b/crates/forge_domain/src/tools/call/tool_call.rs index 317906e6f6..b4764bc870 100644 --- a/crates/forge_domain/src/tools/call/tool_call.rs +++ b/crates/forge_domain/src/tools/call/tool_call.rs @@ -51,6 +51,11 @@ pub struct ToolCallPart { /// Optional thought signature from Gemini3 #[serde(skip_serializing_if = "Option::is_none")] pub thought_signature: Option, + + /// Namespace for the tool call (used by OpenAI Responses API for + /// tools discovered via tool_search with deferred loading). + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, From)] @@ -86,6 +91,11 @@ pub struct ToolCallFull { pub arguments: ToolCallArguments, #[serde(skip_serializing_if = "Option::is_none")] pub thought_signature: Option, + + /// Namespace for the tool call (used by OpenAI Responses API for + /// tools discovered via tool_search with deferred loading). + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, } impl ToolCallFull { @@ -95,6 +105,7 @@ impl ToolCallFull { call_id: None, arguments: ToolCallArguments::default(), thought_signature: None, + namespace: None, } } @@ -113,6 +124,7 @@ impl ToolCallFull { let mut current_tool_name: Option = None; let mut current_arguments = String::new(); let mut current_thought_signature: Option = None; + let mut current_namespace: Option = None; // GLM model workaround: Track the last valid tool name and call_id // GLM sends malformed tool calls where subsequent chunks have: @@ -156,6 +168,7 @@ impl ToolCallFull { call_id: Some(existing_call_id.clone()), arguments, thought_signature: current_thought_signature.take(), + namespace: current_namespace.take(), }); } current_arguments.clear(); @@ -180,6 +193,11 @@ impl ToolCallFull { current_thought_signature = part.thought_signature.clone(); } + // Capture namespace from the first part that has it + if current_namespace.is_none() && part.namespace.is_some() { + current_namespace = part.namespace.clone(); + } + current_arguments.push_str(&part.arguments_part); } @@ -196,6 +214,7 @@ impl ToolCallFull { call_id: current_call_id, arguments, thought_signature: current_thought_signature, + namespace: current_namespace, }); } @@ -344,18 +363,21 @@ mod tests { name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"crates/forge_services/src/fixtures/".to_string(), thought_signature: None, + namespace: None, }, ToolCallPart { call_id: None, name: None, arguments_part: "mascot.md\"}".to_string(), thought_signature: None, + namespace: None, }, ToolCallPart { call_id: Some(ToolCallId("call_2".to_string())), name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"docs/".to_string(), thought_signature: None, + namespace: None, }, ToolCallPart { // NOTE: Call ID can be repeated with each message @@ -363,18 +385,21 @@ mod tests { name: None, arguments_part: "onboarding.md\"}".to_string(), thought_signature: None, + namespace: None, }, ToolCallPart { call_id: Some(ToolCallId("call_3".to_string())), name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"crates/forge_services/src/service/".to_string(), thought_signature: None, + namespace: None, }, ToolCallPart { call_id: None, name: None, arguments_part: "service.md\"}".to_string(), thought_signature: None, + namespace: None, }, ]; @@ -388,12 +413,14 @@ mod tests { r#"{"path": "crates/forge_services/src/fixtures/mascot.md"}"#, ), thought_signature: None, + namespace: None, }, ToolCallFull { name: ToolName::new("read"), call_id: Some(ToolCallId("call_2".to_string())), arguments: ToolCallArguments::from_json(r#"{"path": "docs/onboarding.md"}"#), thought_signature: None, + namespace: None, }, ToolCallFull { name: ToolName::new("read"), @@ -402,6 +429,7 @@ mod tests { r#"{"path": "crates/forge_services/src/service/service.md"}"#, ), thought_signature: None, + namespace: None, }, ]; @@ -516,6 +544,7 @@ mod tests { name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"docs/onboarding.md\"}".to_string(), thought_signature: None, + namespace: None, }]; let actual = ToolCallFull::try_from_parts(&input).unwrap(); @@ -524,6 +553,7 @@ mod tests { name: ToolName::new("read"), arguments: ToolCallArguments::from_json(r#"{"path": "docs/onboarding.md"}"#), thought_signature: None, + namespace: None, }]; assert_eq!(actual, expected); @@ -544,6 +574,7 @@ mod tests { name: Some(ToolName::new("screenshot")), arguments_part: "".to_string(), thought_signature: None, + namespace: None, }]; let actual = ToolCallFull::try_from_parts(&input).unwrap(); @@ -552,6 +583,7 @@ mod tests { name: ToolName::new("screenshot"), arguments: ToolCallArguments::default(), thought_signature: None, + namespace: None, }]; assert_eq!(actual, expected); @@ -585,18 +617,21 @@ mod tests { name: Some(ToolName::new("read")), arguments_part: "".to_string(), thought_signature: None, + namespace: None, }, ToolCallPart { call_id: Some(ToolCallId("0".to_string())), name: Some(ToolName::new("")), // Empty name should not override valid name arguments_part: "{\"path\"".to_string(), thought_signature: None, + namespace: None, }, ToolCallPart { call_id: Some(ToolCallId("0".to_string())), name: Some(ToolName::new("")), // Empty name should not override valid name arguments_part: ": \"/test/file.md\"}".to_string(), thought_signature: None, + namespace: None, }, ]; @@ -606,6 +641,7 @@ mod tests { name: ToolName::new("read"), arguments: ToolCallArguments::from_json(r#"{"path": "/test/file.md"}"#), thought_signature: None, + namespace: None, }]; assert_eq!(actual, expected); @@ -699,12 +735,14 @@ mod tests { name: Some(ToolName::new("shell")), arguments_part: "{\"command\": \"date\"".to_string(), thought_signature: Some("signature_abc123".to_string()), + namespace: None, }, ToolCallPart { call_id: None, name: None, arguments_part: "}".to_string(), thought_signature: None, // Later parts typically don't have signature + namespace: None, }, ]; @@ -714,6 +752,7 @@ mod tests { name: ToolName::new("shell"), arguments: ToolCallArguments::from_json(r#"{"command": "date"}"#), thought_signature: Some("signature_abc123".to_string()), + namespace: None, }]; assert_eq!(actual, expected); @@ -728,12 +767,14 @@ mod tests { name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"file1.txt\"}".to_string(), thought_signature: Some("sig_1".to_string()), + namespace: None, }, ToolCallPart { call_id: Some(ToolCallId("call_2".to_string())), name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"file2.txt\"}".to_string(), thought_signature: Some("sig_2".to_string()), + namespace: None, }, ]; @@ -744,12 +785,14 @@ mod tests { name: ToolName::new("read"), arguments: ToolCallArguments::from_json(r#"{"path": "file1.txt"}"#), thought_signature: Some("sig_1".to_string()), + namespace: None, }, ToolCallFull { call_id: Some(ToolCallId("call_2".to_string())), name: ToolName::new("read"), arguments: ToolCallArguments::from_json(r#"{"path": "file2.txt"}"#), thought_signature: Some("sig_2".to_string()), + namespace: None, }, ]; diff --git a/crates/forge_domain/src/tools/catalog.rs b/crates/forge_domain/src/tools/catalog.rs index 4db441107e..67ff7baa4e 100644 --- a/crates/forge_domain/src/tools/catalog.rs +++ b/crates/forge_domain/src/tools/catalog.rs @@ -1205,7 +1205,13 @@ impl From for ToolCallFull { ToolCallArguments::default() }; - ToolCallFull { name, call_id: None, arguments, thought_signature: None } + ToolCallFull { + name, + call_id: None, + arguments, + thought_signature: None, + namespace: None, + } } } @@ -1264,6 +1270,7 @@ mod tests { r#"{"path": "/test/path.rs", "start_line": "10", "end_line": "20"}"#, ), thought_signature: None, + namespace: None, }; // This should not panic - it should coerce strings to integers @@ -1295,6 +1302,7 @@ mod tests { r#"{"path": "/test/path.rs", "start_line": 10, "end_line": 20}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1325,6 +1333,7 @@ mod tests { r#"{"path": "/test/path.rs", "start_line": 10, "end_line": 20}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1355,6 +1364,7 @@ mod tests { r#"{"path": "/test/path.rs", "content": "test content"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1382,6 +1392,7 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_json(r#"{"path": "/test/path.rs"}"#), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1406,6 +1417,7 @@ mod tests { r#"{"path": "/test/path.rs", "content": "test"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1564,6 +1576,7 @@ mod tests { r#"{"path": "/test/file.rs", "operation": "replace", "new_string": "new", "old_string": "old"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1592,6 +1605,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "operation": "replace", "new_string": "new", "search": "old text"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1620,6 +1634,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "operation": "replace", "content": "new content", "old_string": "old"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1648,6 +1663,7 @@ mod tests { r#"{"path": "/test/file.rs", "operation": "replace", "content": "new content", "search": "old text"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1678,6 +1694,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "operation": "replace", "new_string": "new content", "old_string": "old text"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1705,6 +1722,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "new_string": "new", "old_string": "old", "replace_all": true}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1880,6 +1898,7 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_json(r#"{"command": "ls"}"#), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1899,6 +1918,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "new_string": "new", "old_string": "old"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); diff --git a/crates/forge_domain/src/transformer/mod.rs b/crates/forge_domain/src/transformer/mod.rs index 1f4ccc91b7..ad1e6cfa65 100644 --- a/crates/forge_domain/src/transformer/mod.rs +++ b/crates/forge_domain/src/transformer/mod.rs @@ -119,6 +119,7 @@ mod tests { call_id: Some(ToolCallId::new("call_123")), arguments: serde_json::json!({"param": "value"}).into(), thought_signature: None, + namespace: None, }; Context::default() diff --git a/crates/forge_domain/src/transformer/normalize_tool_args.rs b/crates/forge_domain/src/transformer/normalize_tool_args.rs index 1e5492f79e..c7fa8ae3b9 100644 --- a/crates/forge_domain/src/transformer/normalize_tool_args.rs +++ b/crates/forge_domain/src/transformer/normalize_tool_args.rs @@ -67,12 +67,14 @@ mod tests { r#"{"file_path": "/test/path", "start_line": 1}"#, ), thought_signature: None, + namespace: None, }]), thought_signature: None, model: None, reasoning_details: None, droppable: false, phase: None, + response_items: None, })); // Apply the transformer @@ -144,12 +146,14 @@ mod tests { "start_line": 1 })), thought_signature: None, + namespace: None, }]), thought_signature: None, model: None, reasoning_details: None, droppable: false, phase: None, + response_items: None, })); let mut transformer = NormalizeToolCallArguments::new(); diff --git a/crates/forge_domain/src/transformer/transform_tool_calls.rs b/crates/forge_domain/src/transformer/transform_tool_calls.rs index da063a8886..51833243dd 100644 --- a/crates/forge_domain/src/transformer/transform_tool_calls.rs +++ b/crates/forge_domain/src/transformer/transform_tool_calls.rs @@ -44,6 +44,7 @@ impl Transformer for TransformToolCalls { model: text_msg.model.clone(), droppable: text_msg.droppable, phase: text_msg.phase, + response_items: text_msg.response_items.clone(), }) .into(), ); @@ -105,6 +106,7 @@ mod tests { call_id: Some(ToolCallId::new("call_123")), arguments: serde_json::json!({"param": "value"}).into(), thought_signature: None, + namespace: None, }; Context::default() diff --git a/crates/forge_repo/src/conversation/conversation_record.rs b/crates/forge_repo/src/conversation/conversation_record.rs index 7df99bf5a3..31d9df797a 100644 --- a/crates/forge_repo/src/conversation/conversation_record.rs +++ b/crates/forge_repo/src/conversation/conversation_record.rs @@ -116,6 +116,8 @@ pub(super) struct ToolCallFullRecord { arguments: ToolCallArgumentsRecord, #[serde(skip_serializing_if = "Option::is_none")] thought_signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + namespace: Option, } impl From<&forge_domain::ToolCallFull> for ToolCallFullRecord { @@ -125,6 +127,7 @@ impl From<&forge_domain::ToolCallFull> for ToolCallFullRecord { call_id: call.call_id.as_ref().map(ToolCallIdRecord::from), arguments: ToolCallArgumentsRecord::from(&call.arguments), thought_signature: call.thought_signature.clone(), + namespace: call.namespace.clone(), } } } @@ -136,6 +139,7 @@ impl From for forge_domain::ToolCallFull { call_id: record.call_id.map(Into::into), arguments: record.arguments.into(), thought_signature: record.thought_signature, + namespace: record.namespace, } } } @@ -317,6 +321,8 @@ pub(super) struct TextMessageRecord { reasoning_details: Option>, #[serde(default, skip_serializing_if = "is_false")] droppable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + response_items: Option>, } /// Helper function for serde to skip serializing false boolean values @@ -341,6 +347,7 @@ impl From<&forge_domain::TextMessage> for TextMessageRecord { .as_ref() .map(|details| details.iter().map(ReasoningFullRecord::from).collect()), droppable: msg.droppable, + response_items: msg.response_items.clone(), } } } @@ -363,6 +370,7 @@ impl TryFrom for forge_domain::TextMessage { .map(|details| details.into_iter().map(Into::into).collect()), droppable: record.droppable, phase: None, + response_items: record.response_items, }) } } @@ -498,16 +506,25 @@ pub(super) enum ContextMessageValueRecord { Text(TextMessageRecord), Tool(ToolResultRecord), Image(ImageRecord), + ToolSearchOutput(forge_domain::ToolSearchOutput), } -impl From<&forge_domain::ContextMessage> for ContextMessageValueRecord { - fn from(value: &forge_domain::ContextMessage) -> Self { +impl ContextMessageValueRecord { + /// Tries to convert a domain ContextMessage to a record. + /// All variants are currently persisted and included in the conversation + /// record. + fn try_from_domain(value: &forge_domain::ContextMessage) -> Option { match value { - forge_domain::ContextMessage::Text(msg) => Self::Text(TextMessageRecord::from(msg)), + forge_domain::ContextMessage::Text(msg) => { + Some(Self::Text(TextMessageRecord::from(msg))) + } forge_domain::ContextMessage::Tool(result) => { - Self::Tool(ToolResultRecord::from(result)) + Some(Self::Tool(ToolResultRecord::from(result))) } - forge_domain::ContextMessage::Image(img) => Self::Image(ImageRecord::from(img)), + forge_domain::ContextMessage::ToolSearchOutput(tso) => { + Some(Self::ToolSearchOutput(tso.clone())) + } + forge_domain::ContextMessage::Image(img) => Some(Self::Image(ImageRecord::from(img))), } } } @@ -520,6 +537,7 @@ impl TryFrom for forge_domain::ContextMessage { ContextMessageValueRecord::Text(msg) => Self::Text(msg.try_into()?), ContextMessageValueRecord::Tool(result) => Self::Tool(result.try_into()?), ContextMessageValueRecord::Image(img) => Self::Image(img.into()), + ContextMessageValueRecord::ToolSearchOutput(tso) => Self::ToolSearchOutput(tso), }) } } @@ -561,12 +579,11 @@ impl<'de> Deserialize<'de> for ContextMessageRecord { } } -impl From<&forge_domain::MessageEntry> for ContextMessageRecord { - fn from(msg: &forge_domain::MessageEntry) -> Self { - Self { - message: ContextMessageValueRecord::from(&msg.message), - usage: msg.usage.as_ref().map(UsageRecord::from), - } +impl ContextMessageRecord { + /// Converts a domain MessageEntry to a record. + fn try_from_entry(msg: &forge_domain::MessageEntry) -> Option { + ContextMessageValueRecord::try_from_domain(&msg.message) + .map(|message| Self { message, usage: msg.usage.as_ref().map(UsageRecord::from) }) } } @@ -754,7 +771,7 @@ impl From<&Context> for ContextRecord { messages: context .messages .iter() - .map(ContextMessageRecord::from) + .filter_map(ContextMessageRecord::try_from_entry) .collect(), tools: context .tools @@ -819,6 +836,7 @@ impl TryFrom for Context { reasoning: record.reasoning.map(Into::into), stream: record.stream, response_format: None, + tool_search: None, }) } } diff --git a/crates/forge_repo/src/conversation/conversation_repo.rs b/crates/forge_repo/src/conversation/conversation_repo.rs index e5dfcee035..7fc7a0747a 100644 --- a/crates/forge_repo/src/conversation/conversation_repo.rs +++ b/crates/forge_repo/src/conversation/conversation_repo.rs @@ -698,12 +698,14 @@ mod tests { serde_json::json!({"param": "value"}), ), thought_signature: None, + namespace: None, }]), model: Some(forge_domain::ModelId::from("gpt-4")), thought_signature: None, reasoning_details: None, droppable: false, phase: None, + response_items: None, }), usage: Some(Usage { prompt_tokens: forge_domain::TokenCount::Actual(100), diff --git a/crates/forge_repo/src/provider/anthropic.rs b/crates/forge_repo/src/provider/anthropic.rs index 3292f5ab9f..82a92e9e0c 100644 --- a/crates/forge_repo/src/provider/anthropic.rs +++ b/crates/forge_repo/src/provider/anthropic.rs @@ -550,6 +550,7 @@ mod tests { call_id: Some(ToolCallId::new("math-1")), arguments: serde_json::json!({"expression": "2 + 2"}).into(), thought_signature: None, + namespace: None, }]), )) .add_tool_results(vec![ToolResult { diff --git a/crates/forge_repo/src/provider/bedrock.rs b/crates/forge_repo/src/provider/bedrock.rs index c5e9653167..ff8a7c787d 100644 --- a/crates/forge_repo/src/provider/bedrock.rs +++ b/crates/forge_repo/src/provider/bedrock.rs @@ -312,6 +312,7 @@ impl IntoDomain for aws_sdk_bedrockruntime::types::ConverseStreamOutput { name: None, arguments_part: tool_use.input, thought_signature: None, + namespace: None, }, ) } @@ -372,6 +373,7 @@ impl IntoDomain for aws_sdk_bedrockruntime::types::ConverseStreamOutput { name: Some(ToolName::new(tool_use.name)), arguments_part: String::new(), thought_signature: None, + namespace: None, }, ) } @@ -658,6 +660,10 @@ impl FromDomain> for aws_sdk_bedrockruntime::t content_blocks.push(ContentBlock::ToolResult(tool_result_block)); } + forge_domain::ContextMessage::ToolSearchOutput(_) => { + // Tool search output is OpenAI Responses API specific - skip for Bedrock + continue; + } _ => anyhow::bail!("Expected Tool message, got different message type"), } } @@ -813,6 +819,14 @@ impl FromDomain for aws_sdk_bedrockruntime::types: .build() .map_err(|e| anyhow::anyhow!("Failed to build image message: {}", e)) } + forge_domain::ContextMessage::ToolSearchOutput(_) => { + // Tool search output is OpenAI Responses API specific - skip for Bedrock + Message::builder() + .role(ConversationRole::User) + .set_content(Some(vec![])) + .build() + .map_err(|e| anyhow::anyhow!("Failed to build empty message: {}", e)) + } } } } @@ -1352,6 +1366,7 @@ mod tests { name: Some(ToolName::new("get_weather")), arguments_part: String::new(), thought_signature: None, + namespace: None, }); assert_eq!(actual, expected); @@ -1685,6 +1700,7 @@ mod tests { reasoning: None, stream: None, response_format: None, + tool_search: None, }; let actual = ConverseStreamInput::from_domain(fixture).unwrap(); @@ -1715,6 +1731,7 @@ mod tests { reasoning: None, stream: None, response_format: None, + tool_search: None, }; let actual = ConverseStreamInput::from_domain(fixture).unwrap(); @@ -1746,6 +1763,7 @@ mod tests { }), stream: None, response_format: None, + tool_search: None, }; let actual = ConverseStreamInput::from_domain(fixture).unwrap(); @@ -1780,6 +1798,7 @@ mod tests { }), stream: None, response_format: None, + tool_search: None, }; let actual = ConverseStreamInput::from_domain(fixture).unwrap(); diff --git a/crates/forge_repo/src/provider/bedrock_cache.rs b/crates/forge_repo/src/provider/bedrock_cache.rs index 571ea1e31f..439b612ad3 100644 --- a/crates/forge_repo/src/provider/bedrock_cache.rs +++ b/crates/forge_repo/src/provider/bedrock_cache.rs @@ -104,6 +104,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -132,6 +133,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -162,6 +164,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); diff --git a/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs b/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs index 13489ee039..bc4b14f624 100644 --- a/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs +++ b/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs @@ -119,6 +119,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -163,6 +164,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -213,6 +215,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -250,6 +253,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); diff --git a/crates/forge_repo/src/provider/google.rs b/crates/forge_repo/src/provider/google.rs index 390f1cd7f6..c06aa5dca4 100644 --- a/crates/forge_repo/src/provider/google.rs +++ b/crates/forge_repo/src/provider/google.rs @@ -387,6 +387,7 @@ mod tests { call_id: Some(ToolCallId::new("math-1")), arguments: serde_json::json!({"expression": "2 + 2"}).into(), thought_signature: None, + namespace: None, }]), )) .add_tool_results(vec![ToolResult { diff --git a/crates/forge_repo/src/provider/openai_responses/codex_transformer.rs b/crates/forge_repo/src/provider/openai_responses/codex_transformer.rs index b11e606d31..04f5297781 100644 --- a/crates/forge_repo/src/provider/openai_responses/codex_transformer.rs +++ b/crates/forge_repo/src/provider/openai_responses/codex_transformer.rs @@ -1,4 +1,4 @@ -use async_openai::types::responses::{self as oai, CreateResponse}; +use async_openai::types::responses::{self as oai, CreateResponse, FunctionTool, Tool}; use forge_domain::Transformer; /// Transformer that adjusts Responses API requests for the Codex backend. @@ -32,6 +32,98 @@ impl Transformer for CodexTransformer { } } +/// Transformer that sets defer_loading on MCP tools and injects tool_search. +/// +/// This transformer is designed for GPT 5.4 models that support deferred tool +/// loading. It: +/// - Sets `defer_loading: Some(true)` on MCP tools (names starting with "mcp_") +/// - Injects a `tool_search` tool at the beginning of the tools list when there +/// are deferred tools (required by the API) +pub struct SetDeferLoading; + +impl SetDeferLoading { + /// Determines if a tool name represents an MCP tool that should be + /// deferred. + fn is_mcp_tool(name: &str) -> bool { + name.starts_with("mcp_") + } + + /// Creates the hosted tool_search tool required when deferred tools are + /// present. + /// + /// Uses hosted (server-side) tool search so the API automatically searches + /// the deferred tools declared in the request and returns the matching + /// subset in the same response. No client-side search logic is needed. + fn create_tool_search_tool() -> Tool { + Tool::ToolSearch(oai::ToolSearchToolParam { + execution: None, + description: None, + parameters: None, + }) + } +} + +impl Transformer for SetDeferLoading { + type Value = CreateResponse; + + fn transform(&mut self, mut request: Self::Value) -> Self::Value { + // Check if there are tools to process + let Some(ref tools) = request.tools else { + return request; + }; + + if tools.is_empty() { + return request; + } + + // Track if any tools will be deferred + let mut has_deferred_tools = false; + + // Transform tools: set defer_loading for MCP tools + let transformed_tools: Vec = tools + .iter() + .filter(|tool| { + // Filter out tool_search if it exists (we'll add our own) + match tool { + Tool::ToolSearch(_) => false, + _ => true, + } + }) + .map(|tool| { + match tool { + Tool::Function(func_tool) => { + let should_defer = Self::is_mcp_tool(&func_tool.name); + if should_defer { + has_deferred_tools = true; + } + Tool::Function(FunctionTool { + name: func_tool.name.clone(), + parameters: func_tool.parameters.clone(), + strict: func_tool.strict, + description: func_tool.description.clone(), + defer_loading: Some(should_defer), + }) + } + // Pass through other tool types unchanged + _ => tool.clone(), + } + }) + .collect(); + + // If there are deferred tools, inject the tool_search tool at the beginning + if has_deferred_tools { + let mut new_tools = vec![Self::create_tool_search_tool()]; + new_tools.extend(transformed_tools); + request.tools = Some(new_tools); + } else { + // No deferred tools, just use the transformed tools + request.tools = Some(transformed_tools); + } + + request + } +} + #[cfg(test)] mod tests { use async_openai::types::responses as oai; @@ -155,4 +247,149 @@ mod tests { assert_eq!(actual.model.as_deref(), Some("gpt-5.1-codex")); assert_eq!(actual.stream, Some(true)); } + + // Tests for SetDeferLoading transformer + + fn create_test_function_tool(name: &str) -> Tool { + Tool::Function(FunctionTool { + name: name.to_string(), + parameters: Some(serde_json::json!({"type": "object"})), + strict: Some(true), + description: Some(format!("Test tool: {}", name)), + defer_loading: None, + }) + } + + #[test] + fn test_set_defer_loading_no_tools() { + let mut request = CreateResponse::default(); + request.tools = None; + + let mut transformer = SetDeferLoading; + let actual = transformer.transform(request); + + assert_eq!(actual.tools, None); + } + + #[test] + fn test_set_defer_loading_empty_tools() { + let mut request = CreateResponse::default(); + request.tools = Some(vec![]); + + let mut transformer = SetDeferLoading; + let actual = transformer.transform(request); + + assert_eq!(actual.tools, Some(vec![])); + } + + #[test] + fn test_set_defer_loading_only_builtin_tools() { + let mut request = CreateResponse::default(); + request.tools = Some(vec![ + create_test_function_tool("read"), + create_test_function_tool("write"), + create_test_function_tool("shell"), + ]); + + let mut transformer = SetDeferLoading; + let actual = transformer.transform(request); + + let tools = actual.tools.unwrap(); + assert_eq!(tools.len(), 3); + + // All built-in tools should have defer_loading: Some(false) + for tool in tools { + if let Tool::Function(func) = tool { + assert_eq!(func.defer_loading, Some(false)); + } + } + } + + #[test] + fn test_set_defer_loading_mcp_tools_get_deferred() { + let mut request = CreateResponse::default(); + request.tools = Some(vec![ + create_test_function_tool("read"), + create_test_function_tool("mcp_github"), + create_test_function_tool("write"), + create_test_function_tool("mcp_gitlab"), + ]); + + let mut transformer = SetDeferLoading; + let actual = transformer.transform(request); + + let tools = actual.tools.unwrap(); + + // Should have tool_search + 4 original tools = 5 total + assert_eq!(tools.len(), 5); + + // First tool should be tool_search + assert!(matches!(tools[0], Tool::ToolSearch(_))); + + // Check remaining tools + for tool in &tools[1..] { + if let Tool::Function(func) = tool { + if func.name.starts_with("mcp_") { + assert_eq!( + func.defer_loading, + Some(true), + "MCP tool {} should be deferred", + func.name + ); + } else { + assert_eq!( + func.defer_loading, + Some(false), + "Built-in tool {} should not be deferred", + func.name + ); + } + } + } + } + + #[test] + fn test_set_defer_loading_filters_existing_tool_search() { + let mut request = CreateResponse::default(); + let tool_search = SetDeferLoading::create_tool_search_tool(); + request.tools = Some(vec![tool_search, create_test_function_tool("mcp_github")]); + + let mut transformer = SetDeferLoading; + let actual = transformer.transform(request); + + let tools = actual.tools.unwrap(); + + // Should still have only 2 tools (tool_search + mcp_github) + // because existing tool_search is filtered and replaced + assert_eq!(tools.len(), 2); + + // First tool should be tool_search (the new one we created) + assert!(matches!(tools[0], Tool::ToolSearch(_))); + } + + #[test] + fn test_set_defer_loading_preserves_non_function_tools() { + // Create a request with only built-in tools (no MCP tools) + let mut request = CreateResponse::default(); + request.tools = Some(vec![ + create_test_function_tool("read"), + create_test_function_tool("write"), + ]); + + let mut transformer = SetDeferLoading; + let actual = transformer.transform(request); + + // Should have 2 tools (no tool_search since no MCP tools) + let tools = actual.tools.unwrap(); + assert_eq!(tools.len(), 2); + + // Both should be Function tools with defer_loading: Some(false) + for tool in tools { + if let Tool::Function(func) = tool { + assert_eq!(func.defer_loading, Some(false)); + } else { + panic!("Expected Function tool, got something else"); + } + } + } } diff --git a/crates/forge_repo/src/provider/openai_responses/repository.rs b/crates/forge_repo/src/provider/openai_responses/repository.rs index e311d13c20..8fb8d08b38 100644 --- a/crates/forge_repo/src/provider/openai_responses/repository.rs +++ b/crates/forge_repo/src/provider/openai_responses/repository.rs @@ -148,6 +148,7 @@ impl OpenAIResponsesProvider { model: &ModelId, context: ChatContext, ) -> ResultStream { + let tool_search_enabled = context.tool_search.unwrap_or(false); let conversation_id = context.conversation_id.as_ref().map(ToString::to_string); let headers = create_headers(self.get_headers_for_conversation(conversation_id.as_deref())); let mut request = oai::CreateResponse::from_domain(context)?; @@ -159,6 +160,13 @@ impl OpenAIResponsesProvider { request = super::codex_transformer::CodexTransformer.transform(request); } + // Apply deferred loading for GPT 5.4 models when tool_search is enabled. + let model_str = model.as_str(); + if tool_search_enabled && model_str.starts_with("gpt-5.4") { + use forge_domain::Transformer; + request = super::codex_transformer::SetDeferLoading.transform(request); + } + info!( url = %self.responses_url, base_url = %self.api_base, diff --git a/crates/forge_repo/src/provider/openai_responses/request.rs b/crates/forge_repo/src/provider/openai_responses/request.rs index 2ada99c894..55de2d0438 100644 --- a/crates/forge_repo/src/provider/openai_responses/request.rs +++ b/crates/forge_repo/src/provider/openai_responses/request.rs @@ -4,10 +4,75 @@ use anyhow::Context as _; use async_openai::types::responses as oai; use forge_app::domain::{Context as ChatContext, ContextMessage, MessagePhase, Role, ToolChoice}; use forge_app::utils::enforce_strict_schema; -use forge_domain::{Effort, ReasoningConfig, ReasoningFull}; +use forge_domain::{ + Effort, ReasoningConfig, ReasoningFull, ResponseOutputItem, ToolSearchExecution, + ToolSearchOutput, ToolSearchStatus, +}; use crate::provider::FromDomain; +/// Converts domain ToolSearchOutput to OpenAI ToolSearchOutputItemParam. +fn tool_search_output_to_oai( + output: &ToolSearchOutput, +) -> anyhow::Result { + let status = match output.status { + ToolSearchStatus::InProgress => oai::OutputStatus::InProgress, + ToolSearchStatus::Completed => oai::OutputStatus::Completed, + ToolSearchStatus::Incomplete => oai::OutputStatus::Incomplete, + }; + + let execution = match output.execution { + ToolSearchExecution::Server => oai::ToolSearchExecutionType::Server, + ToolSearchExecution::Client => oai::ToolSearchExecutionType::Client, + }; + + // Convert the JSON tools to oai::Tool + let tools: Vec = output + .tools + .iter() + .map(|tool_json| { + // Each tool should be a Function tool + serde_json::from_value(tool_json.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse tool: {}", e)) + }) + .collect::>>()?; + + Ok(oai::ToolSearchOutputItemParam { + call_id: output.call_id.as_ref().map(|id| id.as_str().to_string()), + execution: Some(execution), + status: Some(status), + tools, + id: None, + }) +} + +/// Emits tool_search_call (if present) followed by tool_search_output into +/// the `items` list. The API expects tool_search_call to precede +/// tool_search_output when replaying conversation history. +fn emit_tool_search_items( + items: &mut Vec, + output: &ToolSearchOutput, +) -> anyhow::Result<()> { + // Emit tool_search_call first (if captured from the API response) + if let Some(call_json) = &output.tool_search_call + && let Ok(mut call_param) = + serde_json::from_value::(call_json.clone()) + { + // The original API response has status "in_progress", but + // when replaying we need to send "completed". + call_param.status = Some(oai::OutputStatus::Completed); + items.push(oai::InputItem::Item(oai::Item::ToolSearchCall(call_param))); + } + + // Then emit tool_search_output + let tool_search_item = tool_search_output_to_oai(output)?; + items.push(oai::InputItem::Item(oai::Item::ToolSearchOutput( + tool_search_item, + ))); + + Ok(()) +} + /// Converts domain MessagePhase to OpenAI MessagePhase fn to_oai_phase(phase: MessagePhase) -> oai::MessagePhase { match phase { @@ -185,18 +250,22 @@ impl FromDomain for oai::CreateResponse { let mut instructions: Option = None; let mut items: Vec = Vec::new(); + let messages: Vec<_> = context.messages.into_iter().collect(); + // Track which ToolSearchOutput entries were already consumed inline + // with their preceding assistant message. + let consumed_tso_indices: std::collections::HashSet = Default::default(); - for entry in context.messages { - match entry.message { + for (idx, entry) in messages.iter().enumerate() { + match &entry.message { ContextMessage::Text(message) => match message.role { Role::System => { if instructions.is_none() { - instructions = Some(message.content); + instructions = Some(message.content.clone()); } else { items.push(oai::InputItem::EasyMessage(oai::EasyInputMessage { r#type: oai::MessageType::Message, role: oai::Role::Developer, - content: oai::EasyInputContent::Text(message.content), + content: oai::EasyInputContent::Text(message.content.clone()), phase: None, })); } @@ -205,45 +274,119 @@ impl FromDomain for oai::CreateResponse { items.push(oai::InputItem::EasyMessage(oai::EasyInputMessage { r#type: oai::MessageType::Message, role: oai::Role::User, - content: oai::EasyInputContent::Text(message.content), + content: oai::EasyInputContent::Text(message.content.clone()), phase: None, })); } Role::Assistant => { - if !message.content.trim().is_empty() { - items.push(oai::InputItem::EasyMessage(oai::EasyInputMessage { - r#type: oai::MessageType::Message, - role: oai::Role::Assistant, - content: oai::EasyInputContent::Text(message.content), - phase: message.phase.map(to_oai_phase), - })); - } + // When response_items is available (Responses API), emit + // items in their original stream order. This preserves the + // exact interleaving of reasoning, tool_search, and + // function_call items that the API requires. + if let Some(response_items) = &message.response_items { + let content = &message.content; + if !content.trim().is_empty() { + items.push(oai::InputItem::EasyMessage(oai::EasyInputMessage { + r#type: oai::MessageType::Message, + role: oai::Role::Assistant, + content: oai::EasyInputContent::Text(content.clone()), + phase: message.phase.map(to_oai_phase), + })); + } - if let Some(reasoning_details) = message.reasoning_details { - items.extend(map_reasoning_details_to_input_items(reasoning_details)); - } + for item in response_items { + match item { + ResponseOutputItem::Reasoning(detail) => { + items.extend(map_reasoning_details_to_input_items(vec![ + detail.clone(), + ])); + } + ResponseOutputItem::ToolSearchCall(json) => { + if let Ok(mut call) = + serde_json::from_value::( + json.clone(), + ) + { + call.status = Some(oai::OutputStatus::Completed); + items.push(oai::InputItem::Item( + oai::Item::ToolSearchCall(call), + )); + } + } + ResponseOutputItem::ToolSearchOutput(tso) => { + items.push(oai::InputItem::Item( + oai::Item::ToolSearchOutput(tool_search_output_to_oai( + tso, + )?), + )); + } + ResponseOutputItem::FunctionCall { + id, + call_id, + name, + arguments, + namespace, + } => { + items.push(oai::InputItem::Item(oai::Item::FunctionCall( + oai::FunctionToolCall { + arguments: arguments.clone().into_string(), + call_id: call_id.as_str().to_string(), + name: name.to_string(), + namespace: namespace.clone(), + id: Some(id.clone()), + status: Some(oai::OutputStatus::Completed), + }, + ))); + } + ResponseOutputItem::Text(_) => { + // Text content already handled above + // via + // message.content + } + } + } + } else { + // Fallback for non-Responses API providers (Anthropic, + // Bedrock, etc.): use the bundled fields. + let content = &message.content; + if !content.trim().is_empty() { + items.push(oai::InputItem::EasyMessage(oai::EasyInputMessage { + r#type: oai::MessageType::Message, + role: oai::Role::Assistant, + content: oai::EasyInputContent::Text(content.clone()), + phase: message.phase.map(to_oai_phase), + })); + } + + if let Some(reasoning_details) = &message.reasoning_details { + items.extend(map_reasoning_details_to_input_items( + reasoning_details.clone(), + )); + } - if let Some(tool_calls) = message.tool_calls { - for call in tool_calls { - let call_id = - call.call_id.as_ref().map(|id| id.as_str().to_string()).ok_or_else( - || { + if let Some(tool_calls) = &message.tool_calls { + for call in tool_calls { + let call_id = call + .call_id + .as_ref() + .map(|id| id.as_str().to_string()) + .ok_or_else(|| { anyhow::anyhow!( "Tool call is missing call_id; cannot be sent to Responses API" ) + })?; + + items.push(oai::InputItem::Item(oai::Item::FunctionCall( + oai::FunctionToolCall { + arguments: call.arguments.clone().into_string(), + call_id, + name: call.name.to_string(), + namespace: call.namespace.clone(), + id: None, + status: None, }, - )?; - - items.push(oai::InputItem::Item(oai::Item::FunctionCall( - oai::FunctionToolCall { - arguments: call.arguments.into_string(), - call_id, - name: call.name.to_string(), - namespace: None, - id: None, - status: None, - }, - ))); + ))); + } } } } @@ -287,6 +430,13 @@ impl FromDomain for oai::CreateResponse { phase: None, })); } + ContextMessage::ToolSearchOutput(output) => { + // Skip if already consumed inline with the preceding assistant message + if consumed_tso_indices.contains(&idx) { + continue; + } + emit_tool_search_items(&mut items, output)?; + } } } diff --git a/crates/forge_repo/src/provider/openai_responses/response.rs b/crates/forge_repo/src/provider/openai_responses/response.rs index 7177da713e..e5a175e478 100644 --- a/crates/forge_repo/src/provider/openai_responses/response.rs +++ b/crates/forge_repo/src/provider/openai_responses/response.rs @@ -5,7 +5,10 @@ use forge_app::domain::{ ChatCompletionMessage, Content, FinishReason, MessagePhase, TokenCount, ToolCall, ToolCallArguments, ToolCallFull, ToolCallId, ToolCallPart, ToolName, Usage, }; -use forge_domain::{BoxStream, ResultStream}; +use forge_domain::{ + BoxStream, ResponseOutputItem, ResultStream, ToolSearchExecution, ToolSearchOutput, + ToolSearchStatus, +}; use futures::StreamExt; use serde::{Deserialize, Deserializer}; @@ -123,6 +126,8 @@ impl IntoDomain for oai::Response { } let mut saw_tool_call = false; + let mut tool_search_call_json: Option = None; + let mut response_items: Vec = Vec::new(); for item in &self.output { match item { oai::OutputItem::Message(output_msg) => { @@ -138,7 +143,15 @@ impl IntoDomain for oai::Response { name: ToolName::new(call.name.clone()), arguments: ToolCallArguments::from_json(&call.arguments), thought_signature: None, + namespace: call.namespace.clone(), })); + response_items.push(forge_domain::ResponseOutputItem::FunctionCall { + id: call.id.clone().unwrap_or_default(), + call_id: ToolCallId::new(call.call_id.clone()), + name: ToolName::new(call.name.clone()), + arguments: ToolCallArguments::from_json(&call.arguments), + namespace: call.namespace.clone(), + }); } oai::OutputItem::Reasoning(reasoning) => { let mut all_reasoning_text = String::new(); @@ -204,11 +217,69 @@ impl IntoDomain for oai::Response { if !all_reasoning_text.is_empty() { message = message.reasoning(Content::full(all_reasoning_text)); } + + // Push a single ResponseOutputItem::Reasoning with all detail + // for this reasoning block (encrypted + text + summary merged) + let mut reasoning_full = forge_domain::ReasoningFull { + id: Some(reasoning.id.clone()), + ..Default::default() + }; + if let Some(encrypted_content) = &reasoning.encrypted_content { + reasoning_full.data = Some(encrypted_content.clone()); + reasoning_full.type_of = Some("reasoning.encrypted".to_string()); + } + response_items + .push(forge_domain::ResponseOutputItem::Reasoning(reasoning_full)); + } + // Tool search call: server-side search for deferred tools. + oai::OutputItem::ToolSearchCall(call) => { + let json = serde_json::to_value(call).ok(); + tool_search_call_json = json.clone(); + if let Some(json) = json { + response_items.push(forge_domain::ResponseOutputItem::ToolSearchCall(json)); + } + } + // Tool search output: results of the tool search with discovered tools + // This is emitted when the server executed the tool search (execution: "server") + oai::OutputItem::ToolSearchOutput(output) => { + // For server execution, call_id is null — preserve that. + // Only use a fallback call_id for client execution. + let call_id = output.call_id.clone().map(ToolCallId::new); + + let execution = match output.execution { + oai::ToolSearchExecutionType::Server => ToolSearchExecution::Server, + oai::ToolSearchExecutionType::Client => ToolSearchExecution::Client, + }; + + let tool_search_output = ToolSearchOutput { + call_id, + status: ToolSearchStatus::Completed, + execution, + tools: output + .tools + .iter() + .map(serde_json::to_value) + .collect::, _>>() + .unwrap_or_default(), + tool_search_call: tool_search_call_json.take(), + }; + + // Push to ordered response_items + response_items.push(ResponseOutputItem::ToolSearchOutput( + tool_search_output.clone(), + )); + + message = message.tool_search_output(tool_search_output); } _ => {} } } + // Set ordered response items for faithful replay in Responses API + if !response_items.is_empty() { + message = message.response_items(response_items); + } + if let Some(usage) = self.usage { message = message.usage(usage.into_domain()); } @@ -229,12 +300,18 @@ struct ToolCallIndex(u32); #[derive(Default)] struct CodexStreamState { output_index_to_tool_call: HashMap, + /// Maps output index to namespace for tool calls discovered via + /// tool_search. + output_index_to_namespace: HashMap, /// Tracks output indices that have received at least one arguments delta. /// When arguments are streamed via deltas, the `done` event should be /// skipped to avoid duplication. When no deltas are received (e.g. the /// Spark model sends arguments only in the `done` event), we must emit /// them from the `done` handler. received_toolcall_deltas: HashSet, + /// Raw JSON of the ToolSearchCall from the API, captured so it can be + /// replayed before ToolSearchOutput in the next request. + tool_search_call_json: Option, } /// Retains only reasoning details that carry `encrypted_content` data. @@ -275,24 +352,28 @@ impl IntoDomain for BoxStream { futures::future::ready({ let item = match item { Ok(StreamItem::Message(msg)) => Some(Ok(*msg)), - Ok(StreamItem::Event(event)) => match *event { - oai::ResponseStreamEvent::ResponseOutputTextDelta(delta) => Some(Ok( - ChatCompletionMessage::assistant(Content::part(delta.delta)), - )), - oai::ResponseStreamEvent::ResponseReasoningTextDelta(delta) => { - Some(Ok(ChatCompletionMessage::default() - .reasoning(Content::part(delta.delta.clone())) - .add_reasoning_detail(forge_domain::Reasoning::Part(vec![ - forge_domain::ReasoningPart { - text: Some(delta.delta), - id: Some(delta.item_id), - type_of: Some("reasoning.text".to_string()), - ..Default::default() - }, - ])))) - } - oai::ResponseStreamEvent::ResponseReasoningSummaryTextDelta(delta) => { - Some(Ok(ChatCompletionMessage::default() + Ok(StreamItem::Event(event)) => { + match *event { + oai::ResponseStreamEvent::ResponseOutputTextDelta(delta) => { + Some(Ok(ChatCompletionMessage::assistant(Content::part( + delta.delta, + )))) + } + oai::ResponseStreamEvent::ResponseReasoningTextDelta(delta) => { + Some(Ok(ChatCompletionMessage::default() + .reasoning(Content::part(delta.delta.clone())) + .add_reasoning_detail(forge_domain::Reasoning::Part( + vec![forge_domain::ReasoningPart { + text: Some(delta.delta), + id: Some(delta.item_id), + type_of: Some("reasoning.text".to_string()), + ..Default::default() + }], + )))) + } + oai::ResponseStreamEvent::ResponseReasoningSummaryTextDelta( + delta, + ) => Some(Ok(ChatCompletionMessage::default() .reasoning(Content::part(delta.delta.clone())) .add_reasoning_detail(forge_domain::Reasoning::Part(vec![ forge_domain::ReasoningPart { @@ -301,152 +382,233 @@ impl IntoDomain for BoxStream { type_of: Some("reasoning.summary".to_string()), ..Default::default() }, - ])))) - } - oai::ResponseStreamEvent::ResponseOutputItemAdded(added) => { - match &added.item { - oai::OutputItem::FunctionCall(call) => { - let tool_call_id = ToolCallId::new(call.call_id.clone()); - let tool_name = ToolName::new(call.name.clone()); - - state.output_index_to_tool_call.insert( - added.output_index.into(), - (tool_call_id.clone(), tool_name.clone()), - ); - - // Only emit if we have non-empty initial arguments. - // Otherwise, wait for deltas or done event. - if !call.arguments.is_empty() { - Some(Ok(ChatCompletionMessage::default() - .add_tool_call(ToolCall::Part(ToolCallPart { - call_id: Some(tool_call_id), - name: Some(tool_name), - arguments_part: call.arguments.clone(), - thought_signature: None, - })))) - } else { + ])))), + oai::ResponseStreamEvent::ResponseOutputItemAdded(added) => { + match &added.item { + oai::OutputItem::FunctionCall(call) => { + let tool_call_id = + ToolCallId::new(call.call_id.clone()); + let tool_name = ToolName::new(call.name.clone()); + + state.output_index_to_tool_call.insert( + added.output_index.into(), + (tool_call_id.clone(), tool_name.clone()), + ); + + // Store namespace for this tool call so it + // can be replayed in subsequent requests. + if let Some(ns) = &call.namespace { + state + .output_index_to_namespace + .insert(added.output_index.into(), ns.clone()); + } + + // Only emit if we have non-empty initial arguments. + // Otherwise, wait for deltas or done event. + if !call.arguments.is_empty() { + Some(Ok(ChatCompletionMessage::default() + .add_tool_call(ToolCall::Part(ToolCallPart { + call_id: Some(tool_call_id), + name: Some(tool_name), + arguments_part: call.arguments.clone(), + thought_signature: None, + namespace: call.namespace.clone(), + })))) + } else { + None + } + } + oai::OutputItem::Reasoning(_reasoning) => { + // Reasoning items don't emit content in real-time, only + // at + // completion None } + // Tool search call: server-side mechanism, not a tool + // call for the orch. Capture the raw JSON to replay + // in subsequent requests. + oai::OutputItem::ToolSearchCall(call) => { + state.tool_search_call_json = + serde_json::to_value(call).ok(); + None + } + // Tool search output: results of the tool search with + // discovered tools + // This is emitted when the server executed the tool search + // (execution: "server") + oai::OutputItem::ToolSearchOutput(output) => { + // For server execution, call_id is null — preserve + // that. + let call_id = + output.call_id.clone().map(ToolCallId::new); + + let execution = match output.execution { + oai::ToolSearchExecutionType::Server => { + ToolSearchExecution::Server + } + oai::ToolSearchExecutionType::Client => { + ToolSearchExecution::Client + } + }; + + // Always use Completed status - the OutputItemAdded + // event has + // InProgress, but by the time we replay this in the + // next request, the search is done. + let tool_search_output = ToolSearchOutput { + call_id, + status: ToolSearchStatus::Completed, + execution, + tools: output + .tools + .iter() + .map(serde_json::to_value) + .collect::, _>>() + .unwrap_or_default(), + tool_search_call: state + .tool_search_call_json + .take(), + }; + + Some(Ok(ChatCompletionMessage::default() + .tool_search_output(tool_search_output))) + } + _ => None, } - oai::OutputItem::Reasoning(_reasoning) => { - // Reasoning items don't emit content in real-time, only at - // completion - None - } - _ => None, } - } - oai::ResponseStreamEvent::ResponseFunctionCallArgumentsDelta(delta) => { - state - .received_toolcall_deltas - .insert(delta.output_index.into()); - let (call_id, name) = state - .output_index_to_tool_call - .get(&(delta.output_index.into())) - .cloned() - .unwrap_or_else(|| { - ( - ToolCallId::new(format!( - "output_{}", - delta.output_index - )), - ToolName::new(""), - ) - }); - - let name = (!name.as_str().is_empty()).then_some(name); - - Some(Ok(ChatCompletionMessage::default().add_tool_call( - ToolCall::Part(ToolCallPart { - call_id: Some(call_id), - name, - arguments_part: delta.delta, - thought_signature: None, - }), - ))) - } - oai::ResponseStreamEvent::ResponseFunctionCallArgumentsDone(done) => { - // If deltas were already streamed for this output index, - // the arguments have already been emitted incrementally. - if state - .received_toolcall_deltas - .contains(&(done.output_index.into())) - { - None - } else { - // No deltas were received (e.g. the Spark model sends - // the complete arguments only in the `done` event). - // Emit the full tool call now. + oai::ResponseStreamEvent::ResponseFunctionCallArgumentsDelta( + delta, + ) => { + state + .received_toolcall_deltas + .insert(delta.output_index.into()); let (call_id, name) = state .output_index_to_tool_call - .get(&(done.output_index.into())) + .get(&(delta.output_index.into())) .cloned() .unwrap_or_else(|| { ( ToolCallId::new(format!( "output_{}", - done.output_index + delta.output_index )), - ToolName::new( - done.name.clone().unwrap_or_default(), - ), + ToolName::new(""), ) }); let name = (!name.as_str().is_empty()).then_some(name); + let namespace = state + .output_index_to_namespace + .get(&(delta.output_index.into())) + .cloned(); + Some(Ok(ChatCompletionMessage::default().add_tool_call( ToolCall::Part(ToolCallPart { call_id: Some(call_id), name, - arguments_part: done.arguments, + arguments_part: delta.delta, thought_signature: None, + namespace, }), ))) } + oai::ResponseStreamEvent::ResponseFunctionCallArgumentsDone( + done, + ) => { + // If deltas were already streamed for this output index, + // the arguments have already been emitted incrementally. + if state + .received_toolcall_deltas + .contains(&(done.output_index.into())) + { + None + } else { + // No deltas were received (e.g. the Spark model sends + // the complete arguments only in the `done` event). + // Emit the full tool call now. + let (call_id, name) = state + .output_index_to_tool_call + .get(&(done.output_index.into())) + .cloned() + .unwrap_or_else(|| { + ( + ToolCallId::new(format!( + "output_{}", + done.output_index + )), + ToolName::new( + done.name.clone().unwrap_or_default(), + ), + ) + }); + + let name = (!name.as_str().is_empty()).then_some(name); + + let namespace = state + .output_index_to_namespace + .get(&(done.output_index.into())) + .cloned(); + + Some(Ok(ChatCompletionMessage::default().add_tool_call( + ToolCall::Part(ToolCallPart { + call_id: Some(call_id), + name, + arguments_part: done.arguments, + thought_signature: None, + namespace, + }), + ))) + } + } + oai::ResponseStreamEvent::ResponseCompleted(done) => { + // Text content, reasoning, and tool calls were already streamed + // via delta events Only + // emit metadata + // (usage, finish_reason) + let mut message: ChatCompletionMessage = + done.response.into_domain(); + message.content = None; // Clear content to avoid duplication + message.reasoning = None; // Clear reasoning to avoid duplication + // Keep only encrypted-content reasoning details — text and + // summary were already streamed via deltas but + // encrypted_content is never streamed and must be preserved + // for multi-turn reasoning replay. + message.reasoning_details = retain_encrypted_reasoning_details( + message.reasoning_details, + ); + message.tool_calls.clear(); // Clear tool calls to avoid duplication + message.tool_search_output = None; // Already streamed via OutputItemAdded + Some(Ok(message)) + } + oai::ResponseStreamEvent::ResponseIncomplete(done) => { + // Text content, reasoning, and tool calls were already streamed + // via delta events + let mut message: ChatCompletionMessage = + done.response.into_domain(); + message.content = None; // Clear content to avoid duplication + message.reasoning = None; // Clear reasoning to avoid duplication + // Keep only encrypted-content reasoning details (see above). + message.reasoning_details = retain_encrypted_reasoning_details( + message.reasoning_details, + ); + message.tool_calls.clear(); // Clear tool calls to avoid duplication + message.tool_search_output = None; // Already streamed via OutputItemAdded + message = message.finish_reason_opt(Some(FinishReason::Length)); + Some(Ok(message)) + } + oai::ResponseStreamEvent::ResponseFailed(failed) => { + Some(Err(anyhow::anyhow!( + "Upstream response failed: {:?}", + failed.response.error + ))) + } + oai::ResponseStreamEvent::ResponseError(err) => { + Some(Err(anyhow::anyhow!("Upstream error: {}", err.message))) + } + _ => None, } - oai::ResponseStreamEvent::ResponseCompleted(done) => { - // Text content, reasoning, and tool calls were already streamed via - // delta events Only emit metadata - // (usage, finish_reason) - let mut message: ChatCompletionMessage = - done.response.into_domain(); - message.content = None; // Clear content to avoid duplication - message.reasoning = None; // Clear reasoning to avoid duplication - // Keep only encrypted-content reasoning details — text and - // summary were already streamed via deltas but - // encrypted_content is never streamed and must be preserved - // for multi-turn reasoning replay. - message.reasoning_details = - retain_encrypted_reasoning_details(message.reasoning_details); - message.tool_calls.clear(); // Clear tool calls to avoid duplication - Some(Ok(message)) - } - oai::ResponseStreamEvent::ResponseIncomplete(done) => { - // Text content, reasoning, and tool calls were already streamed via - // delta events - let mut message: ChatCompletionMessage = - done.response.into_domain(); - message.content = None; // Clear content to avoid duplication - message.reasoning = None; // Clear reasoning to avoid duplication - // Keep only encrypted-content reasoning details (see above). - message.reasoning_details = - retain_encrypted_reasoning_details(message.reasoning_details); - message.tool_calls.clear(); // Clear tool calls to avoid duplication - message = message.finish_reason_opt(Some(FinishReason::Length)); - Some(Ok(message)) - } - oai::ResponseStreamEvent::ResponseFailed(failed) => { - Some(Err(anyhow::anyhow!( - "Upstream response failed: {:?}", - failed.response.error - ))) - } - oai::ResponseStreamEvent::ResponseError(err) => { - Some(Err(anyhow::anyhow!("Upstream error: {}", err.message))) - } - _ => None, - }, + } Err(err) => Some(Err(err)), }; diff --git a/forge.schema.json b/forge.schema.json index f925ff6a5f..ed5e15599e 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -292,6 +292,11 @@ } ] }, + "tool_search": { + "description": "Whether server-side tool search is enabled for models that support\ndeferred tool loading (e.g. GPT-5.4). When enabled, MCP tools are\nsent with `defer_loading: true` and a `tool_search` tool is injected\nso the API can discover them on demand. Defaults to `false`; set to\n`true` to enable.", + "type": "boolean", + "default": false + }, "tool_supported": { "description": "Whether tool use is supported in the current environment; when false,\nall tool calls are disabled.", "type": "boolean",