File size: 14,806 Bytes
e5034c3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
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};

/// Agent service trait that provides core chat and tool call functionality.
/// This trait abstracts the essential operations needed by the Orchestrator.
#[async_trait::async_trait]
pub trait AgentService: Send + Sync + 'static {
    /// Execute a chat completion request
    async fn chat_agent(
        &self,
        id: &ModelId,
        context: Context,
        provider_id: Option<ProviderId>,
    ) -> ResultStream<ChatCompletionMessage, anyhow::Error>;

    /// Execute a tool call
    async fn call(
        &self,
        agent: &Agent,
        context: &ToolCallContext,
        call: ToolCallFull,
    ) -> ToolResult;

    /// Synchronize the on-going conversation
    async fn update(&self, conversation: Conversation) -> anyhow::Result<()>;
}

/// Blanket implementation of AgentService for any type that implements Services
#[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
    }
}

/// Extension trait for applying workflow-level configuration overrides to an
/// [`Agent`].
///
/// This lives in the application layer because the configuration is built
/// from [`ForgeConfig`] and applied to domain agents at runtime.
pub trait AgentExt {
    /// Applies workflow-level configuration overrides to this agent.
    ///
    /// Fields in `config` always win over agent defaults, except for
    /// `max_tool_failure_per_turn` and `max_requests_per_turn` where the
    /// agent's own value takes priority (i.e. the workflow value is only
    /// applied when the agent has no value set).
    ///
    /// # Arguments
    /// * `config` - The top-level Forge configuration.
    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);
        }

        // Apply workflow compact configuration to agents
        if let Some(ref workflow_compact) = config.compact {
            // Convert forge_config::Compact to forge_domain::Compact, then merge.
            // Agent settings take priority over workflow settings.
            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;
        }

        // Apply workflow reasoning configuration to agents.
        // Agent-level fields take priority; config fills in any unset fields.
        // Exception: config `enabled = false` always wins — it is an explicit
        // global disable that must override any per-agent setting.
        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,
            };
            // Start from the agent's own settings and fill unset fields from config.
            let mut merged = agent.reasoning.clone().unwrap_or_default();
            merged.merge(config_as_domain);
            // If the config explicitly disables reasoning, honour that override
            // regardless of what the agent definition says.
            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"),
        )
    }

    /// When the agent has no reasoning config, the config's reasoning is
    /// applied in full.
    #[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);
    }

    /// When the agent already has reasoning fields set, those fields take
    /// priority; config only fills in fields the agent left unset.
    #[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),
        );

        // Agent overrides effort but leaves enabled and max_tokens unset.
        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) // agent's value wins
                .enabled(true) // filled in from config
                .max_tokens(1024_usize), // filled in from config
        );

        assert_eq!(actual, expected);
    }

    /// When config sets `enabled = false`, it must override the agent's
    /// `enabled = true`. This prevents reasoning parameters from being sent to
    /// models that don't support them (e.g. claude-haiku with effort set).
    #[test]
    fn test_config_disabled_overrides_agent_enabled() {
        let config = ForgeConfig::default().reasoning(
            ConfigReasoningConfig::default()
                .enabled(false)
                .effort(ConfigEffort::None),
        );

        // Agent has reasoning explicitly enabled.
        let agent = fixture_agent().reasoning(
            ReasoningConfig::default()
                .enabled(true)
                .effort(Effort::High),
        );

        let actual = agent.apply_config(&config).reasoning;

        // enabled must be false even though the agent said true.
        assert_eq!(actual.as_ref().and_then(|r| r.enabled), Some(false));
    }

    /// Tests the current behavior: agent compact settings take priority over
    /// workflow config.
    ///
    /// CURRENT BEHAVIOR: When agent has compact settings, they override
    /// workflow settings. This means user's .forge.toml compact settings
    /// are ignored if agent has ANY compact config.
    ///
    /// Note: The apply_config comment says "Agent settings take priority over
    /// workflow settings", which is implemented via the merge() call that
    /// overwrites workflow values with agent values.
    #[test]
    fn test_compact_agent_settings_take_priority_over_workflow_config() {
        use forge_config::Percentage;

        // Workflow config with custom compact settings (from .forge.toml)
        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);

        // Agent with default compact config - retention_window=0 from Default
        let agent = fixture_agent();

        let actual = agent.apply_config(&config).compact;

        // CURRENT BEHAVIOR: Due to merge order (workflow_compact merged with
        // agent.compact), agent's retention_window=0 overwrites workflow's 10
        // This is the documented behavior: "Agent settings take priority over workflow
        // settings"

        // Agent default has retention_window=0, which overwrites workflow's 10
        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."
        );

        // Agent default has token_threshold=None, workflow's 80000 should apply
        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"
        );
    }

    /// Tests the current behavior when agent has partial compact config:
    /// those agent values override workflow values.
    ///
    /// CURRENT BEHAVIOR: If agent sets ANY compact field, that value wins over
    /// workflow config. Only fields where agent has None will get workflow
    /// values.
    #[test]
    fn test_compact_partial_agent_settings_override_workflow_values() {
        use forge_config::Percentage;
        use forge_domain::Compact as DomainCompact;

        // Workflow config with ALL settings
        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);

        // Agent with PARTIAL compact config (only retention_window set to 5)
        let agent = fixture_agent().compact(
            DomainCompact::new()
                .retention_window(5_usize)
                .token_threshold_percentage(0.25_f64),
        );

        let actual = agent.apply_config(&config).compact;

        // CURRENT BEHAVIOR: Agent's retention_window=5 overwrites workflow's 15
        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)."
        );

        // Fields where agent had None get workflow values
        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)"
        );
    }
}