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
9 changes: 9 additions & 0 deletions crates/forge_api/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ pub trait API: Sync + Send {
/// suggestion generation).
async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()>;

/// Gets the global reasoning configuration.
async fn get_reasoning_config(&self) -> anyhow::Result<Option<forge_domain::ReasoningConfig>>;

/// Sets the global reasoning configuration.
async fn set_reasoning_config(
&self,
config: forge_domain::ReasoningConfig,
) -> anyhow::Result<()>;

/// Refresh MCP caches by fetching fresh data
async fn reload_mcp(&self) -> Result<()>;

Expand Down
8 changes: 8 additions & 0 deletions crates/forge_api/src/forge_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ impl<A: Services, F: CommandInfra + EnvironmentInfra + SkillRepository + GrpcInf
self.services.set_suggest_config(config).await
}

async fn get_reasoning_config(&self) -> anyhow::Result<Option<ReasoningConfig>> {
self.services.get_reasoning_config().await
}

async fn set_reasoning_config(&self, config: ReasoningConfig) -> anyhow::Result<()> {
self.services.set_reasoning_config(config).await
}

async fn reload_mcp(&self) -> Result<()> {
self.services.mcp_service().reload_mcp().await
}
Expand Down
8 changes: 8 additions & 0 deletions crates/forge_app/src/command_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,14 @@ mod tests {
async fn set_suggest_config(&self, _config: forge_domain::SuggestConfig) -> Result<()> {
Ok(())
}

async fn get_reasoning_config(&self) -> Result<Option<forge_domain::ReasoningConfig>> {
Ok(None)
}

async fn set_reasoning_config(&self, _config: forge_domain::ReasoningConfig) -> Result<()> {
Ok(())
}
}

