Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion crates/forge_app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ impl<S: Services> ForgeApp<S> {
.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(
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 @@ -25,6 +25,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>,
}

impl<S: AgentService> Orchestrator<S> {
Expand All @@ -44,6 +48,7 @@ impl<S: AgentService> Orchestrator<S> {
models: Default::default(),
error_tracker: Default::default(),
hook: Arc::new(Hook::default()),
tool_search: Default::default(),
}
}

Expand Down Expand Up @@ -153,10 +158,13 @@ impl<S: AgentService> Orchestrator<S> {
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 @@ -268,8 +276,11 @@ impl<S: AgentService> Orchestrator<S> {

// 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 @@ -314,6 +325,8 @@ impl<S: AgentService> Orchestrator<S> {
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
10 changes: 10 additions & 0 deletions crates/forge_domain/src/compact/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ impl From<&Context> for ContextSummary {
}
}
ContextMessage::Image(_) => {}
ContextMessage::ToolSearchOutput(_) => {
// Tool search output is not included in the summary
}
}
}

Expand Down Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -987,6 +991,7 @@ mod tests {
r#"{"path": "/test", "pattern": "pattern"}"#,
),
thought_signature: None,
namespace: None,
}],
)]);

Expand Down Expand Up @@ -1464,6 +1469,7 @@ mod tests {
r#"{"title": "Bug report", "body": "Description"}"#,
),
thought_signature: None,
namespace: None,
}],
)]);

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"),
Expand All @@ -1531,6 +1539,7 @@ mod tests {
r##"{"channel": "#dev", "text": "Hello"}"##,
),
thought_signature: None,
namespace: None,
},
],
)]);
Expand Down Expand Up @@ -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"),
],
Expand Down
Loading
Loading