diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index d52f408247..032273dbe1 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -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>; + + /// 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<()>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 7cf9c35418..76db908e1e 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -289,6 +289,14 @@ impl anyhow::Result> { + 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 } diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 7650deef19..3493951ab3 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -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> { + Ok(None) + } + + async fn set_reasoning_config(&self, _config: forge_domain::ReasoningConfig) -> Result<()> { + Ok(()) + } } #[tokio::test] diff --git a/crates/forge_app/src/dto/anthropic/request.rs b/crates/forge_app/src/dto/anthropic/request.rs index 293a57961b..e730f8f582 100644 --- a/crates/forge_app/src/dto/anthropic/request.rs +++ b/crates/forge_app/src/dto/anthropic/request.rs @@ -116,16 +116,30 @@ impl TryFrom 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 = 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 => { diff --git a/crates/forge_app/src/dto/google/request.rs b/crates/forge_app/src/dto/google/request.rs index c148e28414..c1867dcf0e 100644 --- a/crates/forge_app/src/dto/google/request.rs +++ b/crates/forge_app/src/dto/google/request.rs @@ -397,17 +397,33 @@ impl From 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() }); diff --git a/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs b/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs index d6f2a959ba..65b75ab64a 100644 --- a/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs +++ b/crates/forge_app/src/dto/openai/transformers/set_reasoning_effort.rs @@ -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 diff --git a/crates/forge_app/src/orch_spec/orch_setup.rs b/crates/forge_app/src/orch_spec/orch_setup.rs index f03ff78aca..295ee02aa2 100644 --- a/crates/forge_app/src/orch_spec/orch_setup.rs +++ b/crates/forge_app/src/orch_spec/orch_setup.rs @@ -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( diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index c11b4fafe4..4c48c6f280 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -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>; + + /// Sets the global reasoning configuration. + async fn set_reasoning_config( + &self, + config: forge_domain::ReasoningConfig, + ) -> anyhow::Result<()>; } #[async_trait::async_trait] @@ -984,6 +993,17 @@ impl 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> { + 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] diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index fa2331e690..f324b6c9ab 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -59,3 +59,7 @@ token_threshold = 100000 [updates] auto_update = true frequency = "daily" + +[reasoning] +enabled = true +effort = "medium" diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 366633054e..9aef41d45d 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -7,7 +7,9 @@ use serde::{Deserialize, Serialize}; 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). @@ -159,6 +161,11 @@ pub struct ForgeConfig { /// all tool calls are disabled. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_supported: Option, + + /// 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, } #[cfg(test)] diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index 6bcbdaa147..cc253277e4 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -8,6 +8,7 @@ mod legacy; mod model; mod percentage; mod reader; +mod reasoning; mod retry; mod writer; @@ -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::*; diff --git a/crates/forge_config/src/reasoning.rs b/crates/forge_config/src/reasoning.rs new file mode 100644 index 0000000000..9deed53fab --- /dev/null +++ b/crates/forge_config/src/reasoning.rs @@ -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, + + /// 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, + + /// 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, + + /// 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, +} diff --git a/crates/forge_domain/src/agent.rs b/crates/forge_domain/src/agent.rs index e6a2e3340d..0df1040e16 100644 --- a/crates/forge_domain/src/agent.rs +++ b/crates/forge_domain/src/agent.rs @@ -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 } diff --git a/crates/forge_domain/src/agent_definition.rs b/crates/forge_domain/src/agent_definition.rs index ec7165f63b..3234a09ab3 100644 --- a/crates/forge_domain/src/agent_definition.rs +++ b/crates/forge_domain/src/agent_definition.rs @@ -217,23 +217,43 @@ pub struct ReasoningConfig { #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Effort { - High, - Medium, + /// No reasoning tokens; disables extended thinking entirely. + None, + /// Minimal reasoning; fastest, fewest thinking tokens (1–512). + Minimal, + /// Constrained reasoning suitable for straightforward tasks (513–1024). Low, + /// Balanced reasoning for moderately complex tasks (1025–8192). + Medium, + /// Deep reasoning for complex problems (8193–32768). + High, + /// Maximum reasoning budget for the hardest tasks (32769+). + #[serde(rename = "xhigh")] + #[strum(serialize = "xhigh")] + XHigh, } /// Converts a thinking budget (max_tokens) 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 impl From for Effort { fn from(budget: usize) -> Self { - if budget <= 1024 { + if budget == 0 { + Effort::None + } else if budget <= 512 { + Effort::Minimal + } else if budget <= 1024 { Effort::Low } else if budget <= 8192 { Effort::Medium - } else { + } else if budget <= 32768 { Effort::High + } else { + Effort::XHigh } } } @@ -294,10 +314,20 @@ mod tests { use super::*; + #[test] + fn test_effort_from_budget_none() { + assert_eq!(Effort::from(0), Effort::None); + } + + #[test] + fn test_effort_from_budget_minimal() { + assert_eq!(Effort::from(1), Effort::Minimal); + assert_eq!(Effort::from(512), Effort::Minimal); + } + #[test] fn test_effort_from_budget_low() { - assert_eq!(Effort::from(0), Effort::Low); - assert_eq!(Effort::from(1), Effort::Low); + assert_eq!(Effort::from(513), Effort::Low); assert_eq!(Effort::from(1024), Effort::Low); } @@ -312,7 +342,13 @@ mod tests { fn test_effort_from_budget_high() { assert_eq!(Effort::from(8193), Effort::High); assert_eq!(Effort::from(10000), Effort::High); - assert_eq!(Effort::from(100000), Effort::High); + assert_eq!(Effort::from(32768), Effort::High); + } + + #[test] + fn test_effort_from_budget_xhigh() { + assert_eq!(Effort::from(32769), Effort::XHigh); + assert_eq!(Effort::from(100000), Effort::XHigh); } #[test] diff --git a/crates/forge_domain/src/env.rs b/crates/forge_domain/src/env.rs index 1db2b2903c..639c21c4bf 100644 --- a/crates/forge_domain/src/env.rs +++ b/crates/forge_domain/src/env.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use url::Url; use crate::{ - CommitConfig, Compact, HttpConfig, MaxTokens, ModelId, ProviderId, RetryConfig, SuggestConfig, - Temperature, TopK, TopP, Update, + CommitConfig, Compact, HttpConfig, MaxTokens, ModelId, ProviderId, ReasoningConfig, + RetryConfig, SuggestConfig, Temperature, TopK, TopP, Update, }; /// Domain-level session configuration pairing a provider with a model. @@ -40,6 +40,8 @@ pub enum ConfigOperation { SetCommitConfig(CommitConfig), /// Set the shell-command suggestion configuration. SetSuggestConfig(SuggestConfig), + /// Set the global reasoning configuration. + SetReasoning(ReasoningConfig), } const VERSION: &str = match option_env!("APP_VERSION") { @@ -191,6 +193,12 @@ pub struct Environment { #[serde(default, skip_serializing_if = "Option::is_none")] pub compact: Option, + /// Reasoning configuration applied to all agents; agent-level settings + /// take priority over this global setting when both are present. + #[dummy(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Configuration for automatic forge updates. #[dummy(default)] #[serde(default, skip_serializing_if = "Option::is_none")] @@ -257,6 +265,9 @@ impl Environment { .model_id(suggest.model.to_string()), ); } + ConfigOperation::SetReasoning(reasoning) => { + self.reasoning = Some(reasoning); + } } } @@ -382,6 +393,7 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use crate::Effort; fn fixture_env() -> Environment { Faker.fake() @@ -477,6 +489,14 @@ mod tests { assert_eq!(fixture.suggest, Some(expected)); } + #[test] + fn test_apply_op_set_reasoning() { + let mut fixture = fixture_env(); + let reasoning = ReasoningConfig::default().effort(Effort::High); + fixture.apply_op(ConfigOperation::SetReasoning(reasoning.clone())); + assert_eq!(fixture.reasoning, Some(reasoning)); + } + #[test] fn test_agent_cwd_path() { let fixture: Environment = Faker.fake(); diff --git a/crates/forge_infra/src/env.rs b/crates/forge_infra/src/env.rs index aeb5882838..b99a6750cd 100644 --- a/crates/forge_infra/src/env.rs +++ b/crates/forge_infra/src/env.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use forge_app::EnvironmentInfra; use forge_config::{ConfigReader, ForgeConfig, ModelConfig}; use forge_domain::{ - AutoDumpFormat, Compact, ConfigOperation, Environment, HttpConfig, MaxTokens, ModelId, - RetryConfig, SessionConfig, Temperature, TlsBackend, TlsVersion, TopK, TopP, Update, - UpdateFrequency, + AutoDumpFormat, Compact, ConfigOperation, Effort, Environment, HttpConfig, MaxTokens, ModelId, + ReasoningConfig, RetryConfig, SessionConfig, Temperature, TlsBackend, TlsVersion, TopK, TopP, + Update, UpdateFrequency, }; use reqwest::Url; use tracing::{debug, error}; @@ -114,6 +114,52 @@ fn to_compact(c: forge_config::Compact) -> Compact { } } +/// Converts a [`forge_config::Effort`] into a [`forge_domain::Effort`]. +fn to_effort(e: forge_config::Effort) -> Effort { + match e { + forge_config::Effort::None => Effort::None, + forge_config::Effort::Minimal => Effort::Minimal, + forge_config::Effort::Low => Effort::Low, + forge_config::Effort::Medium => Effort::Medium, + forge_config::Effort::High => Effort::High, + forge_config::Effort::XHigh => Effort::XHigh, + } +} + +/// Converts a [`forge_domain::Effort`] back into a [`forge_config::Effort`]. +fn from_effort(e: Effort) -> forge_config::Effort { + match e { + Effort::None => forge_config::Effort::None, + Effort::Minimal => forge_config::Effort::Minimal, + Effort::Low => forge_config::Effort::Low, + Effort::Medium => forge_config::Effort::Medium, + Effort::High => forge_config::Effort::High, + Effort::XHigh => forge_config::Effort::XHigh, + } +} + +/// Converts a [`forge_config::ReasoningConfig`] into a +/// [`forge_domain::ReasoningConfig`]. +fn to_reasoning_config(r: forge_config::ReasoningConfig) -> ReasoningConfig { + ReasoningConfig { + effort: r.effort.map(to_effort), + max_tokens: r.max_tokens, + exclude: r.exclude, + enabled: r.enabled, + } +} + +/// Converts a [`forge_domain::ReasoningConfig`] back into a +/// [`forge_config::ReasoningConfig`]. +fn from_reasoning_config(r: &ReasoningConfig) -> forge_config::ReasoningConfig { + forge_config::ReasoningConfig { + effort: r.effort.clone().map(from_effort), + max_tokens: r.max_tokens, + exclude: r.exclude, + enabled: r.enabled, + } +} + /// Builds a [`forge_domain::Environment`] entirely from a [`ForgeConfig`] and /// runtime context (`restricted`, `cwd`), mapping every config field to its /// corresponding environment field. @@ -178,6 +224,7 @@ fn to_environment(fc: ForgeConfig, cwd: PathBuf) -> Environment { max_requests_per_turn: fc.max_requests_per_turn, compact: fc.compact.map(to_compact), updates: fc.updates.map(to_update), + reasoning: fc.reasoning.map(to_reasoning_config), } } @@ -339,6 +386,7 @@ fn to_forge_config(env: &Environment) -> ForgeConfig { fc.max_requests_per_turn = env.max_requests_per_turn; fc.compact = env.compact.as_ref().map(from_compact); fc.updates = env.updates.as_ref().map(from_update); + fc.reasoning = env.reasoning.as_ref().map(from_reasoning_config); // --- Session configs --- fc.session = env.session.as_ref().map(|sc| ModelConfig { diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json index 178676fd9b..cc445c9c01 100644 --- a/crates/forge_main/src/built_in_commands.json +++ b/crates/forge_main/src/built_in_commands.json @@ -31,6 +31,14 @@ "command": "config-suggest-model", "description": "Set the model used for command suggestion generation [alias: csm]" }, + { + "command": "reasoning", + "description": "Switch the reasoning effort for the current session only, without modifying global config [alias: re]" + }, + { + "command": "config-reasoning", + "description": "Set the global reasoning effort level [alias: cr]" + }, { "command": "config", "description": "List current configuration values" diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 622ad7011b..04d7642641 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -10,6 +10,37 @@ use std::path::PathBuf; use clap::{Parser, Subcommand, ValueEnum}; use forge_domain::{AgentId, ConversationId, ModelId, ProviderId}; +/// Reasoning effort level for CLI configuration. +#[derive(Copy, Clone, Debug, ValueEnum)] +#[clap(rename_all = "lower")] +pub enum CliEffort { + /// 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. + Xhigh, +} + +impl From for forge_domain::Effort { + fn from(e: CliEffort) -> Self { + match e { + CliEffort::None => forge_domain::Effort::None, + CliEffort::Minimal => forge_domain::Effort::Minimal, + CliEffort::Low => forge_domain::Effort::Low, + CliEffort::Medium => forge_domain::Effort::Medium, + CliEffort::High => forge_domain::Effort::High, + CliEffort::Xhigh => forge_domain::Effort::XHigh, + } + } +} + #[derive(Parser)] #[command(version = env!("CARGO_PKG_VERSION"))] pub struct Cli { @@ -542,6 +573,11 @@ pub enum ConfigSetField { /// Model ID to use for command suggestion generation. model: ModelId, }, + /// Set the global reasoning effort level. + Reasoning { + /// Reasoning effort level (none/minimal/low/medium/high/xhigh). + effort: CliEffort, + }, } /// Type-safe subcommands for `forge config get`. @@ -555,6 +591,8 @@ pub enum ConfigGetField { Commit, /// Get the command suggestion generation config. Suggest, + /// Get the global reasoning effort level. + Reasoning, } /// Command group for conversation management. @@ -952,6 +990,38 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn test_config_set_reasoning_effort() { + let fixture = Cli::parse_from(["forge", "config", "set", "reasoning", "high"]); + let actual = match fixture.subcommands { + Some(TopLevelCommand::Config(config)) => match config.command { + ConfigCommand::Set(args) => match args.field { + ConfigSetField::Reasoning { effort } => { + Some(forge_domain::Effort::from(effort).to_string()) + } + _ => None, + }, + _ => None, + }, + _ => None, + }; + let expected = Some("high".to_string()); + assert_eq!(actual, expected); + } + + #[test] + fn test_config_get_reasoning() { + let fixture = Cli::parse_from(["forge", "config", "get", "reasoning"]); + let actual = match fixture.subcommands { + Some(TopLevelCommand::Config(config)) => match config.command { + ConfigCommand::Get(args) => matches!(args.field, ConfigGetField::Reasoning), + _ => panic!("Expected ConfigCommand::Get"), + }, + _ => panic!("Expected TopLevelCommand::Config"), + }; + assert!(actual); + } + #[test] fn test_conversation_list() { let fixture = Cli::parse_from(["forge", "conversation", "list"]); diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index bca52acd0c..491fc6dedb 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3478,6 +3478,16 @@ impl A + Send + Sync> UI { format!("is now the suggest model for provider '{provider}'"), ))?; } + ConfigSetField::Reasoning { effort } => { + let domain_effort = forge_domain::Effort::from(effort); + let reasoning = + forge_domain::ReasoningConfig::default().effort(domain_effort.clone()); + self.api.set_reasoning_config(reasoning).await?; + self.writeln_title( + TitleFormat::action(domain_effort.to_string().as_str()) + .sub_title("is now the reasoning effort level"), + )?; + } } Ok(()) @@ -3539,6 +3549,19 @@ impl A + Send + Sync> UI { None => self.writeln("Suggest: Not set")?, } } + ConfigGetField::Reasoning => { + let reasoning = self.api.get_reasoning_config().await?; + match reasoning { + Some(config) => { + let effort = config + .effort + .map(|e| e.to_string()) + .unwrap_or_else(|| "Not set".to_string()); + self.writeln(effort)?; + } + None => self.writeln("Reasoning: Not set")?, + } + } } Ok(()) diff --git a/crates/forge_repo/src/conversation/conversation_record.rs b/crates/forge_repo/src/conversation/conversation_record.rs index c8d0a263b4..614b6f8b0f 100644 --- a/crates/forge_repo/src/conversation/conversation_record.rs +++ b/crates/forge_repo/src/conversation/conversation_record.rs @@ -647,17 +647,24 @@ impl From for forge_domain::ToolChoice { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(super) enum EffortRecord { - High, - Medium, + None, + Minimal, Low, + Medium, + High, + #[serde(rename = "xhigh")] + XHigh, } impl From<&forge_domain::Effort> for EffortRecord { fn from(effort: &forge_domain::Effort) -> Self { match effort { - forge_domain::Effort::High => Self::High, - forge_domain::Effort::Medium => Self::Medium, + forge_domain::Effort::None => Self::None, + forge_domain::Effort::Minimal => Self::Minimal, forge_domain::Effort::Low => Self::Low, + forge_domain::Effort::Medium => Self::Medium, + forge_domain::Effort::High => Self::High, + forge_domain::Effort::XHigh => Self::XHigh, } } } @@ -665,9 +672,12 @@ impl From<&forge_domain::Effort> for EffortRecord { impl From for forge_domain::Effort { fn from(record: EffortRecord) -> Self { match record { - EffortRecord::High => Self::High, - EffortRecord::Medium => Self::Medium, + EffortRecord::None => Self::None, + EffortRecord::Minimal => Self::Minimal, EffortRecord::Low => Self::Low, + EffortRecord::Medium => Self::Medium, + EffortRecord::High => Self::High, + EffortRecord::XHigh => Self::XHigh, } } } diff --git a/crates/forge_repo/src/provider/bedrock.rs b/crates/forge_repo/src/provider/bedrock.rs index 9e949cbc23..f3c1c6a440 100644 --- a/crates/forge_repo/src/provider/bedrock.rs +++ b/crates/forge_repo/src/provider/bedrock.rs @@ -561,34 +561,46 @@ impl FromDomain // Based on AWS Bedrock docs: additionalModelRequestFields for Claude extended // thinking https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html let additional_model_fields = if let Some(reasoning_config) = &context.reasoning { - if reasoning_config.enabled.unwrap_or(false) { - let mut thinking_config = std::collections::HashMap::new(); - thinking_config.insert( - "type".to_string(), - aws_smithy_types::Document::String("enabled".to_string()), - ); - - // Set budget_tokens (REQUIRED when thinking is enabled) - // The budget_tokens parameter determines the maximum number of tokens - // Claude is allowed to use for its internal reasoning process - // Default to 4000 if not specified (AWS recommendation for good quality) - let budget_tokens = reasoning_config.max_tokens.unwrap_or(4000); - thinking_config.insert( - "budget_tokens".to_string(), - aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt( - budget_tokens as u64, - )), - ); - - let mut fields = std::collections::HashMap::new(); - fields.insert( - "thinking".to_string(), - aws_smithy_types::Document::Object(thinking_config), - ); - - Some(aws_smithy_types::Document::Object(fields)) - } else { + // Effort::None explicitly disables thinking + if matches!(reasoning_config.effort, Some(forge_domain::Effort::None)) { None + } else { + // Map effort variant to a token budget; takes priority over max_tokens + let effort_budget: Option = + reasoning_config.effort.as_ref().map(|e| match e { + forge_domain::Effort::None => 0, // unreachable — handled above + forge_domain::Effort::Minimal => 1024, + forge_domain::Effort::Low => 2048, + forge_domain::Effort::Medium => 8192, + forge_domain::Effort::High => 16384, + forge_domain::Effort::XHigh => 32768, + }); + let should_enable = + effort_budget.is_some() || reasoning_config.enabled.unwrap_or(false); + if should_enable { + let budget_tokens = effort_budget + .or(reasoning_config.max_tokens) + .unwrap_or(4000); + let mut thinking_config = std::collections::HashMap::new(); + thinking_config.insert( + "type".to_string(), + aws_smithy_types::Document::String("enabled".to_string()), + ); + thinking_config.insert( + "budget_tokens".to_string(), + aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt( + budget_tokens as u64, + )), + ); + let mut fields = std::collections::HashMap::new(); + fields.insert( + "thinking".to_string(), + aws_smithy_types::Document::Object(thinking_config), + ); + Some(aws_smithy_types::Document::Object(fields)) + } else { + None + } } } else { None diff --git a/crates/forge_repo/src/provider/openai_responses/request.rs b/crates/forge_repo/src/provider/openai_responses/request.rs index 07546c5933..e576c975a6 100644 --- a/crates/forge_repo/src/provider/openai_responses/request.rs +++ b/crates/forge_repo/src/provider/openai_responses/request.rs @@ -113,11 +113,14 @@ impl FromDomain for oai::Reasoning { // Map effort level if let Some(effort) = config.effort { let oai_effort = match effort { - Effort::High => oai::ReasoningEffort::High, - Effort::Medium => oai::ReasoningEffort::Medium, - Effort::Low => oai::ReasoningEffort::Low, + Effort::XHigh | Effort::High => Some(oai::ReasoningEffort::High), + Effort::Medium => Some(oai::ReasoningEffort::Medium), + Effort::Low | Effort::Minimal => Some(oai::ReasoningEffort::Low), + Effort::None => None, }; - builder.effort(oai_effort); + if let Some(oai_effort) = oai_effort { + builder.effort(oai_effort); + } } else if config.enabled.unwrap_or(false) { // Default to Medium effort when enabled without explicit effort builder.effort(oai::ReasoningEffort::Medium); diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 1ef9f182a0..e6057ea6a1 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -2,7 +2,8 @@ use std::sync::Arc; use forge_app::{AppConfigService, EnvironmentInfra}; use forge_domain::{ - CommitConfig, ConfigOperation, ModelId, ProviderId, ProviderRepository, SuggestConfig, + CommitConfig, ConfigOperation, ModelId, ProviderId, ProviderRepository, ReasoningConfig, + SuggestConfig, }; use tracing::debug; @@ -127,6 +128,15 @@ impl AppConfigService self.update(ConfigOperation::SetSuggestConfig(suggest_config)) .await } + + async fn get_reasoning_config(&self) -> anyhow::Result> { + let env = self.infra.get_environment(); + Ok(env.reasoning) + } + + async fn set_reasoning_config(&self, reasoning: ReasoningConfig) -> anyhow::Result<()> { + self.update(ConfigOperation::SetReasoning(reasoning)).await + } } #[cfg(test)] @@ -262,6 +272,9 @@ mod tests { .model_id(suggest.model.to_string()), ); } + ConfigOperation::SetReasoning(reasoning) => { + env.reasoning = Some(reasoning); + } } } Ok(()) diff --git a/forge.schema.json b/forge.schema.json index b529d71e07..f86a7d78b2 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -240,6 +240,17 @@ "format": "uint64", "minimum": 0 }, + "reasoning": { + "description": "Reasoning configuration applied to all agents; agent-level settings\ntake priority over this global setting when both are present.", + "anyOf": [ + { + "$ref": "#/$defs/ReasoningConfig" + }, + { + "type": "null" + } + ] + }, "restricted": { "description": "Whether restricted mode is active; when enabled, tool execution requires\nexplicit permission grants.", "type": [ @@ -439,6 +450,41 @@ } } }, + "Effort": { + "description": "Effort level for the reasoning capability.", + "oneOf": [ + { + "description": "No reasoning tokens; disables extended thinking entirely.", + "type": "string", + "const": "none" + }, + { + "description": "Minimal reasoning; fastest, fewest thinking tokens.", + "type": "string", + "const": "minimal" + }, + { + "description": "Constrained reasoning suitable for straightforward tasks.", + "type": "string", + "const": "low" + }, + { + "description": "Balanced reasoning for moderately complex tasks.", + "type": "string", + "const": "medium" + }, + { + "description": "Deep reasoning for complex problems.", + "type": "string", + "const": "high" + }, + { + "description": "Maximum reasoning budget for the hardest tasks.", + "type": "string", + "const": "xhigh" + } + ] + }, "HttpConfig": { "description": "HTTP client configuration.", "type": "object", @@ -568,6 +614,46 @@ } } }, + "ReasoningConfig": { + "description": "Reasoning configuration applied to all agents when set at the global level.\n\nControls the reasoning capabilities of the model. When set here, it acts as\na default for all agents; agent-level settings take priority over this\nglobal setting.", + "type": "object", + "properties": { + "effort": { + "description": "Controls the effort level of the agent's reasoning.\nSupported by openrouter and forge provider.", + "anyOf": [ + { + "$ref": "#/$defs/Effort" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "description": "Enables reasoning at the \"medium\" effort level with no exclusions.\nSupported by openrouter, anthropic and forge provider.", + "type": [ + "boolean", + "null" + ] + }, + "exclude": { + "description": "Model thinks deeply, but the reasoning is hidden from you.\nSupported by openrouter and forge provider.", + "type": [ + "boolean", + "null" + ] + }, + "max_tokens": { + "description": "Controls how many tokens the model can spend thinking.\nSupported by openrouter, anthropic and forge provider.\nShould be greater than 1024 but less than overall max_tokens.", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0 + } + } + }, "RetryConfig": { "description": "Configuration for retry mechanism.", "type": "object", diff --git a/shell-plugin/forge.theme.zsh b/shell-plugin/forge.theme.zsh index 065f275a0b..59e4cbaaa1 100644 --- a/shell-plugin/forge.theme.zsh +++ b/shell-plugin/forge.theme.zsh @@ -17,6 +17,7 @@ function _forge_prompt_info() { forge_cmd+=(zsh rprompt) [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" + [[ -n "$_FORGE_SESSION_REASONING" ]] && local -x FORGE_REASONING__EFFORT="$_FORGE_SESSION_REASONING" _FORGE_CONVERSATION_ID=$_FORGE_CONVERSATION_ID _FORGE_ACTIVE_AGENT=$_FORGE_ACTIVE_AGENT "${forge_cmd[@]}" } diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index a05a331b72..ddc5adae85 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -436,3 +436,89 @@ function _forge_action_skill() { echo _forge_exec list skill } + +# Helper: Print the static list of reasoning effort levels. +# Outputs a two-column header + data table (EFFORT DESCRIPTION) for fzf. +function _forge_reasoning_levels() { + printf "EFFORT DESCRIPTION\n" + printf "none No reasoning tokens; disables extended thinking entirely\n" + printf "minimal Minimal reasoning; fastest, fewest thinking tokens\n" + printf "low Constrained reasoning suitable for straightforward tasks\n" + printf "medium Balanced reasoning for moderately complex tasks\n" + printf "high Deep reasoning for complex problems\n" + printf "xhigh Maximum reasoning budget for the hardest tasks\n" +} + +# Action handler: Select reasoning effort level for the current session only. +# Sets _FORGE_SESSION_REASONING in the shell environment so that every +# subsequent forge invocation uses FORGE_REASONING__EFFORT without touching +# the permanent global configuration. +function _forge_action_reasoning() { + local input_text="$1" + echo + + local levels + levels=$(_forge_reasoning_levels) + + local fzf_args=( + --delimiter="$_FORGE_DELIMITER" + --with-nth="1,2" + --prompt="Reasoning ❯ " + ) + + if [[ -n "$input_text" ]]; then + fzf_args+=(--query="$input_text") + fi + + if [[ -n "$_FORGE_SESSION_REASONING" ]]; then + local index=$(_forge_find_index "$levels" "$_FORGE_SESSION_REASONING" 1) + fzf_args+=(--bind="start:pos($index)") + fi + + local selected + selected=$(echo "$levels" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + + if [[ -n "$selected" ]]; then + local effort="${selected%% *}" + _FORGE_SESSION_REASONING="$effort" + _forge_log success "Session reasoning effort set to \033[1m${effort}\033[0m" + fi +} + +# Action handler: Set the global reasoning effort level in the persistent config. +# Calls `forge config set reasoning ` to write to ~/forge/.forge.toml. +function _forge_action_config_reasoning() { + local input_text="$1" + ( + echo + + local levels + levels=$(_forge_reasoning_levels) + + local current_effort + current_effort=$(_forge_exec config get reasoning 2>/dev/null) + + local fzf_args=( + --delimiter="$_FORGE_DELIMITER" + --with-nth="1,2" + --prompt="Config Reasoning ❯ " + ) + + if [[ -n "$input_text" ]]; then + fzf_args+=(--query="$input_text") + fi + + if [[ -n "$current_effort" ]]; then + local index=$(_forge_find_index "$levels" "$current_effort" 1) + fzf_args+=(--bind="start:pos($index)") + fi + + local selected + selected=$(echo "$levels" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + + if [[ -n "$selected" ]]; then + local effort="${selected%% *}" + _forge_exec config set reasoning "$effort" + fi + ) +} diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index 0a7510b321..1a74ed3371 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -34,3 +34,8 @@ typeset -h _FORGE_PREVIOUS_CONVERSATION_ID # invocation for the lifetime of the current shell session. typeset -h _FORGE_SESSION_MODEL typeset -h _FORGE_SESSION_PROVIDER + +# Session-scoped reasoning effort override (set via :reasoning / :re). +# When non-empty, FORGE_REASONING__EFFORT is exported to every forge +# invocation for the lifetime of the current shell session. +typeset -h _FORGE_SESSION_REASONING diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 43fcc89688..0fcea7ab0c 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -184,6 +184,12 @@ function forge-accept-line() { config-suggest-model|csm) _forge_action_suggest_model "$input_text" ;; + reasoning|re) + _forge_action_reasoning "$input_text" + ;; + config-reasoning|cr) + _forge_action_config_reasoning "$input_text" + ;; tools|t) _forge_action_tools ;; diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index 03bcece244..8c0b55c7b8 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -25,6 +25,7 @@ function _forge_exec() { cmd+=("$@") [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" + [[ -n "$_FORGE_SESSION_REASONING" ]] && local -x FORGE_REASONING__EFFORT="$_FORGE_SESSION_REASONING" "${cmd[@]}" } @@ -41,6 +42,7 @@ function _forge_exec_interactive() { cmd+=("$@") [[ -n "$_FORGE_SESSION_MODEL" ]] && local -x FORGE_SESSION__MODEL_ID="$_FORGE_SESSION_MODEL" [[ -n "$_FORGE_SESSION_PROVIDER" ]] && local -x FORGE_SESSION__PROVIDER_ID="$_FORGE_SESSION_PROVIDER" + [[ -n "$_FORGE_SESSION_REASONING" ]] && local -x FORGE_REASONING__EFFORT="$_FORGE_SESSION_REASONING" "${cmd[@]}" /dev/tty }