From 246deade5a8abc6ddac8dfa78ac146b86520d987 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Sun, 5 Apr 2026 16:11:37 +0530 Subject: [PATCH 1/9] feat(tool_search): add deferred tool loading support for GPT-5.4 --- crates/forge_app/src/app.rs | 3 +- crates/forge_app/src/dto/anthropic/request.rs | 4 + .../forge_app/src/dto/anthropic/response.rs | 2 + .../transforms/auth_system_message.rs | 1 + .../src/dto/anthropic/transforms/set_cache.rs | 2 + crates/forge_app/src/dto/google/request.rs | 7 + crates/forge_app/src/dto/google/response.rs | 1 + crates/forge_app/src/dto/openai/request.rs | 16 ++ crates/forge_app/src/dto/openai/response.rs | 2 + .../dto/openai/transformers/drop_tool_call.rs | 2 + .../src/dto/openai/transformers/set_cache.rs | 1 + crates/forge_app/src/hooks/doom_loop.rs | 4 + crates/forge_app/src/hooks/tracing.rs | 3 + crates/forge_app/src/orch.rs | 19 +- crates/forge_app/src/user_prompt.rs | 3 + crates/forge_config/.forge.toml | 1 + crates/forge_config/src/config.rs | 8 + crates/forge_domain/src/compact/summary.rs | 10 + crates/forge_domain/src/context.rs | 96 +++++++- crates/forge_domain/src/conversation_html.rs | 96 +++++++- .../forge_domain/src/conversation_style.css | 22 ++ crates/forge_domain/src/hook.rs | 2 + crates/forge_domain/src/lib.rs | 4 + crates/forge_domain/src/message.rs | 17 +- crates/forge_domain/src/message_pattern.rs | 1 + crates/forge_domain/src/response_item.rs | 33 +++ crates/forge_domain/src/result_stream_ext.rs | 52 ++++ ...onversation_html__tests__conversation.snap | 1 + ...sation_html__tests__conversation.snap.html | 22 ++ crates/forge_domain/src/tool_search.rs | 71 ++++++ crates/forge_domain/src/tools/call/parser.rs | 5 + .../forge_domain/src/tools/call/tool_call.rs | 43 ++++ crates/forge_domain/src/tools/catalog.rs | 16 +- crates/forge_domain/src/transformer/mod.rs | 1 + .../src/transformer/normalize_tool_args.rs | 4 + .../src/transformer/transform_tool_calls.rs | 2 + crates/forge_main/src/info.rs | 2 + crates/forge_main/src/ui.rs | 7 +- .../src/conversation/conversation_record.rs | 43 +++- .../src/conversation/conversation_repo.rs | 2 + crates/forge_repo/src/provider/anthropic.rs | 1 + crates/forge_repo/src/provider/bedrock.rs | 19 ++ .../forge_repo/src/provider/bedrock_cache.rs | 3 + .../src/provider/bedrock_sanitize_ids.rs | 4 + crates/forge_repo/src/provider/google.rs | 1 + .../openai_responses/codex_transformer.rs | 231 +++++++++++++++++- .../provider/openai_responses/repository.rs | 8 + .../src/provider/openai_responses/request.rs | 218 ++++++++++++++--- .../src/provider/openai_responses/response.rs | 142 ++++++++++- forge.schema.json | 5 + 50 files changed, 1197 insertions(+), 66 deletions(-) create mode 100644 crates/forge_domain/src/response_item.rs create mode 100644 crates/forge_domain/src/tool_search.rs diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 169a002f03..ab49c7e60e 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -161,7 +161,8 @@ impl ForgeApp { .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(forge_config.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 da5b4aa9f1..a7546a8d3c 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..4bafdcc550 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..f617eb6c18 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..76ed93be80 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..3dbf6a65e9 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..0ed64b209b 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 e744705afb..fb9d4f025c 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -25,6 +25,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, } impl Orchestrator { @@ -44,6 +48,7 @@ impl Orchestrator { models: Default::default(), error_tracker: Default::default(), hook: Arc::new(Hook::default()), + tool_search: Default::default(), } } @@ -153,10 +158,13 @@ impl Orchestrator { 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()) @@ -268,8 +276,11 @@ impl Orchestrator { // 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 @@ -314,6 +325,8 @@ impl Orchestrator { 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 371087419c..5510b92183 100644 --- a/crates/forge_domain/src/compact/summary.rs +++ b/crates/forge_domain/src/compact/summary.rs @@ -274,6 +274,9 @@ impl From<&Context> for ContextSummary { } } ContextMessage::Image(_) => {} + ContextMessage::ToolSearchOutput(_) => { + // Tool search output is not included in the summary + } } } @@ -895,6 +898,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, &[]); @@ -987,6 +991,7 @@ mod tests { r#"{"path": "/test", "pattern": "pattern"}"#, ), thought_signature: None, + namespace: None, }], )]); @@ -1464,6 +1469,7 @@ mod tests { r#"{"title": "Bug report", "body": "Description"}"#, ), thought_signature: None, + namespace: None, }], )]); @@ -1493,6 +1499,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), @@ -1523,6 +1530,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"), @@ -1531,6 +1539,7 @@ mod tests { r##"{"channel": "#dev", "text": "Hello"}"##, ), thought_signature: None, + namespace: None, }, ], )]); @@ -1566,6 +1575,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 821bba586e..a93acee819 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,14 @@ 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 +360,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 +381,7 @@ impl TextMessage { reasoning_details: None, droppable: false, phase: None, + response_items: None, } } @@ -356,6 +404,7 @@ impl TextMessage { model, droppable: false, phase: None, + response_items: None, } } } @@ -431,6 +480,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 enabled). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_search: Option, } impl Context { @@ -570,6 +625,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 +643,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 +665,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 +677,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 @@ -1316,12 +1396,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, }, ]), )) @@ -1437,12 +1519,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 = @@ -1677,6 +1761,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..527003eb9f 100644 --- a/crates/forge_domain/src/conversation_html.rs +++ b/crates/forge_domain/src/conversation_html.rs @@ -209,6 +209,8 @@ fn create_info_table(conversation: &Conversation) -> Element { table = table.append(create_table_row("Cost", format!("${:.4}", cost))); } } + + } section.append(table) @@ -361,6 +363,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") @@ -369,7 +427,7 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { // Add tool calls if any - if let Some(tool_calls) = &content_message.tool_calls { + let message_elm = if let Some(tool_calls) = &content_message.tool_calls { if !tool_calls.is_empty() { message_elm.append(Element::new("div").append( tool_calls.iter().map(|tool_call| { @@ -403,7 +461,9 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { } } else { message_elm - } + }; + + message_elm } ContextMessage::Tool(tool_result) => { // Tool Message - apply error styling if the tool result is an error @@ -437,7 +497,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 +518,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..aac31e30bd 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 13a6b18135..4dc1ea7866 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; @@ -47,6 +48,7 @@ mod template; mod tools; mod tool_order; +mod tool_search; mod top_k; mod top_p; mod transformer; @@ -90,6 +92,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::*; @@ -101,6 +104,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 fd5cecdbb8..515bfa0292 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. /// @@ -64,6 +65,13 @@ 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 { @@ -184,7 +192,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, @@ -196,6 +204,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 f2597e878a..eeab5d32b8 100644 --- a/crates/forge_domain/src/result_stream_ext.rs +++ b/crates/forge_domain/src/result_stream_ext.rs @@ -248,11 +248,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..4c244b9eb0 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..e06184f402 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 d17aefa00b..ac21b83e4e 100644 --- a/crates/forge_domain/src/tools/catalog.rs +++ b/crates/forge_domain/src/tools/catalog.rs @@ -1141,7 +1141,7 @@ 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 } } } @@ -1200,6 +1200,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 @@ -1231,6 +1232,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); @@ -1261,6 +1263,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); @@ -1291,6 +1294,7 @@ mod tests { r#"{"path": "/test/path.rs", "content": "test content"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1318,6 +1322,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); @@ -1342,6 +1347,7 @@ mod tests { r#"{"path": "/test/path.rs", "content": "test"}"#, ), thought_signature: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1500,6 +1506,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); @@ -1528,6 +1535,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); @@ -1556,6 +1564,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); @@ -1584,6 +1593,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); @@ -1614,6 +1624,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); @@ -1641,6 +1652,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); @@ -1816,6 +1828,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); @@ -1835,6 +1848,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..0111e1215a 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..4547e63efd 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..67f23f3e9e 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_main/src/info.rs b/crates/forge_main/src/info.rs index b0815a8799..a260cb68e5 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -750,6 +750,8 @@ impl From<&Conversation> for Info { info = info.extend(usage); } + + info } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 3d6b946bac..f4f044bbcb 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3182,7 +3182,8 @@ impl A + Send + Sync> UI { return Ok(()); } match message { - ChatResponse::TaskMessage { content } => match content { + ChatResponse::TaskMessage { content } => { + match content { ChatResponseContent::ToolInput(title) => { writer.finish()?; self.writeln(title.display())?; @@ -3194,7 +3195,7 @@ impl A + Send + Sync> UI { ChatResponseContent::Markdown { text, partial: _ } => { writer.write(&text)?; } - }, + }}, ChatResponse::ToolCallStart { tool_call, notifier } => { // Scope guard to ensure notification happens even on error. // If writer.finish() or spinner.stop() fails, the guard's drop @@ -3354,6 +3355,8 @@ impl A + Send + Sync> UI { context.assistant_message_count().to_string(), ) .add_key_value("Tool Calls", context.tool_call_count().to_string()); + + } // Add token usage if available diff --git a/crates/forge_repo/src/conversation/conversation_record.rs b/crates/forge_repo/src/conversation/conversation_record.rs index 965f93d5f8..4998f60c60 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,26 @@ 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, returning None + /// for variants that are not persisted (e.g. ToolSearchOutput). + 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::ToolSearchOutput(tso) => { + Some(Self::ToolSearchOutput(tso.clone())) + } + forge_domain::ContextMessage::Image(img) => { + Some(Self::Image(ImageRecord::from(img))) } - forge_domain::ContextMessage::Image(img) => Self::Image(ImageRecord::from(img)), } } } @@ -520,6 +538,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 +580,13 @@ 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), +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 +774,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 +839,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..ccd4307b14 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 c4df9cfc26..2a8b907675 100644 --- a/crates/forge_repo/src/provider/anthropic.rs +++ b/crates/forge_repo/src/provider/anthropic.rs @@ -474,6 +474,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 abb871bd56..3d4efec4f3 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 daec9556e5..0525ade631 100644 --- a/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs +++ b/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs @@ -129,6 +129,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -173,6 +174,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -223,6 +225,7 @@ mod tests { stream: None, response_format: None, initiator: None, + tool_search: None, }; let request = ConverseStreamInput::from_domain(context).expect("Failed to convert context"); @@ -260,6 +263,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 e8af9a533a..a0f7baf3d4 100644 --- a/crates/forge_repo/src/provider/google.rs +++ b/crates/forge_repo/src/provider/google.rs @@ -390,6 +390,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..2928b8e7a8 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,97 @@ 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 +246,142 @@ 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 8298a79be6..ecc6869d54 100644 --- a/crates/forge_repo/src/provider/openai_responses/repository.rs +++ b/crates/forge_repo/src/provider/openai_responses/repository.rs @@ -149,6 +149,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)?; @@ -160,6 +161,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..6ab86fdc48 100644 --- a/crates/forge_repo/src/provider/openai_responses/request.rs +++ b/crates/forge_repo/src/provider/openai_responses/request.rs @@ -4,10 +4,71 @@ 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 { + if 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 +246,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 +270,123 @@ 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 900ffd0a5d..04bf97955c 100644 --- a/crates/forge_repo/src/provider/openai_responses/response.rs +++ b/crates/forge_repo/src/provider/openai_responses/response.rs @@ -5,7 +5,7 @@ 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}; @@ -119,6 +119,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) => { @@ -134,7 +136,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(); @@ -200,11 +210,68 @@ 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(|tool| serde_json::to_value(tool)) + .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()); } @@ -225,12 +292,17 @@ 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. @@ -271,7 +343,8 @@ impl IntoDomain for BoxStream { futures::future::ready({ let item = match item { Ok(StreamItem::Message(msg)) => Some(Ok(*msg)), - Ok(StreamItem::Event(event)) => match *event { + Ok(StreamItem::Event(event)) => { + match *event { oai::ResponseStreamEvent::ResponseOutputTextDelta(delta) => Some(Ok( ChatCompletionMessage::assistant(Content::part(delta.delta)), )), @@ -310,6 +383,15 @@ impl IntoDomain for BoxStream { (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() { @@ -319,6 +401,7 @@ impl IntoDomain for BoxStream { name: Some(tool_name), arguments_part: call.arguments.clone(), thought_signature: None, + namespace: call.namespace.clone(), })))) } else { None @@ -329,6 +412,45 @@ impl IntoDomain for BoxStream { // 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(|tool| serde_json::to_value(tool)) + .collect::, _>>() + .unwrap_or_default(), + tool_search_call: state.tool_search_call_json.take(), + }; + + Some(Ok( + ChatCompletionMessage::default() + .tool_search_output(tool_search_output) + )) + } _ => None, } } @@ -352,12 +474,18 @@ impl IntoDomain for BoxStream { 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: delta.delta, thought_signature: None, + namespace, }), ))) } @@ -391,12 +519,18 @@ impl IntoDomain for BoxStream { 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, }), ))) } @@ -416,6 +550,7 @@ impl IntoDomain for BoxStream { 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) => { @@ -429,6 +564,7 @@ impl IntoDomain for BoxStream { 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)) } @@ -442,7 +578,7 @@ impl IntoDomain for BoxStream { 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..5040559115 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -292,6 +292,11 @@ } ] }, + "tool_search": { + "description": "Whether the tool_search API is enabled for deferred tool loading. When true, MCP tools are sent with defer_loading and discovered on demand via the Responses API tool_search mechanism. Defaults to false; must be explicitly enabled.", + "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", From a35ba606bf5f5ab7d2ded79c77ca132b8d67db4a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:46:44 +0000 Subject: [PATCH 2/9] [autofix.ci] apply automated fixes --- crates/forge_app/src/dto/google/request.rs | 6 +- crates/forge_app/src/dto/openai/request.rs | 2 +- .../dto/openai/transformers/drop_tool_call.rs | 2 +- crates/forge_app/src/hooks/doom_loop.rs | 8 +- crates/forge_app/src/hooks/tracing.rs | 4 +- crates/forge_domain/src/compact/summary.rs | 14 +- crates/forge_domain/src/context.rs | 11 +- crates/forge_domain/src/conversation_html.rs | 10 +- crates/forge_domain/src/hook.rs | 2 +- crates/forge_domain/src/message.rs | 5 +- crates/forge_domain/src/result_stream_ext.rs | 40 +- crates/forge_domain/src/tool_search.rs | 7 +- crates/forge_domain/src/tools/call/parser.rs | 10 +- .../forge_domain/src/tools/call/tool_call.rs | 44 +- crates/forge_domain/src/tools/catalog.rs | 36 +- crates/forge_domain/src/transformer/mod.rs | 2 +- .../src/transformer/normalize_tool_args.rs | 8 +- .../src/transformer/transform_tool_calls.rs | 2 +- crates/forge_main/src/info.rs | 2 - crates/forge_main/src/ui.rs | 7 +- .../src/conversation/conversation_record.rs | 10 +- .../src/conversation/conversation_repo.rs | 2 +- .../openai_responses/codex_transformer.rs | 30 +- .../src/provider/openai_responses/request.rs | 68 +-- .../src/provider/openai_responses/response.rs | 416 ++++++++++-------- 25 files changed, 390 insertions(+), 358 deletions(-) diff --git a/crates/forge_app/src/dto/google/request.rs b/crates/forge_app/src/dto/google/request.rs index 4bafdcc550..38993fba1d 100644 --- a/crates/forge_app/src/dto/google/request.rs +++ b/crates/forge_app/src/dto/google/request.rs @@ -580,7 +580,7 @@ mod tests { r#"{"file_path":"test.rs","old_string":"foo","new_string":"bar"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; // Convert to Google Part @@ -620,14 +620,14 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_json(r#"{"path":"file1.rs"}"#), thought_signature: None, - namespace: None, + namespace: None, }, ToolCallFull { name: ToolName::new("remove"), call_id: None, arguments: ToolCallArguments::from_json(r#"{"path":"file2.rs"}"#), thought_signature: None, - namespace: None, + namespace: None, }, ]; diff --git a/crates/forge_app/src/dto/openai/request.rs b/crates/forge_app/src/dto/openai/request.rs index f617eb6c18..bbd4bba748 100644 --- a/crates/forge_app/src/dto/openai/request.rs +++ b/crates/forge_app/src/dto/openai/request.rs @@ -756,7 +756,7 @@ mod tests { name: ToolName::new("test_tool"), arguments: serde_json::json!({"key": "value"}).into(), thought_signature: None, - namespace: None, + namespace: None, }; let assistant_message = ContextMessage::Text( 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 76ed93be80..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,7 +47,7 @@ mod tests { name: ToolName::new("test_tool"), arguments: serde_json::json!({"key": "value"}).into(), thought_signature: None, - namespace: None, + namespace: None, }; let tool_result = ToolResult::new(ToolName::new("test_tool")) diff --git a/crates/forge_app/src/hooks/doom_loop.rs b/crates/forge_app/src/hooks/doom_loop.rs index 3dbf6a65e9..73df95a406 100644 --- a/crates/forge_app/src/hooks/doom_loop.rs +++ b/crates/forge_app/src/hooks/doom_loop.rs @@ -269,7 +269,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, - response_items: None, + response_items: None, } } @@ -406,7 +406,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, - response_items: None, + response_items: None, }; let user_msg = TextMessage { @@ -419,7 +419,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, - response_items: None, + response_items: None, }; let assistant_msg_2 = TextMessage { @@ -432,7 +432,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, - response_items: 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 0ed64b209b..8fb51b99b2 100644 --- a/crates/forge_app/src/hooks/tracing.rs +++ b/crates/forge_app/src/hooks/tracing.rs @@ -206,7 +206,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; let event = EventData::new(test_agent(), test_model_id(), ResponsePayload::new(message)); @@ -223,7 +223,7 @@ mod tests { call_id: Some(ToolCallId::new("test-id")), arguments: serde_json::json!({"key": "value"}).into(), thought_signature: None, - namespace: None, + namespace: None, }; let result = ToolResult::new(ToolName::from("test-tool")) .call_id(ToolCallId::new("test-id")) diff --git a/crates/forge_domain/src/compact/summary.rs b/crates/forge_domain/src/compact/summary.rs index 5510b92183..a002a984aa 100644 --- a/crates/forge_domain/src/compact/summary.rs +++ b/crates/forge_domain/src/compact/summary.rs @@ -898,7 +898,7 @@ mod tests { call_id: Some(ToolCallId::new("call_1")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug report"}"#), thought_signature: None, - namespace: None, + namespace: None, }; let actual = extract_tool_info(&fixture, &[]); @@ -991,7 +991,7 @@ mod tests { r#"{"path": "/test", "pattern": "pattern"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }], )]); @@ -1469,7 +1469,7 @@ mod tests { r#"{"title": "Bug report", "body": "Description"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }], )]); @@ -1499,7 +1499,7 @@ mod tests { call_id: Some(ToolCallId::new("call_1")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug"}"#), thought_signature: None, - namespace: None, + namespace: None, }], ), tool_result("mcp_github_create_issue", "call_1", false), @@ -1530,7 +1530,7 @@ mod tests { call_id: Some(ToolCallId::new("call_1")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug"}"#), thought_signature: None, - namespace: None, + namespace: None, }, ToolCallFull { name: ToolName::new("mcp_slack_post_message"), @@ -1539,7 +1539,7 @@ mod tests { r##"{"channel": "#dev", "text": "Hello"}"##, ), thought_signature: None, - namespace: None, + namespace: None, }, ], )]); @@ -1575,7 +1575,7 @@ mod tests { call_id: Some(ToolCallId::new("call_2")), arguments: ToolCallArguments::from_json(r#"{"title": "Bug"}"#), thought_signature: None, - namespace: 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 a93acee819..3b6abddcd7 100644 --- a/crates/forge_domain/src/context.rs +++ b/crates/forge_domain/src/context.rs @@ -296,7 +296,8 @@ impl ContextMessage { } } - /// Returns the tool search output if this message is a ToolSearchOutput variant + /// 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), @@ -1396,14 +1397,14 @@ mod tests { name: crate::ToolName::new("tool1"), arguments: serde_json::json!({"arg": "value"}).into(), thought_signature: None, - namespace: 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, + namespace: None, }, ]), )) @@ -1519,14 +1520,14 @@ mod tests { name: crate::ToolName::new("fs_search"), arguments: serde_json::json!({"query": "test"}).into(), thought_signature: None, - namespace: 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, + namespace: None, }, ]; let fixture = diff --git a/crates/forge_domain/src/conversation_html.rs b/crates/forge_domain/src/conversation_html.rs index 527003eb9f..c4ac8261eb 100644 --- a/crates/forge_domain/src/conversation_html.rs +++ b/crates/forge_domain/src/conversation_html.rs @@ -209,8 +209,6 @@ fn create_info_table(conversation: &Conversation) -> Element { table = table.append(create_table_row("Cost", format!("${:.4}", cost))); } } - - } section.append(table) @@ -427,7 +425,9 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { // Add tool calls if any - let message_elm = if let Some(tool_calls) = &content_message.tool_calls { + + + if let Some(tool_calls) = &content_message.tool_calls { if !tool_calls.is_empty() { message_elm.append(Element::new("div").append( tool_calls.iter().map(|tool_call| { @@ -461,9 +461,7 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { } } else { message_elm - }; - - message_elm + } } ContextMessage::Tool(tool_result) => { // Tool Message - apply error styling if the tool result is an error diff --git a/crates/forge_domain/src/hook.rs b/crates/forge_domain/src/hook.rs index aac31e30bd..d5cfecf53c 100644 --- a/crates/forge_domain/src/hook.rs +++ b/crates/forge_domain/src/hook.rs @@ -642,7 +642,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }), )), LifecycleEvent::ToolcallStart(EventData::new( diff --git a/crates/forge_domain/src/message.rs b/crates/forge_domain/src/message.rs index 515bfa0292..82479e8fda 100644 --- a/crates/forge_domain/src/message.rs +++ b/crates/forge_domain/src/message.rs @@ -66,8 +66,9 @@ pub struct ChatCompletionMessage { /// 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. + /// 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. diff --git a/crates/forge_domain/src/result_stream_ext.rs b/crates/forge_domain/src/result_stream_ext.rs index eeab5d32b8..de13d84cda 100644 --- a/crates/forge_domain/src/result_stream_ext.rs +++ b/crates/forge_domain/src/result_stream_ext.rs @@ -337,7 +337,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -393,7 +393,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -447,7 +447,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -502,7 +502,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -559,7 +559,7 @@ mod tests { finish_reason: Some(FinishReason::Stop), phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -666,7 +666,7 @@ mod tests { call_id: Some(ToolCallId::new("call_123")), arguments: serde_json::json!("test_arg").into(), thought_signature: None, - namespace: None, + namespace: None, }; let messages = vec![Ok(ChatCompletionMessage::default() @@ -690,7 +690,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -706,7 +706,7 @@ mod tests { name: Some(ToolName::new("test_tool")), arguments_part: "invalid json {".to_string(), // Invalid JSON thought_signature: None, - namespace: None, + namespace: None, }; let messages = vec![Ok(ChatCompletionMessage::default() @@ -727,7 +727,7 @@ mod tests { call_id: Some(ToolCallId::new("call_123")), arguments: ToolCallArguments::from_json("invalid json {"), thought_signature: None, - namespace: None, + namespace: None, }; assert_eq!(actual.tool_calls[0], expected); } @@ -761,7 +761,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -819,7 +819,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -852,7 +852,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -943,7 +943,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -988,7 +988,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -1031,7 +1031,7 @@ mod tests { * finish reason */ phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -1063,7 +1063,7 @@ mod tests { finish_reason: Some(FinishReason::ToolCalls), phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -1094,7 +1094,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -1188,7 +1188,7 @@ mod tests { finish_reason: Some(FinishReason::Stop), phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); @@ -1202,7 +1202,7 @@ mod tests { call_id: Some(ToolCallId::new("call_123")), arguments: serde_json::json!("test_arg").into(), thought_signature: None, - namespace: None, + namespace: None, }; let messages = vec![Ok(ChatCompletionMessage::default() @@ -1226,7 +1226,7 @@ mod tests { finish_reason: None, phase: None, tool_search_output: None, - response_items: None, + response_items: None, }; assert_eq!(actual, expected); diff --git a/crates/forge_domain/src/tool_search.rs b/crates/forge_domain/src/tool_search.rs index 9f3c264cd4..cdb5f1e925 100644 --- a/crates/forge_domain/src/tool_search.rs +++ b/crates/forge_domain/src/tool_search.rs @@ -3,13 +3,14 @@ use serde::{Deserialize, Serialize}; use crate::ToolCallId; -/// Represents the output of a tool search operation, containing discovered tools -/// that can be used in subsequent turns. +/// Represents the output of a tool search operation, containing discovered +/// tools that can be used in subsequent turns. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Setters)] #[setters(into)] pub struct ToolSearchOutput { /// The ID of the tool search call that produced this output. - /// For server-executed tool search, this is `None` (the API returns `null`). + /// For server-executed tool search, this is `None` (the API returns + /// `null`). #[serde(default, skip_serializing_if = "Option::is_none")] pub call_id: Option, /// The status of the tool search operation diff --git a/crates/forge_domain/src/tools/call/parser.rs b/crates/forge_domain/src/tools/call/parser.rs index 4c244b9eb0..36ac888425 100644 --- a/crates/forge_domain/src/tools/call/parser.rs +++ b/crates/forge_domain/src/tools/call/parser.rs @@ -85,7 +85,7 @@ impl From for ToolCallFull { call_id: None, arguments: ToolCallArguments::from_parameters(value.args), thought_signature: None, - namespace: None, + namespace: None, } } } @@ -173,7 +173,7 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_parameters(self.args.clone()), thought_signature: None, - namespace: None, + namespace: None, } } } @@ -225,7 +225,7 @@ mod tests { call_id: None, arguments: json!({"path":"/a/b/c.txt"}).into(), thought_signature: None, - namespace: None, + namespace: None, }]; assert_eq!(action, expected); } @@ -382,7 +382,7 @@ mod tests { call_id: None, arguments: json!({"path":"/test/path","regex":"test"}).into(), thought_signature: None, - namespace: None, + namespace: None, }]; assert_eq!(action, expected); } @@ -402,7 +402,7 @@ mod tests { call_id: None, arguments: json!({"p1":"\nabc\n"}).into(), thought_signature: None, - namespace: 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 e06184f402..b4764bc870 100644 --- a/crates/forge_domain/src/tools/call/tool_call.rs +++ b/crates/forge_domain/src/tools/call/tool_call.rs @@ -363,21 +363,21 @@ mod tests { name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"crates/forge_services/src/fixtures/".to_string(), thought_signature: None, - namespace: None, + namespace: None, }, ToolCallPart { call_id: None, name: None, arguments_part: "mascot.md\"}".to_string(), thought_signature: None, - namespace: 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, + namespace: None, }, ToolCallPart { // NOTE: Call ID can be repeated with each message @@ -385,21 +385,21 @@ mod tests { name: None, arguments_part: "onboarding.md\"}".to_string(), thought_signature: None, - namespace: 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, + namespace: None, }, ToolCallPart { call_id: None, name: None, arguments_part: "service.md\"}".to_string(), thought_signature: None, - namespace: None, + namespace: None, }, ]; @@ -413,14 +413,14 @@ mod tests { r#"{"path": "crates/forge_services/src/fixtures/mascot.md"}"#, ), thought_signature: None, - namespace: 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, + namespace: None, }, ToolCallFull { name: ToolName::new("read"), @@ -429,7 +429,7 @@ mod tests { r#"{"path": "crates/forge_services/src/service/service.md"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }, ]; @@ -544,7 +544,7 @@ mod tests { name: Some(ToolName::new("read")), arguments_part: "{\"path\": \"docs/onboarding.md\"}".to_string(), thought_signature: None, - namespace: None, + namespace: None, }]; let actual = ToolCallFull::try_from_parts(&input).unwrap(); @@ -553,7 +553,7 @@ mod tests { name: ToolName::new("read"), arguments: ToolCallArguments::from_json(r#"{"path": "docs/onboarding.md"}"#), thought_signature: None, - namespace: None, + namespace: None, }]; assert_eq!(actual, expected); @@ -574,7 +574,7 @@ mod tests { name: Some(ToolName::new("screenshot")), arguments_part: "".to_string(), thought_signature: None, - namespace: None, + namespace: None, }]; let actual = ToolCallFull::try_from_parts(&input).unwrap(); @@ -583,7 +583,7 @@ mod tests { name: ToolName::new("screenshot"), arguments: ToolCallArguments::default(), thought_signature: None, - namespace: None, + namespace: None, }]; assert_eq!(actual, expected); @@ -617,21 +617,21 @@ mod tests { name: Some(ToolName::new("read")), arguments_part: "".to_string(), thought_signature: None, - namespace: 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, + 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, + namespace: None, }, ]; @@ -641,7 +641,7 @@ mod tests { name: ToolName::new("read"), arguments: ToolCallArguments::from_json(r#"{"path": "/test/file.md"}"#), thought_signature: None, - namespace: None, + namespace: None, }]; assert_eq!(actual, expected); @@ -752,7 +752,7 @@ mod tests { name: ToolName::new("shell"), arguments: ToolCallArguments::from_json(r#"{"command": "date"}"#), thought_signature: Some("signature_abc123".to_string()), - namespace: None, + namespace: None, }]; assert_eq!(actual, expected); @@ -767,14 +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, + 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, + namespace: None, }, ]; @@ -785,14 +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, + 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, + namespace: None, }, ]; diff --git a/crates/forge_domain/src/tools/catalog.rs b/crates/forge_domain/src/tools/catalog.rs index ac21b83e4e..2646034109 100644 --- a/crates/forge_domain/src/tools/catalog.rs +++ b/crates/forge_domain/src/tools/catalog.rs @@ -1141,7 +1141,13 @@ impl From for ToolCallFull { ToolCallArguments::default() }; - ToolCallFull { name, call_id: None, arguments, thought_signature: None, namespace: None } + ToolCallFull { + name, + call_id: None, + arguments, + thought_signature: None, + namespace: None, + } } } @@ -1200,7 +1206,7 @@ mod tests { r#"{"path": "/test/path.rs", "start_line": "10", "end_line": "20"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; // This should not panic - it should coerce strings to integers @@ -1232,7 +1238,7 @@ mod tests { r#"{"path": "/test/path.rs", "start_line": 10, "end_line": 20}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1263,7 +1269,7 @@ mod tests { r#"{"path": "/test/path.rs", "start_line": 10, "end_line": 20}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1294,7 +1300,7 @@ mod tests { r#"{"path": "/test/path.rs", "content": "test content"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1322,7 +1328,7 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_json(r#"{"path": "/test/path.rs"}"#), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1347,7 +1353,7 @@ mod tests { r#"{"path": "/test/path.rs", "content": "test"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1506,7 +1512,7 @@ mod tests { r#"{"path": "/test/file.rs", "operation": "replace", "new_string": "new", "old_string": "old"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1535,7 +1541,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "operation": "replace", "new_string": "new", "search": "old text"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1564,7 +1570,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "operation": "replace", "content": "new content", "old_string": "old"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1593,7 +1599,7 @@ mod tests { r#"{"path": "/test/file.rs", "operation": "replace", "content": "new content", "search": "old text"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1624,7 +1630,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "operation": "replace", "new_string": "new content", "old_string": "old text"}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1652,7 +1658,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "new_string": "new", "old_string": "old", "replace_all": true}"#, ), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1828,7 +1834,7 @@ mod tests { call_id: None, arguments: ToolCallArguments::from_json(r#"{"command": "ls"}"#), thought_signature: None, - namespace: None, + namespace: None, }; let actual = ToolCatalog::try_from(tool_call); @@ -1848,7 +1854,7 @@ mod tests { r#"{"file_path": "/test/file.rs", "new_string": "new", "old_string": "old"}"#, ), thought_signature: None, - namespace: 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 0111e1215a..ad1e6cfa65 100644 --- a/crates/forge_domain/src/transformer/mod.rs +++ b/crates/forge_domain/src/transformer/mod.rs @@ -119,7 +119,7 @@ mod tests { call_id: Some(ToolCallId::new("call_123")), arguments: serde_json::json!({"param": "value"}).into(), thought_signature: None, - namespace: 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 4547e63efd..c7fa8ae3b9 100644 --- a/crates/forge_domain/src/transformer/normalize_tool_args.rs +++ b/crates/forge_domain/src/transformer/normalize_tool_args.rs @@ -67,14 +67,14 @@ mod tests { r#"{"file_path": "/test/path", "start_line": 1}"#, ), thought_signature: None, - namespace: None, + namespace: None, }]), thought_signature: None, model: None, reasoning_details: None, droppable: false, phase: None, - response_items: None, + response_items: None, })); // Apply the transformer @@ -146,14 +146,14 @@ mod tests { "start_line": 1 })), thought_signature: None, - namespace: None, + namespace: None, }]), thought_signature: None, model: None, reasoning_details: None, droppable: false, phase: None, - response_items: 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 67f23f3e9e..51833243dd 100644 --- a/crates/forge_domain/src/transformer/transform_tool_calls.rs +++ b/crates/forge_domain/src/transformer/transform_tool_calls.rs @@ -106,7 +106,7 @@ mod tests { call_id: Some(ToolCallId::new("call_123")), arguments: serde_json::json!({"param": "value"}).into(), thought_signature: None, - namespace: None, + namespace: None, }; Context::default() diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index a260cb68e5..b0815a8799 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -750,8 +750,6 @@ impl From<&Conversation> for Info { info = info.extend(usage); } - - info } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index f4f044bbcb..3d6b946bac 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3182,8 +3182,7 @@ impl A + Send + Sync> UI { return Ok(()); } match message { - ChatResponse::TaskMessage { content } => { - match content { + ChatResponse::TaskMessage { content } => match content { ChatResponseContent::ToolInput(title) => { writer.finish()?; self.writeln(title.display())?; @@ -3195,7 +3194,7 @@ impl A + Send + Sync> UI { ChatResponseContent::Markdown { text, partial: _ } => { writer.write(&text)?; } - }}, + }, ChatResponse::ToolCallStart { tool_call, notifier } => { // Scope guard to ensure notification happens even on error. // If writer.finish() or spinner.stop() fails, the guard's drop @@ -3355,8 +3354,6 @@ impl A + Send + Sync> UI { context.assistant_message_count().to_string(), ) .add_key_value("Tool Calls", context.tool_call_count().to_string()); - - } // Add token usage if available diff --git a/crates/forge_repo/src/conversation/conversation_record.rs b/crates/forge_repo/src/conversation/conversation_record.rs index 4998f60c60..830aecc841 100644 --- a/crates/forge_repo/src/conversation/conversation_record.rs +++ b/crates/forge_repo/src/conversation/conversation_record.rs @@ -523,9 +523,7 @@ impl ContextMessageValueRecord { forge_domain::ContextMessage::ToolSearchOutput(tso) => { Some(Self::ToolSearchOutput(tso.clone())) } - forge_domain::ContextMessage::Image(img) => { - Some(Self::Image(ImageRecord::from(img))) - } + forge_domain::ContextMessage::Image(img) => Some(Self::Image(ImageRecord::from(img))), } } } @@ -583,10 +581,8 @@ impl<'de> Deserialize<'de> for ContextMessageRecord { 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), - }) + ContextMessageValueRecord::try_from_domain(&msg.message) + .map(|message| Self { message, usage: msg.usage.as_ref().map(UsageRecord::from) }) } } diff --git a/crates/forge_repo/src/conversation/conversation_repo.rs b/crates/forge_repo/src/conversation/conversation_repo.rs index ccd4307b14..7fc7a0747a 100644 --- a/crates/forge_repo/src/conversation/conversation_repo.rs +++ b/crates/forge_repo/src/conversation/conversation_repo.rs @@ -705,7 +705,7 @@ mod tests { reasoning_details: None, droppable: false, phase: None, - response_items: None, + response_items: None, }), usage: Some(Usage { prompt_tokens: forge_domain::TokenCount::Actual(100), 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 2928b8e7a8..04f5297781 100644 --- a/crates/forge_repo/src/provider/openai_responses/codex_transformer.rs +++ b/crates/forge_repo/src/provider/openai_responses/codex_transformer.rs @@ -37,12 +37,13 @@ impl Transformer for CodexTransformer { /// 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) +/// - 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. + /// Determines if a tool name represents an MCP tool that should be + /// deferred. fn is_mcp_tool(name: &str) -> bool { name.starts_with("mcp_") } @@ -318,7 +319,7 @@ mod tests { let actual = transformer.transform(request); let tools = actual.tools.unwrap(); - + // Should have tool_search + 4 original tools = 5 total assert_eq!(tools.len(), 5); @@ -329,9 +330,19 @@ mod tests { 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); + 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); + assert_eq!( + func.defer_loading, + Some(false), + "Built-in tool {} should not be deferred", + func.name + ); } } } @@ -341,16 +352,13 @@ mod tests { 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"), - ]); + 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); diff --git a/crates/forge_repo/src/provider/openai_responses/request.rs b/crates/forge_repo/src/provider/openai_responses/request.rs index 6ab86fdc48..3875003d52 100644 --- a/crates/forge_repo/src/provider/openai_responses/request.rs +++ b/crates/forge_repo/src/provider/openai_responses/request.rs @@ -4,12 +4,17 @@ 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, ResponseOutputItem, ToolSearchExecution, ToolSearchOutput, ToolSearchStatus}; +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 { +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, @@ -49,8 +54,8 @@ fn emit_tool_search_items( output: &ToolSearchOutput, ) -> anyhow::Result<()> { // Emit tool_search_call first (if captured from the API response) - if let Some(call_json) = &output.tool_search_call { - if let Ok(mut call_param) = + 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 @@ -58,7 +63,6 @@ fn emit_tool_search_items( 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)?; @@ -282,22 +286,20 @@ impl FromDomain for oai::CreateResponse { 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), - }, - )); + 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), + })); } for item in response_items { match item { ResponseOutputItem::Reasoning(detail) => { - items.extend(map_reasoning_details_to_input_items( - vec![detail.clone()], - )); + items.extend(map_reasoning_details_to_input_items(vec![ + detail.clone(), + ])); } ResponseOutputItem::ToolSearchCall(json) => { if let Ok(mut call) = @@ -305,8 +307,7 @@ impl FromDomain for oai::CreateResponse { json.clone(), ) { - call.status = - Some(oai::OutputStatus::Completed); + call.status = Some(oai::OutputStatus::Completed); items.push(oai::InputItem::Item( oai::Item::ToolSearchCall(call), )); @@ -314,9 +315,9 @@ impl FromDomain for oai::CreateResponse { } ResponseOutputItem::ToolSearchOutput(tso) => { items.push(oai::InputItem::Item( - oai::Item::ToolSearchOutput( - tool_search_output_to_oai(tso)?, - ), + oai::Item::ToolSearchOutput(tool_search_output_to_oai( + tso, + )?), )); } ResponseOutputItem::FunctionCall { @@ -326,19 +327,20 @@ impl FromDomain for oai::CreateResponse { arguments, namespace, } => { - items.push(oai::InputItem::Item( - oai::Item::FunctionCall(oai::FunctionToolCall { + 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 + // Text content already handled above + // via // message.content } } @@ -348,14 +350,12 @@ impl FromDomain for oai::CreateResponse { // 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), - }, - )); + 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 { diff --git a/crates/forge_repo/src/provider/openai_responses/response.rs b/crates/forge_repo/src/provider/openai_responses/response.rs index 04bf97955c..efb810a265 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, ResponseOutputItem, ResultStream, ToolSearchExecution, ToolSearchOutput, ToolSearchStatus}; +use forge_domain::{ + BoxStream, ResponseOutputItem, ResultStream, ToolSearchExecution, ToolSearchOutput, + ToolSearchStatus, +}; use futures::StreamExt; use serde::{Deserialize, Deserializer}; @@ -221,7 +224,8 @@ impl IntoDomain for oai::Response { 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)); + response_items + .push(forge_domain::ResponseOutputItem::Reasoning(reasoning_full)); } // Tool search call: server-side search for deferred tools. oai::OutputItem::ToolSearchCall(call) => { @@ -250,7 +254,7 @@ impl IntoDomain for oai::Response { tools: output .tools .iter() - .map(|tool| serde_json::to_value(tool)) + .map(serde_json::to_value) .collect::, _>>() .unwrap_or_default(), tool_search_call: tool_search_call_json.take(), @@ -292,7 +296,8 @@ 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. + /// 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 @@ -345,23 +350,26 @@ impl IntoDomain for BoxStream { 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() + 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 { @@ -370,150 +378,117 @@ 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()), - ); - - // 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( + ])))), + 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(), - ns.clone(), + (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(), + }; - // 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 + .tool_search_output(tool_search_output))) } + _ => 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(|tool| serde_json::to_value(tool)) - .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::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); - - 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: 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. + 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(""), ) }); @@ -521,64 +496,115 @@ impl IntoDomain for BoxStream { let namespace = state .output_index_to_namespace - .get(&(done.output_index.into())) + .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 - 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, - }}, + } Err(err) => Some(Err(err)), }; From 60d675c0d27ce315e4b4f78b08dcead0d158b760 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Sun, 5 Apr 2026 16:20:48 +0530 Subject: [PATCH 3/9] docs(forge.schema): clarify tool_search description for GPT-5.4 deferred loading --- forge.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge.schema.json b/forge.schema.json index 5040559115..ed5e15599e 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -293,7 +293,7 @@ ] }, "tool_search": { - "description": "Whether the tool_search API is enabled for deferred tool loading. When true, MCP tools are sent with defer_loading and discovered on demand via the Responses API tool_search mechanism. Defaults to false; must be explicitly enabled.", + "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 }, From 8aa2d2d01a3b76e2d0c5dcb0647114bfba80ee63 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Sun, 5 Apr 2026 16:29:03 +0530 Subject: [PATCH 4/9] fix(request): correct tool search item emission logic for replaying conversation history --- crates/forge_domain/src/conversation_html.rs | 2 -- .../src/provider/openai_responses/request.rs | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/forge_domain/src/conversation_html.rs b/crates/forge_domain/src/conversation_html.rs index c4ac8261eb..e029029672 100644 --- a/crates/forge_domain/src/conversation_html.rs +++ b/crates/forge_domain/src/conversation_html.rs @@ -425,8 +425,6 @@ fn create_conversation_context_section(conversation: &Conversation) -> Element { // Add tool calls if any - - if let Some(tool_calls) = &content_message.tool_calls { if !tool_calls.is_empty() { message_elm.append(Element::new("div").append( diff --git a/crates/forge_repo/src/provider/openai_responses/request.rs b/crates/forge_repo/src/provider/openai_responses/request.rs index 3875003d52..55de2d0438 100644 --- a/crates/forge_repo/src/provider/openai_responses/request.rs +++ b/crates/forge_repo/src/provider/openai_responses/request.rs @@ -57,12 +57,12 @@ fn emit_tool_search_items( 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))); - } + { + // 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)?; From ee406dd77a02b4225991241d2ca5f0ba73a0d5dc Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Sun, 5 Apr 2026 16:32:44 +0530 Subject: [PATCH 5/9] docs(context): fix tool_search default behavior comment --- crates/forge_domain/src/context.rs | 2 +- crates/forge_repo/src/provider/bedrock_sanitize_ids.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_domain/src/context.rs b/crates/forge_domain/src/context.rs index 3b6abddcd7..df360bbf4a 100644 --- a/crates/forge_domain/src/context.rs +++ b/crates/forge_domain/src/context.rs @@ -484,7 +484,7 @@ pub struct Context { /// 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 enabled). + /// eagerly. Defaults to `None` (treated as disabled). #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_search: Option, } diff --git a/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs b/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs index 0525ade631..bdef654bbe 100644 --- a/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs +++ b/crates/forge_repo/src/provider/bedrock_sanitize_ids.rs @@ -24,7 +24,7 @@ lazy_static! { /// /// # Example /// -/// ``` +/// ```ignore /// // Before transformation: /// tool_use.tool_use_id = "functions.shell:0" /// From ccbcbd59efebd37ef2b61fe688d3aac93b588a56 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Mon, 6 Apr 2026 17:35:10 +0530 Subject: [PATCH 6/9] fix(tests): initialize tool_search_output and response_items in test case --- crates/forge_domain/src/result_stream_ext.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/forge_domain/src/result_stream_ext.rs b/crates/forge_domain/src/result_stream_ext.rs index 314359dc64..898457e194 100644 --- a/crates/forge_domain/src/result_stream_ext.rs +++ b/crates/forge_domain/src/result_stream_ext.rs @@ -578,6 +578,8 @@ mod tests { reasoning_details: None, finish_reason: Some(FinishReason::Stop), phase: None, + tool_search_output: None, + response_items: None, }; assert_eq!(actual, expected); From acbdde3fd3f69d5266bf4eb222ea8cdd5ccc0e99 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Mon, 6 Apr 2026 19:56:28 +0530 Subject: [PATCH 7/9] fix: update documentation for ContextMessageValueRecord conversion method --- crates/forge_repo/src/conversation/conversation_record.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_repo/src/conversation/conversation_record.rs b/crates/forge_repo/src/conversation/conversation_record.rs index 830aecc841..c9daaee1ad 100644 --- a/crates/forge_repo/src/conversation/conversation_record.rs +++ b/crates/forge_repo/src/conversation/conversation_record.rs @@ -510,8 +510,8 @@ pub(super) enum ContextMessageValueRecord { } impl ContextMessageValueRecord { - /// Tries to convert a domain ContextMessage to a record, returning None - /// for variants that are not persisted (e.g. ToolSearchOutput). + /// 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) => { From 3a1d86af37496514dcca3de8e04517d08423b3d9 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 7 Apr 2026 09:52:06 +0530 Subject: [PATCH 8/9] fix(app): refactor tool_search assignment for clarity --- crates/forge_app/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index c1b050370c..23bbee358f 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -160,6 +160,7 @@ impl ForgeApp { .on_end(tracing_handler.and(title_handler)); let retry_config = forge_config.retry.clone().unwrap_or_default(); + let tool_search = forge_config.tool_search; let orch = Orchestrator::new( services.clone(), @@ -172,7 +173,7 @@ impl ForgeApp { .tool_definitions(tool_definitions) .models(models) .hook(Arc::new(hook)) - .tool_search(forge_config.tool_search); + .tool_search(tool_search); // Create and return the stream let stream = MpscStream::spawn( From aff1872e4546c92d4a7d08cf6ecc8ac30b02a3db Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:23:58 +0000 Subject: [PATCH 9/9] [autofix.ci] apply automated fixes --- crates/forge_repo/src/conversation/conversation_record.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/forge_repo/src/conversation/conversation_record.rs b/crates/forge_repo/src/conversation/conversation_record.rs index c9daaee1ad..a27b554f1f 100644 --- a/crates/forge_repo/src/conversation/conversation_record.rs +++ b/crates/forge_repo/src/conversation/conversation_record.rs @@ -511,7 +511,8 @@ pub(super) enum ContextMessageValueRecord { impl ContextMessageValueRecord { /// Tries to convert a domain ContextMessage to a record. - /// All variants are currently persisted and included in the conversation 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) => {