#[tokio::test]
Expand Down
34 changes: 24 additions & 10 deletions crates/forge_app/src/dto/anthropic/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,30 @@ impl TryFrom<forge_domain::Context> for Request {
tool_choice: request.tool_choice.map(ToolChoice::from),
stream: Some(request.stream.unwrap_or(true)),
thinking: request.reasoning.and_then(|reasoning| {
reasoning.enabled.and_then(|enabled| {
if enabled {
Some(Thinking {
r#type: ThinkingType::Enabled,
budget_tokens: reasoning.max_tokens.unwrap_or(10000) as u64,
})
} else {
None
}
})
use forge_domain::Effort;
// Effort::None explicitly disables thinking
if matches!(reasoning.effort, Some(Effort::None)) {
return None;
}
// Map effort variant to a token budget; takes priority over max_tokens
let effort_budget: Option<u64> = reasoning.effort.as_ref().map(|e| match e {
Effort::None => 0, // unreachable — handled above
Effort::Minimal => 1024,
Effort::Low => 2048,
Effort::Medium => 8192,
Effort::High => 16384,
Effort::XHigh => 32768,
});
// Enable if a non-None effort is set, or if explicitly enabled
let should_enable = effort_budget.is_some() || reasoning.enabled == Some(true);
if should_enable {
let budget_tokens = effort_budget
.or_else(|| reasoning.max_tokens.map(|t| t as u64))
.unwrap_or(10000);
Some(Thinking { r#type: ThinkingType::Enabled, budget_tokens })
} else {
None
}
}),
output_format: request.response_format.and_then(|rf| match rf {
forge_domain::ResponseFormat::Text => {
Expand Down
38 changes: 27 additions & 11 deletions crates/forge_app/src/dto/google/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,17 +397,33 @@ impl From<Context> for Request {
_ => None,
}),
thinking_config: context.reasoning.and_then(|reasoning| {
reasoning.enabled.and_then(|enabled| {
if enabled {
Some(ThinkingConfig {
thinking_level: None,
thinking_budget: reasoning.max_tokens.map(|t| t as i32),
include_thoughts: Some(true),
})
} else {
None
}
})
use forge_domain::Effort;
// Effort::None explicitly disables thinking
if matches!(reasoning.effort, Some(Effort::None)) {
return None;
}
// Map effort variant to Google's Level enum
let thinking_level = reasoning.effort.as_ref().and_then(|e| match e {
Effort::None => None, // unreachable — handled above
Effort::Minimal => Some(Level::Minimal),
Effort::Low => Some(Level::Low),
Effort::Medium => Some(Level::Medium),
Effort::High | Effort::XHigh => Some(Level::High),
});
let thinking_budget = reasoning.max_tokens.map(|t| t as i32);
// Enable if effort is set, budget is set, or explicitly enabled
if thinking_level.is_some()
|| thinking_budget.is_some()
|| reasoning.enabled == Some(true)
{
Some(ThinkingConfig {
thinking_level,
thinking_budget,
include_thoughts: Some(true),
})
} else {
None
}
}),
..Default::default()
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ use crate::dto::openai::Request;
/// # Transformation Rules
///
/// - If `reasoning.enabled == Some(false)` → use "none" (disables reasoning)
/// - If `reasoning.effort` is set (low/medium/high) → use that value
/// - If `reasoning.effort` is set (none/minimal/low/medium/high/xhigh) → use
/// that value
/// - If `reasoning.max_tokens` is set (thinking budget) → convert to effort:
/// - 0-1024 → "low"
/// - 1025-8192 → "medium"
/// - 8193+ → "high"
/// - 0 → "none"
/// - 1–512 → "minimal"
/// - 513–1024 → "low"
/// - 1025–8192 → "medium"
/// - 8193–32768 → "high"
/// - 32769+ → "xhigh"
/// - If `reasoning.enabled == Some(true)` but no other params → default to
/// "medium"
/// - Original `reasoning` field is removed after transformation
Expand Down
1 change: 1 addition & 0 deletions crates/forge_app/src/orch_spec/orch_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ impl Default for TestContext {
max_requests_per_turn: None,
compact: None,
updates: None,
reasoning: None,
},
title: Some("test-conversation".into()),
agent: Agent::new(
Expand Down
20 changes: 20 additions & 0 deletions crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@ pub trait AppConfigService: Send + Sync {
/// Sets the suggest configuration (provider and model for command
/// suggestion generation).
async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()>;

/// Gets the global reasoning configuration.
async fn get_reasoning_config(&self) -> anyhow::Result<Option<forge_domain::ReasoningConfig>>;

/// Sets the global reasoning configuration.
async fn set_reasoning_config(
&self,
config: forge_domain::ReasoningConfig,
) -> anyhow::Result<()>;
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -984,6 +993,17 @@ impl<I: Services> AppConfigService for I {
async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()> {
self.config_service().set_suggest_config(config).await
}

async fn get_reasoning_config(&self) -> anyhow::Result<Option<forge_domain::ReasoningConfig>> {
self.config_service().get_reasoning_config().await
}

async fn set_reasoning_config(
&self,
config: forge_domain::ReasoningConfig,
) -> anyhow::Result<()> {
self.config_service().set_reasoning_config(config).await
}
}

#[async_trait::async_trait]
Expand Down
4 changes: 4 additions & 0 deletions crates/forge_config/.forge.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ token_threshold = 100000
[updates]
auto_update = true
frequency = "daily"

[reasoning]
enabled = true
effort = "medium"
9 changes: 8 additions & 1 deletion crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

use crate::reader::ConfigReader;
use crate::writer::ConfigWriter;
use crate::{AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, RetryConfig, Update};
use crate::{
AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, Update,
};

/// Top-level Forge configuration merged from all sources (defaults, file,
/// environment).
Expand Down Expand Up @@ -159,10 +161,15 @@
/// all tool calls are disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_supported: Option<bool>,

/// Reasoning configuration applied to all agents; agent-level settings
/// take priority over this global setting when both are present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
}

#[cfg(test)]
mod tests {

Check warning on line 172 in crates/forge_config/src/config.rs

View workflow job for this annotation

GitHub Actions / Lint Fix

items after a test module
use pretty_assertions::assert_eq;

use super::*;
Expand Down
2 changes: 2 additions & 0 deletions crates/forge_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod legacy;
mod model;
mod percentage;
mod reader;
mod reasoning;
mod retry;
mod writer;

Expand All @@ -20,6 +21,7 @@ pub use http::*;
pub use model::*;
pub use percentage::*;
pub use reader::*;
pub use reasoning::*;
pub use retry::*;
pub use writer::*;

Expand Down
52 changes: 52 additions & 0 deletions crates/forge_config/src/reasoning.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use fake::Dummy;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Effort level for the reasoning capability.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Dummy)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
/// No reasoning tokens; disables extended thinking entirely.
None,
/// Minimal reasoning; fastest, fewest thinking tokens.
Minimal,
/// Constrained reasoning suitable for straightforward tasks.
Low,
/// Balanced reasoning for moderately complex tasks.
Medium,
/// Deep reasoning for complex problems.
High,
/// Maximum reasoning budget for the hardest tasks.
#[serde(rename = "xhigh")]
XHigh,
}

/// Reasoning configuration applied to all agents when set at the global level.
///
/// Controls the reasoning capabilities of the model. When set here, it acts as
/// a default for all agents; agent-level settings take priority over this
/// global setting.
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Dummy)]
#[serde(rename_all = "snake_case")]
pub struct ReasoningConfig {
/// Controls the effort level of the agent's reasoning.
/// Supported by openrouter and forge provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<Effort>,

/// Controls how many tokens the model can spend thinking.
/// Supported by openrouter, anthropic and forge provider.
/// Should be greater than 1024 but less than overall max_tokens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<usize>,

/// Model thinks deeply, but the reasoning is hidden from you.
/// Supported by openrouter and forge provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude: Option<bool>,

/// Enables reasoning at the "medium" effort level with no exclusions.
/// Supported by openrouter, anthropic and forge provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
13 changes: 13 additions & 0 deletions crates/forge_domain/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ impl Agent {
agent.compact = merged_compact;
}

// Apply workflow reasoning configuration to agents
// Agent-level settings take priority; env fills in any unset fields.
if let Some(ref env_reasoning) = env.reasoning {
let merged = match agent.reasoning.take() {
Some(mut agent_reasoning) => {
agent_reasoning.merge(env_reasoning.clone());
agent_reasoning
}
None => env_reasoning.clone(),
};
agent.reasoning = Some(merged);
}

agent
}

Expand Down
Loading
Loading