Skip to main content

traitclaw_core/
default_strategy.rs

1//! Default strategy — preserves v0.1.0 agent loop behavior.
2//!
3//! This module provides `DefaultStrategy`, which encapsulates the original
4//! agent runtime loop. When no custom strategy is configured, this strategy
5//! is used automatically, ensuring backward compatibility.
6
7use std::time::Instant;
8
9use async_trait::async_trait;
10
11use crate::agent::{AgentOutput, RunUsage};
12use crate::traits::execution_strategy::PendingToolCall;
13use crate::traits::hook::HookAction;
14use crate::traits::strategy::{AgentRuntime, AgentStrategy};
15use crate::types::agent_state::AgentState;
16use crate::types::completion::{CompletionRequest, ResponseContent};
17use crate::types::message::Message;
18use crate::types::tool_call::ToolCall;
19use crate::Result;
20
21/// The default agent strategy preserving v0.1.0 behavior.
22///
23/// This strategy implements the standard agent loop:
24/// 1. Load context (memory + system prompt + user message)
25/// 2. Loop: LLM call → parse response → execute tools → repeat
26/// 3. Exit when LLM returns text instead of tool calls
27pub struct DefaultStrategy;
28
29#[async_trait]
30impl AgentStrategy for DefaultStrategy {
31    #[tracing::instrument(skip_all, fields(session_id = session_id, model = %runtime.provider.model_info().name))]
32    async fn execute(
33        &self,
34        runtime: &AgentRuntime,
35        input: &str,
36        session_id: &str,
37    ) -> Result<AgentOutput> {
38        let start = Instant::now();
39        let model_info = runtime.provider.model_info();
40
41        // Fire on_agent_start hooks
42        for hook in &runtime.hooks {
43            hook.on_agent_start(input).await;
44        }
45
46        let mut state = AgentState::new(model_info.tier, model_info.context_window);
47        if let Some(budget) = runtime.config.token_budget {
48            state.token_budget = budget;
49        }
50
51        let mut messages = match load_context(runtime, session_id, input).await {
52            Ok(msgs) => msgs,
53            Err(e) => {
54                for hook in &runtime.hooks {
55                    hook.on_error(&e).await;
56                }
57                return Err(e);
58            }
59        };
60        let tool_schemas = runtime.tools.iter().map(|t| t.schema()).collect::<Vec<_>>();
61
62        // === Agent Loop ===
63        for _iteration in 0..runtime.config.max_iterations {
64            state.iteration_count += 1;
65            runtime.tracker.on_iteration(&mut state);
66
67            inject_hints(runtime, &state, &mut messages);
68
69            runtime
70                .context_manager
71                .prepare(&mut messages, model_info.context_window, &mut state)
72                .await;
73
74            let request = CompletionRequest {
75                model: model_info.name.clone(),
76                messages: messages.clone(),
77                tools: tool_schemas.clone(),
78                max_tokens: runtime.config.max_tokens,
79                temperature: runtime.config.temperature,
80                response_format: None,
81                stream: false,
82            };
83
84            // Fire on_provider_start hooks
85            for hook in &runtime.hooks {
86                hook.on_provider_start(&request).await;
87            }
88
89            let provider_start = Instant::now();
90            let llm_span = tracing::info_span!(
91                target: "traitclaw::llm",
92                "gen_ai.chat",
93                gen_ai.system = "traitclaw",
94                gen_ai.request.model = %model_info.name,
95                gen_ai.usage.input_tokens = tracing::field::Empty,
96                gen_ai.usage.output_tokens = tracing::field::Empty,
97            );
98            let _llm_guard = llm_span.enter();
99            let provider_result = runtime.provider.complete(request).await;
100            drop(_llm_guard);
101
102            let response = match provider_result {
103                Ok(res) => {
104                    llm_span.record("gen_ai.usage.input_tokens", res.usage.prompt_tokens);
105                    llm_span.record("gen_ai.usage.output_tokens", res.usage.completion_tokens);
106                    res
107                }
108                Err(e) => {
109                    for hook in &runtime.hooks {
110                        hook.on_error(&e).await;
111                    }
112                    return Err(e);
113                }
114            };
115            let provider_duration = provider_start.elapsed();
116
117            // Fire on_provider_end hooks
118            for hook in &runtime.hooks {
119                hook.on_provider_end(&response, provider_duration).await;
120            }
121
122            state.token_usage += response.usage.total_tokens;
123            state.total_context_tokens = response.usage.prompt_tokens;
124            runtime.tracker.on_llm_response(&response, &mut state);
125
126            match response.content {
127                ResponseContent::Text(text) => {
128                    let assistant_msg = Message::assistant(&text);
129                    if let Err(e) = runtime.memory.append(session_id, assistant_msg).await {
130                        tracing::warn!("Failed to save assistant response to memory: {e}");
131                    }
132
133                    let usage = RunUsage {
134                        tokens: state.token_usage,
135                        iterations: state.iteration_count,
136                        duration: start.elapsed(),
137                    };
138
139                    #[allow(clippy::cast_possible_truncation)]
140                    let duration_ms = usage.duration.as_millis() as u64;
141
142                    tracing::info!(
143                        iterations = usage.iterations,
144                        tokens = usage.tokens,
145                        duration_ms,
146                        "Agent completed"
147                    );
148
149                    let output = AgentOutput::text_with_usage(text, usage);
150
151                    // Fire on_agent_end hooks
152                    for hook in &runtime.hooks {
153                        hook.on_agent_end(&output, start.elapsed()).await;
154                    }
155
156                    return Ok(output);
157                }
158                ResponseContent::ToolCalls(tool_calls) => {
159                    process_tool_calls(runtime, &tool_calls, &state, &mut messages).await;
160                }
161            }
162        }
163
164        let err = crate::Error::Runtime(format!(
165            "Agent reached maximum iterations ({})",
166            runtime.config.max_iterations
167        ));
168
169        // Fire on_error hooks
170        for hook in &runtime.hooks {
171            hook.on_error(&err).await;
172        }
173
174        Err(err)
175    }
176
177    fn stream(
178        &self,
179        runtime: &AgentRuntime,
180        input: &str,
181        session_id: &str,
182    ) -> std::pin::Pin<
183        Box<dyn tokio_stream::Stream<Item = Result<crate::types::stream::StreamEvent>> + Send>,
184    > {
185        // Fire synchronous starting hooks if any (currently all hooks in v0.2.0 are async so we emit them inside the spawned stream task)
186        crate::streaming::stream_runtime(runtime.clone(), input.to_string(), session_id.to_string())
187    }
188}
189
190/// Load conversation context: history + system prompt + user message.
191async fn load_context(
192    runtime: &AgentRuntime,
193    session_id: &str,
194    input: &str,
195) -> Result<Vec<Message>> {
196    let mut messages = runtime
197        .memory
198        .messages(session_id)
199        .await
200        .unwrap_or_else(|e| {
201            tracing::warn!("Failed to load memory (continuing fresh): {e}");
202            Vec::new()
203        });
204
205    if let Some(ref system_prompt) = runtime.config.system_prompt {
206        if messages.is_empty() || messages[0].role != crate::types::message::MessageRole::System {
207            messages.insert(0, Message::system(system_prompt));
208        }
209    }
210
211    let user_msg = Message::user(input);
212    messages.push(user_msg.clone());
213
214    if let Err(e) = runtime.memory.append(session_id, user_msg).await {
215        tracing::warn!("Failed to save user message to memory: {e}");
216    }
217
218    Ok(messages)
219}
220
221/// Check hints and inject guidance messages.
222fn inject_hints(runtime: &AgentRuntime, state: &AgentState, messages: &mut Vec<Message>) {
223    for hint in &runtime.hints {
224        if hint.should_trigger(state) {
225            let hint_msg = hint.generate(state);
226            messages.push(Message {
227                role: hint_msg.role,
228                content: hint_msg.content,
229                tool_call_id: None,
230            });
231            tracing::debug!(
232                target: "traitclaw::hint",
233                hint_name = hint.name(),
234                "Hint injected"
235            );
236        }
237    }
238}
239
240/// Process tool calls with hook interception support.
241async fn process_tool_calls(
242    runtime: &AgentRuntime,
243    tool_calls: &[ToolCall],
244    state: &AgentState,
245    messages: &mut Vec<Message>,
246) {
247    if tool_calls.is_empty() {
248        tracing::debug!("process_tool_calls: empty tool-call slice, skipping");
249        return;
250    }
251
252    let summary: Vec<String> = tool_calls
253        .iter()
254        .map(|tc| format!("{}({})", tc.name, tc.arguments))
255        .collect();
256    messages.push(Message::assistant(format!(
257        "[Tool calls: {}]",
258        summary.join(", ")
259    )));
260
261    // Check hooks for interception before executing
262    for tc in tool_calls {
263        let mut blocked = false;
264
265        for hook in &runtime.hooks {
266            if let HookAction::Block(reason) =
267                hook.before_tool_execute(&tc.name, &tc.arguments).await
268            {
269                messages.push(Message::tool_result(&tc.id, &reason));
270                tracing::debug!(
271                    tool = tc.name.as_str(),
272                    reason = reason.as_str(),
273                    "Tool blocked by hook"
274                );
275                blocked = true;
276                break;
277            }
278        }
279
280        if blocked {
281            continue;
282        }
283
284        let tool_start = Instant::now();
285
286        // Execute single tool via execution strategy
287        let pending = vec![PendingToolCall::from(tc)];
288        let results = runtime
289            .execution_strategy
290            .execute_batch(pending, &runtime.tools, &runtime.guards, state)
291            .await;
292
293        for result in results {
294            let processed = runtime
295                .output_transformer
296                .transform(result.output, &tc.name, state)
297                .await;
298
299            // Fire after_tool_execute hooks
300            for hook in &runtime.hooks {
301                hook.after_tool_execute(&tc.name, &processed, tool_start.elapsed())
302                    .await;
303            }
304
305            messages.push(Message::tool_result(&result.id, &processed));
306            tracing::debug!(tool_call_id = result.id.as_str(), "Tool call processed");
307        }
308    }
309}