Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/forge_app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ impl<S: Services + EnvironmentInfra<Config = forge_config::ForgeConfig>> ForgeAp
.on_toolcall_end(tracing_handler.clone())
.on_end(tracing_handler.and(title_handler));

let tool_search = forge_config.tool_search;

let orch = Orchestrator::new(
services.clone(),
conversation,
Expand All @@ -163,7 +165,8 @@ impl<S: Services + EnvironmentInfra<Config = forge_config::ForgeConfig>> ForgeAp
.error_tracker(ToolErrorTracker::new(max_tool_failure_per_turn))
.tool_definitions(tool_definitions)
.models(models)
.hook(Arc::new(hook));
.hook(Arc::new(hook))
.tool_search(tool_search);

// Create and return the stream
let stream = MpscStream::spawn(
Expand Down
4 changes: 4 additions & 0 deletions crates/forge_app/src/dto/anthropic/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ impl TryFrom<ContextMessage> 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 }
}
Expand Down
2 changes: 2 additions & 0 deletions crates/forge_app/src/dto/anthropic/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ impl TryFrom<ContentBlock> for ChatCompletionMessage {
serde_json::to_string(&input)?
},
thought_signature: None,
namespace: None,
})
}
ContentBlock::InputJsonDelta { partial_json } => {
Expand All @@ -402,6 +403,7 @@ impl TryFrom<ContentBlock> for ChatCompletionMessage {
name: None,
arguments_part: partial_json,
thought_signature: None,
namespace: None,
})
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ mod tests {
stream: None,
response_format: None,
initiator: None,
tool_search: None,
};

Request::try_from(context).unwrap()
Expand Down
2 changes: 2 additions & 0 deletions crates/forge_app/src/dto/anthropic/transforms/set_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions crates/forge_app/src/dto/google/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ impl From<ContextMessage> 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),
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
];

Expand Down
1 change: 1 addition & 0 deletions crates/forge_app/src/dto/google/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ impl TryFrom<Part> for ChatCompletionMessage {
name: Some(ToolName::new(function_call.name)),
arguments_part: serde_json::to_string(&function_call.args)?,
thought_signature,
namespace: None,
},
),
),
Expand Down
16 changes: 16 additions & 0 deletions crates/forge_app/src/dto/openai/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,21 @@ impl From<ContextMessage> 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,
}
}
}
}
}
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions crates/forge_app/src/dto/openai/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ impl TryFrom<Response> for ChatCompletionMessage {
&tool_call.function.arguments,
)?,
thought_signature,
namespace: None,
});
}
}
Expand Down Expand Up @@ -433,6 +434,7 @@ impl TryFrom<Response> for ChatCompletionMessage {
name: tool_call.function.name.clone(),
arguments_part: tool_call.function.arguments.clone(),
thought_signature,
namespace: None,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -72,6 +73,7 @@ mod tests {
stream: None,
response_format: None,
initiator: None,
tool_search: None,
};

let request = Request::from(context);
Expand Down
1 change: 1 addition & 0 deletions crates/forge_app/src/dto/openai/transformers/set_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ mod tests {
stream: None,
response_format: None,
initiator: None,
tool_search: None,
};

let request = Request::from(context);
Expand Down
4 changes: 4 additions & 0 deletions crates/forge_app/src/hooks/doom_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ mod tests {
reasoning_details: None,
droppable: false,
phase: None,
response_items: None,
}
}

Expand Down Expand Up @@ -405,6 +406,7 @@ mod tests {
reasoning_details: None,
droppable: false,
phase: None,
response_items: None,
};

let user_msg = TextMessage {
Expand All @@ -417,6 +419,7 @@ mod tests {
reasoning_details: None,
droppable: false,
phase: None,
response_items: None,
};

let assistant_msg_2 = TextMessage {
Expand All @@ -429,6 +432,7 @@ mod tests {
reasoning_details: None,
droppable: false,
phase: None,
response_items: None,
};

let messages = [
Expand Down
3 changes: 3 additions & 0 deletions crates/forge_app/src/hooks/tracing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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"))
Expand Down
19 changes: 16 additions & 3 deletions crates/forge_app/src/orch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub struct Orchestrator<S> {
agent: Agent,
error_tracker: ToolErrorTracker,
hook: Arc<Hook>,
/// 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<bool>,
config: forge_config::ForgeConfig,
}

Expand All @@ -44,6 +48,7 @@ impl<S: AgentService + EnvironmentInfra<Config = forge_config::ForgeConfig>> Orc
models: Default::default(),
error_tracker: Default::default(),
hook: Arc::new(Hook::default()),
tool_search: Default::default(),
}
}

Expand Down Expand Up @@ -195,10 +200,13 @@ impl<S: AgentService + EnvironmentInfra<Config = forge_config::ForgeConfig>> Orc
async fn execute_chat_turn(
&self,
model_id: &ModelId,
context: Context,
mut context: Context,
reasoning_supported: bool,
) -> anyhow::Result<ChatCompletionMessageFull> {
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())
Expand Down Expand Up @@ -310,8 +318,11 @@ impl<S: AgentService + EnvironmentInfra<Config = forge_config::ForgeConfig>> Orc

// Turn is completed, if finish_reason is 'stop'. Gemini models return stop as
// finish reason with tool calls.
is_complete =
message.finish_reason == Some(FinishReason::Stop) && message.tool_calls.is_empty();
// When tool_search_output is present, the model discovered tools via search
// and needs another turn to actually use them — do NOT mark as complete.
is_complete = message.finish_reason == Some(FinishReason::Stop)
&& message.tool_calls.is_empty()
&& message.tool_search_output.is_none();

// Should yield if a tool is asking for a follow-up
should_yield = is_complete
Expand Down Expand Up @@ -356,6 +367,8 @@ impl<S: AgentService + EnvironmentInfra<Config = forge_config::ForgeConfig>> Orc
message.usage,
tool_call_records,
message.phase,
message.tool_search_output.clone(),
message.response_items.clone(),
);

if self.error_tracker.limit_reached() {
Expand Down
3 changes: 3 additions & 0 deletions crates/forge_app/src/user_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ impl<S: AttachmentService> UserPromptGenerator<S> {
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));
}
Expand Down Expand Up @@ -123,6 +124,7 @@ impl<S: AttachmentService> UserPromptGenerator<S> {
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));
}
Expand Down Expand Up @@ -200,6 +202,7 @@ impl<S: AttachmentService> UserPromptGenerator<S> {
model: Some(self.agent.model.clone()),
droppable: false,
phase: None,
response_items: None,
};
context = context.add_message(ContextMessage::Text(message));
}
Expand Down
1 change: 1 addition & 0 deletions crates/forge_config/.forge.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading
Loading