Skip to main content

dot/agent/
mod.rs

1mod events;
2mod profile;
3
4pub use events::{AgentEvent, QuestionResponder, TodoItem, TodoStatus};
5pub use profile::AgentProfile;
6
7use events::PendingToolCall;
8
9use crate::config::Config;
10use crate::db::Db;
11use crate::provider::{ContentBlock, Message, Provider, Role, StreamEventType, Usage};
12use crate::tools::ToolRegistry;
13use anyhow::Result;
14use std::collections::HashMap;
15use tokio::sync::mpsc::UnboundedSender;
16
17const COMPACT_THRESHOLD: f32 = 0.8;
18const COMPACT_KEEP_MESSAGES: usize = 10;
19
20const TITLE_SYSTEM_PROMPT: &str = "\
21You are a title generator. You output ONLY a thread title. Nothing else.
22
23Generate a brief title that would help the user find this conversation later.
24
25Rules:
26- A single line, 50 characters or fewer
27- No explanations, no quotes, no punctuation wrapping
28- Use the same language as the user message
29- Title must be grammatically correct and read naturally
30- Never include tool names (e.g. read tool, bash tool, edit tool)
31- Focus on the main topic or question the user wants to retrieve
32- Vary your phrasing — avoid repetitive patterns like always starting with \"Analyzing\"
33- When a file is mentioned, focus on WHAT the user wants to do WITH the file
34- Keep exact: technical terms, numbers, filenames, HTTP codes
35- Remove filler words: the, this, my, a, an
36- If the user message is short or conversational (e.g. \"hello\", \"hey\"): \
37  create a title reflecting the user's tone (Greeting, Quick check-in, etc.)";
38
39pub struct Agent {
40    providers: Vec<Box<dyn Provider>>,
41    active: usize,
42    tools: ToolRegistry,
43    db: Db,
44    conversation_id: String,
45    messages: Vec<Message>,
46    profiles: Vec<AgentProfile>,
47    active_profile: usize,
48    pub thinking_budget: u32,
49    cwd: String,
50    agents_context: crate::context::AgentsContext,
51    last_input_tokens: u32,
52    permissions: HashMap<String, String>,
53    snapshots: crate::snapshot::SnapshotManager,
54}
55
56impl Agent {
57    pub fn new(
58        providers: Vec<Box<dyn Provider>>,
59        db: Db,
60        config: &Config,
61        tools: ToolRegistry,
62        profiles: Vec<AgentProfile>,
63        cwd: String,
64        agents_context: crate::context::AgentsContext,
65    ) -> Result<Self> {
66        assert!(!providers.is_empty(), "at least one provider required");
67        let conversation_id =
68            db.create_conversation(providers[0].model(), providers[0].name(), &cwd)?;
69        tracing::debug!("Agent created with conversation {}", conversation_id);
70        let profiles = if profiles.is_empty() {
71            vec![AgentProfile::default_profile()]
72        } else {
73            profiles
74        };
75        Ok(Agent {
76            providers,
77            active: 0,
78            tools,
79            db,
80            conversation_id,
81            messages: Vec::new(),
82            profiles,
83            active_profile: 0,
84            thinking_budget: 0,
85            cwd,
86            agents_context,
87            last_input_tokens: 0,
88            permissions: config.permissions.clone(),
89            snapshots: crate::snapshot::SnapshotManager::new(),
90        })
91    }
92    fn provider(&self) -> &dyn Provider {
93        &*self.providers[self.active]
94    }
95    fn provider_mut(&mut self) -> &mut dyn Provider {
96        &mut *self.providers[self.active]
97    }
98    fn profile(&self) -> &AgentProfile {
99        &self.profiles[self.active_profile]
100    }
101    pub fn conversation_id(&self) -> &str {
102        &self.conversation_id
103    }
104    pub fn messages(&self) -> &[Message] {
105        &self.messages
106    }
107    pub fn set_model(&mut self, model: String) {
108        self.provider_mut().set_model(model);
109    }
110    pub fn set_active_provider(&mut self, provider_name: &str, model: &str) {
111        if let Some(idx) = self
112            .providers
113            .iter()
114            .position(|p| p.name() == provider_name)
115        {
116            self.active = idx;
117            self.providers[idx].set_model(model.to_string());
118        }
119    }
120    pub fn set_thinking_budget(&mut self, budget: u32) {
121        self.thinking_budget = budget;
122    }
123    pub fn available_models(&self) -> Vec<String> {
124        self.provider().available_models()
125    }
126    pub async fn fetch_all_models(&self) -> Vec<(String, Vec<String>)> {
127        let mut result = Vec::new();
128        for p in &self.providers {
129            let models = match p.fetch_models().await {
130                Ok(m) => m,
131                Err(e) => {
132                    tracing::warn!("Failed to fetch models for {}: {e}", p.name());
133                    Vec::new()
134                }
135            };
136            result.push((p.name().to_string(), models));
137        }
138        result
139    }
140    pub fn current_model(&self) -> &str {
141        self.provider().model()
142    }
143    pub fn current_provider_name(&self) -> &str {
144        self.provider().name()
145    }
146    pub fn current_agent_name(&self) -> &str {
147        &self.profile().name
148    }
149    pub fn context_window(&self) -> u32 {
150        self.provider().context_window()
151    }
152    pub async fn fetch_context_window(&self) -> u32 {
153        match self.provider().fetch_context_window().await {
154            Ok(cw) => cw,
155            Err(e) => {
156                tracing::warn!("Failed to fetch context window: {e}");
157                0
158            }
159        }
160    }
161    pub fn agent_profiles(&self) -> &[AgentProfile] {
162        &self.profiles
163    }
164    pub fn switch_agent(&mut self, name: &str) -> bool {
165        if let Some(idx) = self.profiles.iter().position(|p| p.name == name) {
166            self.active_profile = idx;
167            let model_spec = self.profiles[idx].model_spec.clone();
168
169            if let Some(spec) = model_spec {
170                let (provider, model) = Config::parse_model_spec(&spec);
171                if let Some(prov) = provider {
172                    self.set_active_provider(prov, model);
173                } else {
174                    self.set_model(model.to_string());
175                }
176            }
177            tracing::info!("Switched to agent '{}'", name);
178            true
179        } else {
180            false
181        }
182    }
183    pub fn cleanup_if_empty(&mut self) {
184        if self.messages.is_empty() {
185            let _ = self.db.delete_conversation(&self.conversation_id);
186        }
187    }
188    pub fn new_conversation(&mut self) -> Result<()> {
189        self.cleanup_if_empty();
190        let conversation_id = self.db.create_conversation(
191            self.provider().model(),
192            self.provider().name(),
193            &self.cwd,
194        )?;
195        self.conversation_id = conversation_id;
196        self.messages.clear();
197        Ok(())
198    }
199    pub fn resume_conversation(&mut self, conversation: &crate::db::Conversation) -> Result<()> {
200        self.conversation_id = conversation.id.clone();
201        self.messages = conversation
202            .messages
203            .iter()
204            .map(|m| Message {
205                role: if m.role == "user" {
206                    Role::User
207                } else {
208                    Role::Assistant
209                },
210                content: vec![ContentBlock::Text(m.content.clone())],
211            })
212            .collect();
213        tracing::debug!("Resumed conversation {}", conversation.id);
214        Ok(())
215    }
216    pub fn list_sessions(&self) -> Result<Vec<crate::db::ConversationSummary>> {
217        self.db.list_conversations_for_cwd(&self.cwd, 50)
218    }
219    pub fn get_session(&self, id: &str) -> Result<crate::db::Conversation> {
220        self.db.get_conversation(id)
221    }
222    pub fn conversation_title(&self) -> Option<String> {
223        self.db
224            .get_conversation(&self.conversation_id)
225            .ok()
226            .and_then(|c| c.title)
227    }
228    pub fn cwd(&self) -> &str {
229        &self.cwd
230    }
231
232    pub fn truncate_messages(&mut self, count: usize) {
233        let target = count.min(self.messages.len());
234        self.messages.truncate(target);
235    }
236
237    pub fn fork_conversation(&mut self, msg_count: usize) -> Result<()> {
238        let kept = self.messages[..msg_count.min(self.messages.len())].to_vec();
239        self.cleanup_if_empty();
240        let conversation_id = self.db.create_conversation(
241            self.provider().model(),
242            self.provider().name(),
243            &self.cwd,
244        )?;
245        self.conversation_id = conversation_id;
246        self.messages = kept;
247        for msg in &self.messages {
248            let role = match msg.role {
249                Role::User => "user",
250                Role::Assistant => "assistant",
251                Role::System => "system",
252            };
253            let text: String = msg
254                .content
255                .iter()
256                .filter_map(|b| {
257                    if let ContentBlock::Text(t) = b {
258                        Some(t.as_str())
259                    } else {
260                        None
261                    }
262                })
263                .collect::<Vec<_>>()
264                .join("\n");
265            if !text.is_empty() {
266                let _ = self.db.add_message(&self.conversation_id, role, &text);
267            }
268        }
269        Ok(())
270    }
271
272    fn title_model(&self) -> &str {
273        match self.provider().name() {
274            "anthropic" => "claude-3-5-haiku-20241022",
275            "openai" => "gpt-5-nano",
276            _ => self.provider().model(),
277        }
278    }
279
280    fn should_compact(&self) -> bool {
281        let limit = self.provider().context_window();
282        let threshold = (limit as f32 * COMPACT_THRESHOLD) as u32;
283        self.last_input_tokens >= threshold
284    }
285    async fn compact(&mut self, event_tx: &UnboundedSender<AgentEvent>) -> Result<()> {
286        let keep = COMPACT_KEEP_MESSAGES;
287        if self.messages.len() <= keep + 2 {
288            return Ok(());
289        }
290        let cutoff = self.messages.len() - keep;
291        let old_messages = self.messages[..cutoff].to_vec();
292        let kept = self.messages[cutoff..].to_vec();
293
294        let mut summary_text = String::new();
295        for msg in &old_messages {
296            let role = match msg.role {
297                Role::User => "User",
298                Role::Assistant => "Assistant",
299                Role::System => "System",
300            };
301            for block in &msg.content {
302                if let ContentBlock::Text(t) = block {
303                    summary_text.push_str(&format!("{}:\n{}\n\n", role, t));
304                }
305            }
306        }
307        let summary_request = vec![Message {
308            role: Role::User,
309            content: vec![ContentBlock::Text(format!(
310                "Summarize the following conversation history concisely, preserving all key decisions, facts, code changes, and context that would be needed to continue the work:\n\n{}",
311                summary_text
312            ))],
313        }];
314
315        let mut stream_rx = self
316            .provider()
317            .stream(
318                &summary_request,
319                Some("You are a concise summarizer. Produce a dense, factual summary."),
320                &[],
321                4096,
322                0,
323            )
324            .await?;
325        let mut full_summary = String::new();
326        while let Some(event) = stream_rx.recv().await {
327            if let StreamEventType::TextDelta(text) = event.event_type {
328                full_summary.push_str(&text);
329            }
330        }
331        self.messages = vec![
332            Message {
333                role: Role::User,
334                content: vec![ContentBlock::Text(
335                    "[Previous conversation summarized below]".to_string(),
336                )],
337            },
338            Message {
339                role: Role::Assistant,
340                content: vec![ContentBlock::Text(format!(
341                    "Summary of prior context:\n\n{}",
342                    full_summary
343                ))],
344            },
345        ];
346        self.messages.extend(kept);
347
348        let _ = self.db.add_message(
349            &self.conversation_id,
350            "assistant",
351            &format!("[Compacted {} messages into summary]", cutoff),
352        );
353        self.last_input_tokens = 0;
354        let _ = event_tx.send(AgentEvent::Compacted {
355            messages_removed: cutoff,
356        });
357        Ok(())
358    }
359    pub async fn send_message(
360        &mut self,
361        content: &str,
362        event_tx: UnboundedSender<AgentEvent>,
363    ) -> Result<()> {
364        self.send_message_with_images(content, Vec::new(), event_tx)
365            .await
366    }
367
368    pub async fn send_message_with_images(
369        &mut self,
370        content: &str,
371        images: Vec<(String, String)>,
372        event_tx: UnboundedSender<AgentEvent>,
373    ) -> Result<()> {
374        if self.should_compact() {
375            self.compact(&event_tx).await?;
376        }
377        self.db
378            .add_message(&self.conversation_id, "user", content)?;
379        let mut blocks: Vec<ContentBlock> = Vec::new();
380        for (media_type, data) in images {
381            blocks.push(ContentBlock::Image { media_type, data });
382        }
383        blocks.push(ContentBlock::Text(content.to_string()));
384        self.messages.push(Message {
385            role: Role::User,
386            content: blocks,
387        });
388        let title_rx = if self.messages.len() == 1 {
389            let title_messages = vec![Message {
390                role: Role::User,
391                content: vec![ContentBlock::Text(format!(
392                    "Generate a title for this conversation:\n\n{}",
393                    content
394                ))],
395            }];
396            self.provider()
397                .stream_with_model(
398                    self.title_model(),
399                    &title_messages,
400                    Some(TITLE_SYSTEM_PROMPT),
401                    &[],
402                    100,
403                    0,
404                )
405                .await
406                .ok()
407        } else {
408            None
409        };
410        let mut final_usage: Option<Usage> = None;
411        let system_prompt = self
412            .agents_context
413            .apply_to_system_prompt(&self.profile().system_prompt);
414        let tool_filter = self.profile().tool_filter.clone();
415        let thinking_budget = self.thinking_budget;
416        loop {
417            let mut tool_defs = self.tools.definitions_filtered(&tool_filter);
418            tool_defs.push(crate::provider::ToolDefinition {
419                name: "todo_write".to_string(),
420                description: "Create or update the task list for the current session. Use to track progress on multi-step tasks.".to_string(),
421                input_schema: serde_json::json!({
422                    "type": "object",
423                    "properties": {
424                        "todos": {
425                            "type": "array",
426                            "items": {
427                                "type": "object",
428                                "properties": {
429                                    "content": { "type": "string", "description": "Brief description of the task" },
430                                    "status": { "type": "string", "enum": ["pending", "in_progress", "completed"], "description": "Current status" }
431                                },
432                                "required": ["content", "status"]
433                            }
434                        }
435                    },
436                    "required": ["todos"]
437                }),
438            });
439            tool_defs.push(crate::provider::ToolDefinition {
440                name: "question".to_string(),
441                description: "Ask the user a question and wait for their response. Use when you need clarification or a decision from the user.".to_string(),
442                input_schema: serde_json::json!({
443                    "type": "object",
444                    "properties": {
445                        "question": { "type": "string", "description": "The question to ask the user" },
446                        "options": { "type": "array", "items": { "type": "string" }, "description": "Optional list of choices" }
447                    },
448                    "required": ["question"]
449                }),
450            });
451            tool_defs.push(crate::provider::ToolDefinition {
452                name: "snapshot_list".to_string(),
453                description: "List all files that have been created or modified in this session."
454                    .to_string(),
455                input_schema: serde_json::json!({
456                    "type": "object",
457                    "properties": {},
458                }),
459            });
460            tool_defs.push(crate::provider::ToolDefinition {
461                name: "snapshot_restore".to_string(),
462                description: "Restore a file to its original state before this session modified it. Pass a path or omit to restore all files.".to_string(),
463                input_schema: serde_json::json!({
464                    "type": "object",
465                    "properties": {
466                        "path": { "type": "string", "description": "File path to restore (omit to restore all)" }
467                    },
468                }),
469            });
470            let mut stream_rx = self
471                .provider()
472                .stream(
473                    &self.messages,
474                    Some(&system_prompt),
475                    &tool_defs,
476                    8192,
477                    thinking_budget,
478                )
479                .await?;
480            let mut full_text = String::new();
481            let mut full_thinking = String::new();
482            let mut full_thinking_signature = String::new();
483            let mut tool_calls: Vec<PendingToolCall> = Vec::new();
484            let mut current_tool_input = String::new();
485            while let Some(event) = stream_rx.recv().await {
486                match event.event_type {
487                    StreamEventType::TextDelta(text) => {
488                        full_text.push_str(&text);
489                        let _ = event_tx.send(AgentEvent::TextDelta(text));
490                    }
491                    StreamEventType::ThinkingDelta(text) => {
492                        full_thinking.push_str(&text);
493                        let _ = event_tx.send(AgentEvent::ThinkingDelta(text));
494                    }
495                    StreamEventType::ThinkingComplete {
496                        thinking,
497                        signature,
498                    } => {
499                        full_thinking = thinking;
500                        full_thinking_signature = signature;
501                    }
502                    StreamEventType::ToolUseStart { id, name } => {
503                        current_tool_input.clear();
504                        let _ = event_tx.send(AgentEvent::ToolCallStart {
505                            id: id.clone(),
506                            name: name.clone(),
507                        });
508                        tool_calls.push(PendingToolCall {
509                            id,
510                            name,
511                            input: String::new(),
512                        });
513                    }
514                    StreamEventType::ToolUseInputDelta(delta) => {
515                        current_tool_input.push_str(&delta);
516                        let _ = event_tx.send(AgentEvent::ToolCallInputDelta(delta));
517                    }
518                    StreamEventType::ToolUseEnd => {
519                        if let Some(tc) = tool_calls.last_mut() {
520                            tc.input = current_tool_input.clone();
521                        }
522                        current_tool_input.clear();
523                    }
524                    StreamEventType::MessageEnd {
525                        stop_reason: _,
526                        usage,
527                    } => {
528                        self.last_input_tokens = usage.input_tokens;
529                        final_usage = Some(usage);
530                    }
531
532                    _ => {}
533                }
534            }
535
536            let mut content_blocks: Vec<ContentBlock> = Vec::new();
537            if !full_thinking.is_empty() {
538                content_blocks.push(ContentBlock::Thinking {
539                    thinking: full_thinking.clone(),
540                    signature: full_thinking_signature.clone(),
541                });
542            }
543            if !full_text.is_empty() {
544                content_blocks.push(ContentBlock::Text(full_text.clone()));
545            }
546
547            for tc in &tool_calls {
548                let input_value: serde_json::Value =
549                    serde_json::from_str(&tc.input).unwrap_or(serde_json::Value::Null);
550                content_blocks.push(ContentBlock::ToolUse {
551                    id: tc.id.clone(),
552                    name: tc.name.clone(),
553                    input: input_value,
554                });
555            }
556
557            self.messages.push(Message {
558                role: Role::Assistant,
559                content: content_blocks,
560            });
561            let stored_text = if !full_text.is_empty() {
562                full_text.clone()
563            } else {
564                String::from("[tool use]")
565            };
566            let assistant_msg_id =
567                self.db
568                    .add_message(&self.conversation_id, "assistant", &stored_text)?;
569            for tc in &tool_calls {
570                let _ = self
571                    .db
572                    .add_tool_call(&assistant_msg_id, &tc.id, &tc.name, &tc.input);
573            }
574            if tool_calls.is_empty() {
575                let _ = event_tx.send(AgentEvent::TextComplete(full_text));
576                if let Some(usage) = final_usage {
577                    let _ = event_tx.send(AgentEvent::Done { usage });
578                }
579                break;
580            }
581
582            let mut result_blocks: Vec<ContentBlock> = Vec::new();
583
584            for tc in &tool_calls {
585                let input_value: serde_json::Value =
586                    serde_json::from_str(&tc.input).unwrap_or(serde_json::Value::Null);
587                // Virtual tool: todo_write
588                if tc.name == "todo_write" {
589                    if let Some(todos_arr) = input_value.get("todos").and_then(|v| v.as_array()) {
590                        let items: Vec<TodoItem> = todos_arr
591                            .iter()
592                            .filter_map(|t| {
593                                let content = t.get("content")?.as_str()?.to_string();
594                                let status = match t
595                                    .get("status")
596                                    .and_then(|s| s.as_str())
597                                    .unwrap_or("pending")
598                                {
599                                    "in_progress" => TodoStatus::InProgress,
600                                    "completed" => TodoStatus::Completed,
601                                    _ => TodoStatus::Pending,
602                                };
603                                Some(TodoItem { content, status })
604                            })
605                            .collect();
606                        let _ = event_tx.send(AgentEvent::TodoUpdate(items));
607                    }
608                    let _ = event_tx.send(AgentEvent::ToolCallResult {
609                        id: tc.id.clone(),
610                        name: tc.name.clone(),
611                        output: "ok".to_string(),
612                        is_error: false,
613                    });
614                    result_blocks.push(ContentBlock::ToolResult {
615                        tool_use_id: tc.id.clone(),
616                        content: "ok".to_string(),
617                        is_error: false,
618                    });
619                    continue;
620                }
621                // Virtual tool: question
622                if tc.name == "question" {
623                    let question = input_value
624                        .get("question")
625                        .and_then(|v| v.as_str())
626                        .unwrap_or("?")
627                        .to_string();
628                    let options: Vec<String> = input_value
629                        .get("options")
630                        .and_then(|v| v.as_array())
631                        .map(|arr| {
632                            arr.iter()
633                                .filter_map(|v| v.as_str().map(String::from))
634                                .collect()
635                        })
636                        .unwrap_or_default();
637                    let (tx, rx) = tokio::sync::oneshot::channel();
638                    let _ = event_tx.send(AgentEvent::Question {
639                        id: tc.id.clone(),
640                        question: question.clone(),
641                        options,
642                        responder: QuestionResponder(tx),
643                    });
644                    let answer = match rx.await {
645                        Ok(a) => a,
646                        Err(_) => "[cancelled]".to_string(),
647                    };
648                    let _ = event_tx.send(AgentEvent::ToolCallResult {
649                        id: tc.id.clone(),
650                        name: tc.name.clone(),
651                        output: answer.clone(),
652                        is_error: false,
653                    });
654                    result_blocks.push(ContentBlock::ToolResult {
655                        tool_use_id: tc.id.clone(),
656                        content: answer,
657                        is_error: false,
658                    });
659                    continue;
660                }
661                // Virtual tool: snapshot_list
662                if tc.name == "snapshot_list" {
663                    let changes = self.snapshots.list_changes();
664                    let output = if changes.is_empty() {
665                        "No file changes in this session.".to_string()
666                    } else {
667                        changes
668                            .iter()
669                            .map(|(p, k)| format!("{} {}", k.icon(), p))
670                            .collect::<Vec<_>>()
671                            .join("\n")
672                    };
673                    let _ = event_tx.send(AgentEvent::ToolCallResult {
674                        id: tc.id.clone(),
675                        name: tc.name.clone(),
676                        output: output.clone(),
677                        is_error: false,
678                    });
679                    result_blocks.push(ContentBlock::ToolResult {
680                        tool_use_id: tc.id.clone(),
681                        content: output,
682                        is_error: false,
683                    });
684                    continue;
685                }
686                // Virtual tool: snapshot_restore
687                if tc.name == "snapshot_restore" {
688                    let output =
689                        if let Some(path) = input_value.get("path").and_then(|v| v.as_str()) {
690                            match self.snapshots.restore(path) {
691                                Ok(msg) => msg,
692                                Err(e) => e.to_string(),
693                            }
694                        } else {
695                            match self.snapshots.restore_all() {
696                                Ok(msgs) => {
697                                    if msgs.is_empty() {
698                                        "Nothing to restore.".to_string()
699                                    } else {
700                                        msgs.join("\n")
701                                    }
702                                }
703                                Err(e) => e.to_string(),
704                            }
705                        };
706                    let _ = event_tx.send(AgentEvent::ToolCallResult {
707                        id: tc.id.clone(),
708                        name: tc.name.clone(),
709                        output: output.clone(),
710                        is_error: false,
711                    });
712                    result_blocks.push(ContentBlock::ToolResult {
713                        tool_use_id: tc.id.clone(),
714                        content: output,
715                        is_error: false,
716                    });
717                    continue;
718                }
719                // Permission check
720                let perm = self
721                    .permissions
722                    .get(&tc.name)
723                    .map(|s| s.as_str())
724                    .unwrap_or("allow");
725                if perm == "deny" {
726                    let output = format!("Tool '{}' is denied by permissions config.", tc.name);
727                    let _ = event_tx.send(AgentEvent::ToolCallResult {
728                        id: tc.id.clone(),
729                        name: tc.name.clone(),
730                        output: output.clone(),
731                        is_error: true,
732                    });
733                    result_blocks.push(ContentBlock::ToolResult {
734                        tool_use_id: tc.id.clone(),
735                        content: output,
736                        is_error: true,
737                    });
738                    continue;
739                }
740                if perm == "ask" {
741                    let summary = format!("{}: {}", tc.name, &tc.input[..tc.input.len().min(100)]);
742                    let (ptx, prx) = tokio::sync::oneshot::channel();
743                    let _ = event_tx.send(AgentEvent::PermissionRequest {
744                        tool_name: tc.name.clone(),
745                        input_summary: summary,
746                        responder: QuestionResponder(ptx),
747                    });
748                    let answer = match prx.await {
749                        Ok(a) => a,
750                        Err(_) => "deny".to_string(),
751                    };
752                    if answer != "allow" {
753                        let output = format!("Tool '{}' denied by user.", tc.name);
754                        let _ = event_tx.send(AgentEvent::ToolCallResult {
755                            id: tc.id.clone(),
756                            name: tc.name.clone(),
757                            output: output.clone(),
758                            is_error: true,
759                        });
760                        result_blocks.push(ContentBlock::ToolResult {
761                            tool_use_id: tc.id.clone(),
762                            content: output,
763                            is_error: true,
764                        });
765                        continue;
766                    }
767                }
768                // Snapshot before file writes
769                if tc.name == "write_file" || tc.name == "apply_patch" {
770                    if tc.name == "write_file" {
771                        if let Some(path) = input_value.get("path").and_then(|v| v.as_str()) {
772                            self.snapshots.before_write(path);
773                        }
774                    } else if let Some(patches) =
775                        input_value.get("patches").and_then(|v| v.as_array())
776                    {
777                        for patch in patches {
778                            if let Some(path) = patch.get("path").and_then(|v| v.as_str()) {
779                                self.snapshots.before_write(path);
780                            }
781                        }
782                    }
783                }
784                let _ = event_tx.send(AgentEvent::ToolCallExecuting {
785                    id: tc.id.clone(),
786                    name: tc.name.clone(),
787                    input: tc.input.clone(),
788                });
789                let tool_name = tc.name.clone();
790                let tool_input = input_value.clone();
791                let exec_result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
792                    tokio::task::block_in_place(|| self.tools.execute(&tool_name, tool_input))
793                })
794                .await;
795                let (output, is_error) = match exec_result {
796                    Err(_elapsed) => (
797                        format!("Tool '{}' timed out after 30 seconds.", tc.name),
798                        true,
799                    ),
800                    Ok(Err(e)) => (e.to_string(), true),
801                    Ok(Ok(out)) => (out, false),
802                };
803                tracing::debug!(
804                    "Tool '{}' result (error={}): {}",
805                    tc.name,
806                    is_error,
807                    &output[..output.len().min(200)]
808                );
809                let _ = self.db.update_tool_result(&tc.id, &output, is_error);
810                let _ = event_tx.send(AgentEvent::ToolCallResult {
811                    id: tc.id.clone(),
812                    name: tc.name.clone(),
813                    output: output.clone(),
814                    is_error,
815                });
816                result_blocks.push(ContentBlock::ToolResult {
817                    tool_use_id: tc.id.clone(),
818                    content: output,
819                    is_error,
820                });
821            }
822
823            self.messages.push(Message {
824                role: Role::User,
825                content: result_blocks,
826            });
827        }
828
829        if let Some(mut rx) = title_rx {
830            let mut raw = String::new();
831            while let Some(event) = rx.recv().await {
832                if let StreamEventType::TextDelta(text) = event.event_type {
833                    raw.push_str(&text);
834                }
835            }
836            let title = raw
837                .trim()
838                .trim_matches('"')
839                .trim_matches('`')
840                .trim_matches('*')
841                .replace('\n', " ");
842            let title: String = title.chars().take(50).collect();
843            if !title.is_empty() {
844                let _ = self
845                    .db
846                    .update_conversation_title(&self.conversation_id, &title);
847                let _ = event_tx.send(AgentEvent::TitleGenerated(title));
848            }
849        }
850
851        Ok(())
852    }
853}