| use std::sync::Arc; |
|
|
| use forge_config::ForgeConfig; |
| use forge_domain::{ |
| Agent, ChatCompletionMessage, Compact, Context, Conversation, Effort, MaxTokens, ModelId, |
| ProviderId, ReasoningConfig, ResultStream, Temperature, ToolCallContext, ToolCallFull, |
| ToolResult, TopK, TopP, |
| }; |
| use merge::Merge; |
|
|
| use crate::services::AppConfigService; |
| use crate::tool_registry::ToolRegistry; |
| use crate::{ConversationService, EnvironmentInfra, ProviderService, Services}; |
|
|
| |
| |
| #[async_trait::async_trait] |
| pub trait AgentService: Send + Sync + 'static { |
| |
| async fn chat_agent( |
| &self, |
| id: &ModelId, |
| context: Context, |
| provider_id: Option<ProviderId>, |
| ) -> ResultStream<ChatCompletionMessage, anyhow::Error>; |
|
|
| |
| async fn call( |
| &self, |
| agent: &Agent, |
| context: &ToolCallContext, |
| call: ToolCallFull, |
| ) -> ToolResult; |
|
|
| |
| async fn update(&self, conversation: Conversation) -> anyhow::Result<()>; |
| } |
|
|
| |
| #[async_trait::async_trait] |
| impl<T: Services + EnvironmentInfra<Config = forge_config::ForgeConfig>> AgentService for T { |
| async fn chat_agent( |
| &self, |
| id: &ModelId, |
| context: Context, |
| provider_id: Option<ProviderId>, |
| ) -> ResultStream<ChatCompletionMessage, anyhow::Error> { |
| let provider_id = if let Some(provider_id) = provider_id { |
| provider_id |
| } else { |
| self.get_session_config() |
| .await |
| .map(|c| c.provider) |
| .ok_or_else(|| forge_domain::Error::NoDefaultSession)? |
| }; |
| let provider = self.get_provider(provider_id).await?; |
|
|
| self.chat(id, context, provider).await |
| } |
|
|
| async fn call( |
| &self, |
| agent: &Agent, |
| context: &ToolCallContext, |
| call: ToolCallFull, |
| ) -> ToolResult { |
| let registry = ToolRegistry::new(Arc::new(self.clone())); |
| registry.call(agent, context, call).await |
| } |
|
|
| async fn update(&self, conversation: Conversation) -> anyhow::Result<()> { |
| self.upsert_conversation(conversation).await |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| pub trait AgentExt { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| fn apply_config(self, config: &ForgeConfig) -> Agent; |
| } |
|
|
| impl AgentExt for Agent { |
| fn apply_config(self, config: &ForgeConfig) -> Agent { |
| let mut agent = self; |
|
|
| if let Some(temperature) = config |
| .temperature |
| .and_then(|d| Temperature::new(d.0 as f32).ok()) |
| { |
| agent.temperature = Some(temperature); |
| } |
|
|
| if let Some(top_p) = config.top_p.and_then(|d| TopP::new(d.0 as f32).ok()) { |
| agent.top_p = Some(top_p); |
| } |
|
|
| if let Some(top_k) = config.top_k.and_then(|k| TopK::new(k).ok()) { |
| agent.top_k = Some(top_k); |
| } |
|
|
| if let Some(max_tokens) = config.max_tokens.and_then(|m| MaxTokens::new(m).ok()) { |
| agent.max_tokens = Some(max_tokens); |
| } |
|
|
| if agent.max_tool_failure_per_turn.is_none() |
| && let Some(max_tool_failure_per_turn) = config.max_tool_failure_per_turn |
| { |
| agent.max_tool_failure_per_turn = Some(max_tool_failure_per_turn); |
| } |
|
|
| agent.tool_supported = Some(config.tool_supported); |
|
|
| if agent.max_requests_per_turn.is_none() |
| && let Some(max_requests_per_turn) = config.max_requests_per_turn |
| { |
| agent.max_requests_per_turn = Some(max_requests_per_turn); |
| } |
|
|
| |
| if let Some(ref workflow_compact) = config.compact { |
| |
| |
| let mut merged_compact = Compact { |
| retention_window: workflow_compact.retention_window, |
| eviction_window: workflow_compact.eviction_window.value(), |
| max_tokens: workflow_compact.max_tokens, |
| token_threshold: workflow_compact.token_threshold, |
| token_threshold_percentage: workflow_compact |
| .token_threshold_percentage |
| .map(|percentage| percentage.value()), |
| turn_threshold: workflow_compact.turn_threshold, |
| message_threshold: workflow_compact.message_threshold, |
| model: workflow_compact.model.as_deref().map(ModelId::new), |
| on_turn_end: workflow_compact.on_turn_end, |
| }; |
| merged_compact.merge(agent.compact.clone()); |
| agent.compact = merged_compact; |
| } |
|
|
| |
| |
| |
| |
| if let Some(ref config_reasoning) = config.reasoning { |
| use forge_config::Effort as ConfigEffort; |
| let config_as_domain = ReasoningConfig { |
| effort: config_reasoning.effort.as_ref().map(|e| match e { |
| ConfigEffort::None => Effort::None, |
| ConfigEffort::Minimal => Effort::Minimal, |
| ConfigEffort::Low => Effort::Low, |
| ConfigEffort::Medium => Effort::Medium, |
| ConfigEffort::High => Effort::High, |
| ConfigEffort::XHigh => Effort::XHigh, |
| ConfigEffort::Max => Effort::Max, |
| }), |
| max_tokens: config_reasoning.max_tokens, |
| exclude: config_reasoning.exclude, |
| enabled: config_reasoning.enabled, |
| }; |
| |
| let mut merged = agent.reasoning.clone().unwrap_or_default(); |
| merged.merge(config_as_domain); |
| |
| |
| if config_reasoning.enabled == Some(false) { |
| merged.enabled = Some(false); |
| } |
| agent.reasoning = Some(merged); |
| } |
|
|
| agent |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use forge_config::{Effort as ConfigEffort, ReasoningConfig as ConfigReasoningConfig}; |
| use forge_domain::{AgentId, Effort, ModelId, ProviderId, ReasoningConfig}; |
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
|
|
| fn fixture_agent() -> Agent { |
| Agent::new( |
| AgentId::new("test"), |
| ProviderId::ANTHROPIC, |
| ModelId::new("claude-3-5-sonnet-20241022"), |
| ) |
| } |
|
|
| |
| |
| #[test] |
| fn test_reasoning_applied_from_config_when_agent_has_none() { |
| let config = ForgeConfig::default().reasoning( |
| ConfigReasoningConfig::default() |
| .enabled(true) |
| .effort(ConfigEffort::Medium), |
| ); |
|
|
| let actual = fixture_agent().apply_config(&config).reasoning; |
|
|
| let expected = Some( |
| ReasoningConfig::default() |
| .enabled(true) |
| .effort(Effort::Medium), |
| ); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| |
| |
| #[test] |
| fn test_reasoning_agent_fields_take_priority_over_config() { |
| let config = ForgeConfig::default().reasoning( |
| ConfigReasoningConfig::default() |
| .enabled(true) |
| .effort(ConfigEffort::Low) |
| .max_tokens(1024_usize), |
| ); |
|
|
| |
| let agent = fixture_agent().reasoning(ReasoningConfig::default().effort(Effort::High)); |
|
|
| let actual = agent.apply_config(&config).reasoning; |
|
|
| let expected = Some( |
| ReasoningConfig::default() |
| .effort(Effort::High) |
| .enabled(true) |
| .max_tokens(1024_usize), |
| ); |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| |
| |
| |
| #[test] |
| fn test_config_disabled_overrides_agent_enabled() { |
| let config = ForgeConfig::default().reasoning( |
| ConfigReasoningConfig::default() |
| .enabled(false) |
| .effort(ConfigEffort::None), |
| ); |
|
|
| |
| let agent = fixture_agent().reasoning( |
| ReasoningConfig::default() |
| .enabled(true) |
| .effort(Effort::High), |
| ); |
|
|
| let actual = agent.apply_config(&config).reasoning; |
|
|
| |
| assert_eq!(actual.as_ref().and_then(|r| r.enabled), Some(false)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| #[test] |
| fn test_compact_agent_settings_take_priority_over_workflow_config() { |
| use forge_config::Percentage; |
|
|
| |
| let workflow_compact = forge_config::Compact::default() |
| .retention_window(10_usize) |
| .eviction_window(Percentage::new(0.3).unwrap()) |
| .max_tokens(5000_usize) |
| .token_threshold(80000_usize) |
| .token_threshold_percentage(0.65_f64); |
|
|
| let config = ForgeConfig::default().compact(workflow_compact); |
|
|
| |
| let agent = fixture_agent(); |
|
|
| let actual = agent.apply_config(&config).compact; |
|
|
| |
| |
| |
| |
|
|
| |
| assert_eq!( |
| actual.retention_window, 0, |
| "Agent's retention_window (0) takes priority over workflow's (10). \ |
| This is the CURRENT behavior per apply_config comment. \ |
| If user wants workflow settings to apply, agent should have no compact config set." |
| ); |
|
|
| |
| assert_eq!( |
| actual.token_threshold, |
| Some(80000), |
| "Workflow token_threshold applies because agent default has None" |
| ); |
| assert_eq!( |
| actual.token_threshold_percentage, |
| Some(0.65), |
| "Workflow context-window percentage applies because agent default has None" |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| #[test] |
| fn test_compact_partial_agent_settings_override_workflow_values() { |
| use forge_config::Percentage; |
| use forge_domain::Compact as DomainCompact; |
|
|
| |
| let workflow_compact = forge_config::Compact::default() |
| .retention_window(15_usize) |
| .eviction_window(Percentage::new(0.25).unwrap()) |
| .max_tokens(6000_usize) |
| .token_threshold(90000_usize) |
| .token_threshold_percentage(0.4_f64) |
| .turn_threshold(20_usize); |
|
|
| let config = ForgeConfig::default().compact(workflow_compact); |
|
|
| |
| let agent = fixture_agent().compact( |
| DomainCompact::new() |
| .retention_window(5_usize) |
| .token_threshold_percentage(0.25_f64), |
| ); |
|
|
| let actual = agent.apply_config(&config).compact; |
|
|
| |
| assert_eq!( |
| actual.retention_window, 5, |
| "Agent's retention_window (5) takes priority. \ |
| This is CURRENT behavior: agent.compact.retention_window is Some(5), \ |
| so merge() overwrites workflow's Some(15) with agent's Some(5)." |
| ); |
|
|
| |
| assert_eq!( |
| actual.token_threshold, |
| Some(90000), |
| "Workflow token_threshold applies (agent had None)" |
| ); |
| assert_eq!( |
| actual.token_threshold_percentage, |
| Some(0.25), |
| "Agent's context-window percentage takes priority over workflow's 0.4" |
| ); |
| assert_eq!( |
| actual.turn_threshold, |
| Some(20), |
| "Workflow turn_threshold applies (agent had None)" |
| ); |
| } |
| } |
|
|