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