Skip to main content

dot/agent/
mod.rs

1mod events;
2mod profile;
3mod subagent;
4
5pub use events::{AgentEvent, QuestionResponder, TodoItem, TodoStatus};
6pub use profile::AgentProfile;
7
8use events::PendingToolCall;
9
10use crate::command::CommandRegistry;
11use crate::config::Config;
12use crate::db::Db;
13use crate::extension::{Event, EventContext, HookRegistry, HookResult};
14use crate::memory::MemoryStore;
15use crate::provider::{ContentBlock, Message, Provider, Role, StopReason, StreamEventType, Usage};
16use crate::tools::ToolRegistry;
17use anyhow::{Context, Result};
18use std::collections::HashMap;
19use std::sync::Arc;
20use tokio::sync::mpsc::UnboundedSender;
21use uuid::Uuid;
22
23const COMPACT_THRESHOLD: f32 = 0.8;
24const COMPACT_KEEP_MESSAGES: usize = 10;
25
26const MEMORY_INSTRUCTIONS: &str = "\n\n\
27# Memory
28
29You have persistent memory across conversations. **Core blocks** (above) are always visible — update them via `core_memory_update` for essential user/agent facts. **Archival memory** is searched per turn — use `memory_add`/`memory_search`/`memory_list`/`memory_delete` to manage it.
30
31When the user says \"remember\"/\"forget\"/\"what do you know about me\", use the appropriate memory tool. Memories are also auto-extracted in the background, so focus on explicit requests.";
32
33/// Tool call data for persisting an interrupted assistant message.
34#[derive(Debug, Clone)]
35pub struct InterruptedToolCall {
36    pub name: String,
37    pub input: String,
38    pub output: Option<String>,
39    pub is_error: bool,
40}
41
42const TITLE_SYSTEM_PROMPT: &str = "\
43You are a title generator. You output ONLY a thread title. Nothing else.
44
45Generate a brief title that would help the user find this conversation later.
46
47Rules:
48- A single line, 50 characters or fewer
49- No explanations, no quotes, no punctuation wrapping
50- Use the same language as the user message
51- Title must be grammatically correct and read naturally
52- Never include tool names (e.g. read tool, bash tool, edit tool)
53- Focus on the main topic or question the user wants to retrieve
54- Vary your phrasing — avoid repetitive patterns like always starting with \"Analyzing\"
55- When a file is mentioned, focus on WHAT the user wants to do WITH the file
56- Keep exact: technical terms, numbers, filenames, HTTP codes
57- Remove filler words: the, this, my, a, an
58- If the user message is short or conversational (e.g. \"hello\", \"hey\"): \
59  create a title reflecting the user's tone (Greeting, Quick check-in, etc.)";
60
61pub struct Agent {
62    providers: Vec<Arc<dyn Provider>>,
63    active: usize,
64    tools: Arc<ToolRegistry>,
65    db: Db,
66    memory: Option<Arc<MemoryStore>>,
67    memory_auto_extract: bool,
68    memory_inject_count: usize,
69    conversation_id: String,
70    persisted: bool,
71    messages: Vec<Message>,
72    profiles: Vec<AgentProfile>,
73    active_profile: usize,
74    pub thinking_budget: u32,
75    cwd: String,
76    agents_context: crate::context::AgentsContext,
77    last_input_tokens: u32,
78    permissions: HashMap<String, String>,
79    snapshots: crate::snapshot::SnapshotManager,
80    hooks: HookRegistry,
81    commands: CommandRegistry,
82    subagent_enabled: bool,
83    subagent_max_turns: usize,
84    background_results: Arc<std::sync::Mutex<HashMap<String, String>>>,
85    background_handles: HashMap<String, tokio::task::JoinHandle<()>>,
86    background_tx: Option<UnboundedSender<AgentEvent>>,
87}
88
89impl Agent {
90    #[allow(clippy::too_many_arguments)]
91    pub fn new(
92        providers: Vec<Box<dyn Provider>>,
93        db: Db,
94        config: &Config,
95        memory: Option<Arc<MemoryStore>>,
96        tools: ToolRegistry,
97        profiles: Vec<AgentProfile>,
98        cwd: String,
99        agents_context: crate::context::AgentsContext,
100        hooks: HookRegistry,
101        commands: CommandRegistry,
102    ) -> Result<Self> {
103        assert!(!providers.is_empty(), "at least one provider required");
104        let providers: Vec<Arc<dyn Provider>> = providers.into_iter().map(Arc::from).collect();
105        let conversation_id = uuid::Uuid::new_v4().to_string();
106        tracing::debug!("Agent created with conversation {}", conversation_id);
107        let mut profiles = if profiles.is_empty() {
108            vec![AgentProfile::default_profile()]
109        } else {
110            profiles
111        };
112        if !profiles.iter().any(|p| p.name == "plan") {
113            let at = 1.min(profiles.len());
114            profiles.insert(at, AgentProfile::plan_profile());
115        }
116        Ok(Agent {
117            providers,
118            active: 0,
119            tools: Arc::new(tools),
120            db,
121            memory,
122            memory_auto_extract: config.memory.auto_extract,
123            memory_inject_count: config.memory.inject_count,
124            conversation_id,
125            persisted: false,
126            messages: Vec::new(),
127            profiles,
128            active_profile: 0,
129            thinking_budget: 0,
130            cwd,
131            agents_context,
132            last_input_tokens: 0,
133            permissions: config.permissions.clone(),
134            snapshots: crate::snapshot::SnapshotManager::new(),
135            hooks,
136            commands,
137            subagent_enabled: config.subagents.enabled,
138            subagent_max_turns: config.subagents.max_turns,
139            background_results: Arc::new(std::sync::Mutex::new(HashMap::new())),
140            background_handles: HashMap::new(),
141            background_tx: None,
142        })
143    }
144    fn ensure_persisted(&mut self) -> Result<()> {
145        if !self.persisted {
146            self.db.create_conversation_with_id(
147                &self.conversation_id,
148                self.provider().model(),
149                self.provider().name(),
150                &self.cwd,
151            )?;
152            self.persisted = true;
153        }
154        Ok(())
155    }
156    fn provider(&self) -> &dyn Provider {
157        &*self.providers[self.active]
158    }
159    fn provider_arc(&self) -> Arc<dyn Provider> {
160        Arc::clone(&self.providers[self.active])
161    }
162    pub fn aside_provider(&self) -> Arc<dyn Provider> {
163        Arc::clone(&self.providers[self.active])
164    }
165    pub fn set_background_tx(&mut self, tx: UnboundedSender<AgentEvent>) {
166        self.background_tx = Some(tx);
167    }
168    pub fn background_tx(&self) -> Option<UnboundedSender<AgentEvent>> {
169        self.background_tx.clone()
170    }
171    fn event_context(&self, event: &Event) -> EventContext {
172        EventContext {
173            event: event.as_str().to_string(),
174            model: self.provider().model().to_string(),
175            provider: self.provider().name().to_string(),
176            cwd: self.cwd.clone(),
177            session_id: self.conversation_id.clone(),
178            ..Default::default()
179        }
180    }
181    pub fn execute_command(&self, name: &str, args: &str) -> Result<String> {
182        self.commands.execute(name, args, &self.cwd)
183    }
184    pub fn list_commands(&self) -> Vec<(&str, &str)> {
185        self.commands.list()
186    }
187    pub fn has_command(&self, name: &str) -> bool {
188        self.commands.has(name)
189    }
190    pub fn hooks(&self) -> &HookRegistry {
191        &self.hooks
192    }
193    fn profile(&self) -> &AgentProfile {
194        &self.profiles[self.active_profile]
195    }
196    pub fn conversation_id(&self) -> &str {
197        &self.conversation_id
198    }
199    pub fn messages(&self) -> &[Message] {
200        &self.messages
201    }
202    pub fn set_model(&mut self, model: String) {
203        if let Some(p) = Arc::get_mut(&mut self.providers[self.active]) {
204            p.set_model(model);
205        } else {
206            tracing::warn!("cannot change model while background subagent is active");
207        }
208    }
209    pub fn set_active_provider(&mut self, provider_name: &str, model: &str) {
210        if let Some(idx) = self
211            .providers
212            .iter()
213            .position(|p| p.name() == provider_name)
214        {
215            self.active = idx;
216            if let Some(p) = Arc::get_mut(&mut self.providers[idx]) {
217                p.set_model(model.to_string());
218            } else {
219                tracing::warn!("cannot change model while background subagent is active");
220            }
221        }
222    }
223    pub fn set_thinking_budget(&mut self, budget: u32) {
224        self.thinking_budget = budget;
225    }
226    pub fn available_models(&self) -> Vec<String> {
227        self.provider().available_models()
228    }
229    pub fn cached_all_models(&self) -> Vec<(String, Vec<String>)> {
230        self.providers
231            .iter()
232            .map(|p| (p.name().to_string(), p.available_models()))
233            .collect()
234    }
235    pub async fn fetch_all_models(&mut self) -> Vec<(String, Vec<String>)> {
236        let mut result = Vec::new();
237        for p in &self.providers {
238            let models = match p.fetch_models().await {
239                Ok(m) => m,
240                Err(e) => {
241                    tracing::warn!("Failed to fetch models for {}: {e}", p.name());
242                    Vec::new()
243                }
244            };
245            result.push((p.name().to_string(), models));
246        }
247        result
248    }
249    pub fn current_model(&self) -> &str {
250        self.provider().model()
251    }
252    pub fn current_provider_name(&self) -> &str {
253        self.provider().name()
254    }
255    pub fn current_agent_name(&self) -> &str {
256        &self.profile().name
257    }
258    pub fn context_window(&self) -> u32 {
259        self.provider().context_window()
260    }
261    pub async fn fetch_context_window(&self) -> u32 {
262        match self.provider().fetch_context_window().await {
263            Ok(cw) => cw,
264            Err(e) => {
265                tracing::warn!("Failed to fetch context window: {e}");
266                0
267            }
268        }
269    }
270    pub fn agent_profiles(&self) -> &[AgentProfile] {
271        &self.profiles
272    }
273    pub fn switch_agent(&mut self, name: &str) -> bool {
274        if let Some(idx) = self.profiles.iter().position(|p| p.name == name) {
275            self.active_profile = idx;
276            let model_spec = self.profiles[idx].model_spec.clone();
277
278            if let Some(spec) = model_spec {
279                let (provider, model) = Config::parse_model_spec(&spec);
280                if let Some(prov) = provider {
281                    self.set_active_provider(prov, model);
282                } else {
283                    self.set_model(model.to_string());
284                }
285            }
286            tracing::info!("Switched to agent '{}'", name);
287            true
288        } else {
289            false
290        }
291    }
292    pub fn cleanup_if_empty(&mut self) {
293        if self.messages.is_empty() && self.persisted {
294            let _ = self.db.delete_conversation(&self.conversation_id);
295        }
296    }
297    pub fn new_conversation(&mut self) -> Result<()> {
298        self.cleanup_if_empty();
299        self.conversation_id = uuid::Uuid::new_v4().to_string();
300        self.persisted = false;
301        self.messages.clear();
302        Ok(())
303    }
304    pub fn resume_conversation(&mut self, conversation: &crate::db::Conversation) -> Result<()> {
305        self.conversation_id = conversation.id.clone();
306        self.persisted = true;
307        self.messages = conversation
308            .messages
309            .iter()
310            .map(|m| Message {
311                role: if m.role == "user" {
312                    Role::User
313                } else {
314                    Role::Assistant
315                },
316                content: vec![ContentBlock::Text(m.content.clone())],
317            })
318            .collect();
319        tracing::debug!("Resumed conversation {}", conversation.id);
320        {
321            let ctx = self.event_context(&Event::OnResume);
322            self.hooks.emit(&Event::OnResume, &ctx);
323        }
324        Ok(())
325    }
326
327    /// Add an interrupted (cancelled) assistant message to context and DB so the
328    /// model sees it on the next send and can continue from where it stopped.
329    pub fn add_interrupted_message(
330        &mut self,
331        content: String,
332        tool_calls: Vec<InterruptedToolCall>,
333        thinking: Option<String>,
334    ) -> Result<()> {
335        let mut blocks: Vec<ContentBlock> = Vec::new();
336        if let Some(t) = thinking
337            && !t.is_empty()
338        {
339            blocks.push(ContentBlock::Thinking {
340                thinking: t,
341                signature: String::new(),
342            });
343        }
344        if !content.is_empty() {
345            blocks.push(ContentBlock::Text(content.clone()));
346        }
347        let mut tool_ids: Vec<String> = Vec::new();
348        for tc in &tool_calls {
349            let id = Uuid::new_v4().to_string();
350            tool_ids.push(id.clone());
351            let input_value: serde_json::Value =
352                serde_json::from_str(&tc.input).unwrap_or_else(|_| serde_json::json!({}));
353            blocks.push(ContentBlock::ToolUse {
354                id: id.clone(),
355                name: tc.name.clone(),
356                input: input_value,
357            });
358        }
359        for (tc, id) in tool_calls.iter().zip(tool_ids.iter()) {
360            blocks.push(ContentBlock::ToolResult {
361                tool_use_id: id.clone(),
362                content: tc.output.clone().unwrap_or_default(),
363                is_error: tc.is_error,
364            });
365        }
366        self.messages.push(Message {
367            role: Role::Assistant,
368            content: blocks,
369        });
370        let stored_text = if content.is_empty() {
371            String::from("[tool use]")
372        } else {
373            content
374        };
375        self.ensure_persisted()?;
376        let assistant_msg_id =
377            self.db
378                .add_message(&self.conversation_id, "assistant", &stored_text)?;
379        for (tc, id) in tool_calls.iter().zip(tool_ids.iter()) {
380            let _ = self
381                .db
382                .add_tool_call(&assistant_msg_id, id, &tc.name, &tc.input);
383            if let Some(ref output) = tc.output {
384                let _ = self.db.update_tool_result(id, output, tc.is_error);
385            }
386        }
387        Ok(())
388    }
389    pub fn list_sessions(&self) -> Result<Vec<crate::db::ConversationSummary>> {
390        self.db.list_conversations_for_cwd(&self.cwd, 50)
391    }
392    pub fn get_session(&self, id: &str) -> Result<crate::db::Conversation> {
393        self.db.get_conversation(id)
394    }
395    pub fn get_tool_calls(&self, message_id: &str) -> Result<Vec<crate::db::DbToolCall>> {
396        self.db.get_tool_calls(message_id)
397    }
398    pub fn conversation_title(&self) -> Option<String> {
399        self.db
400            .get_conversation(&self.conversation_id)
401            .ok()
402            .and_then(|c| c.title)
403    }
404    pub fn rename_session(&self, title: &str) -> Result<()> {
405        self.db
406            .update_conversation_title(&self.conversation_id, title)
407            .context("failed to rename session")
408    }
409    pub fn cwd(&self) -> &str {
410        &self.cwd
411    }
412
413    pub fn truncate_messages(&mut self, count: usize) {
414        let target = count.min(self.messages.len());
415        self.messages.truncate(target);
416    }
417
418    pub fn revert_to_message(&mut self, keep: usize) -> Result<Vec<String>> {
419        let keep = keep.min(self.messages.len());
420        let checkpoint_idx = self.messages[..keep]
421            .iter()
422            .filter(|m| m.role == Role::Assistant)
423            .count();
424        self.messages.truncate(keep);
425        self.db
426            .truncate_messages(&self.conversation_id, keep)
427            .context("truncating db messages")?;
428        let restored = if checkpoint_idx > 0 {
429            let res = self.snapshots.restore_to_checkpoint(checkpoint_idx - 1)?;
430            self.snapshots.truncate_checkpoints(checkpoint_idx);
431            res
432        } else {
433            let res = self.snapshots.restore_all()?;
434            self.snapshots.truncate_checkpoints(0);
435            res
436        };
437        Ok(restored)
438    }
439
440    pub fn fork_conversation(&mut self, msg_count: usize) -> Result<()> {
441        let kept = self.messages[..msg_count.min(self.messages.len())].to_vec();
442        self.cleanup_if_empty();
443        let conversation_id = self.db.create_conversation(
444            self.provider().model(),
445            self.provider().name(),
446            &self.cwd,
447        )?;
448        self.conversation_id = conversation_id;
449        self.persisted = true;
450        self.messages = kept;
451        for msg in &self.messages {
452            let role = match msg.role {
453                Role::User => "user",
454                Role::Assistant => "assistant",
455                Role::System => "system",
456            };
457            let text: String = msg
458                .content
459                .iter()
460                .filter_map(|b| {
461                    if let ContentBlock::Text(t) = b {
462                        Some(t.as_str())
463                    } else {
464                        None
465                    }
466                })
467                .collect::<Vec<_>>()
468                .join("\n");
469            if !text.is_empty() {
470                let _ = self.db.add_message(&self.conversation_id, role, &text);
471            }
472        }
473        Ok(())
474    }
475
476    fn title_model(&self) -> &str {
477        self.provider().model()
478    }
479
480    fn should_compact(&self) -> bool {
481        let limit = self.provider().context_window();
482        let threshold = (limit as f32 * COMPACT_THRESHOLD) as u32;
483        self.last_input_tokens >= threshold
484    }
485    fn emit_compact_hooks(&self, phase: &Event) {
486        let ctx = self.event_context(phase);
487        self.hooks.emit(phase, &ctx);
488    }
489    async fn compact(&mut self, event_tx: &UnboundedSender<AgentEvent>) -> Result<()> {
490        let keep = COMPACT_KEEP_MESSAGES;
491        if self.messages.len() <= keep + 2 {
492            return Ok(());
493        }
494        let cutoff = self.messages.len() - keep;
495        let old_messages = self.messages[..cutoff].to_vec();
496        let kept = self.messages[cutoff..].to_vec();
497
498        let mut summary_text = String::new();
499        for msg in &old_messages {
500            let role = match msg.role {
501                Role::User => "User",
502                Role::Assistant => "Assistant",
503                Role::System => "System",
504            };
505            for block in &msg.content {
506                if let ContentBlock::Text(t) = block {
507                    summary_text.push_str(&format!("{}:\n{}\n\n", role, t));
508                }
509            }
510        }
511        let summary_request = vec![Message {
512            role: Role::User,
513            content: vec![ContentBlock::Text(format!(
514                "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{}",
515                summary_text
516            ))],
517        }];
518
519        self.emit_compact_hooks(&Event::BeforeCompact);
520        let mut stream_rx = self
521            .provider()
522            .stream(
523                &summary_request,
524                Some("You are a concise summarizer. Produce a dense, factual summary."),
525                &[],
526                4096,
527                0,
528            )
529            .await?;
530        let mut full_summary = String::new();
531        while let Some(event) = stream_rx.recv().await {
532            if let StreamEventType::TextDelta(text) = event.event_type {
533                full_summary.push_str(&text);
534            }
535        }
536        self.messages = vec![
537            Message {
538                role: Role::User,
539                content: vec![ContentBlock::Text(
540                    "[Previous conversation summarized below]".to_string(),
541                )],
542            },
543            Message {
544                role: Role::Assistant,
545                content: vec![ContentBlock::Text(format!(
546                    "Summary of prior context:\n\n{}",
547                    full_summary
548                ))],
549            },
550        ];
551        self.messages.extend(kept);
552
553        let _ = self.db.add_message(
554            &self.conversation_id,
555            "assistant",
556            &format!("[Compacted {} messages into summary]", cutoff),
557        );
558        self.last_input_tokens = 0;
559        let _ = event_tx.send(AgentEvent::Compacted {
560            messages_removed: cutoff,
561        });
562        self.emit_compact_hooks(&Event::AfterCompact);
563        Ok(())
564    }
565    /// Remove orphaned tool-call cycles and trailing user messages from the
566    /// end of the conversation. This handles cases where a previous stream was
567    /// cancelled or errored mid-execution, leaving ToolResult messages without
568    /// a subsequent assistant response, assistant ToolUse messages without
569    /// corresponding ToolResult messages, or plain user messages that never
570    /// received a response (which would cause consecutive user messages on the
571    /// next send).
572    fn sanitize(&mut self) {
573        loop {
574            let dominated = match self.messages.last() {
575                None => false,
576                Some(msg) if msg.role == Role::User => {
577                    !msg.content.is_empty()
578                        && msg
579                            .content
580                            .iter()
581                            .all(|b| matches!(b, ContentBlock::ToolResult { .. }))
582                }
583                Some(msg) if msg.role == Role::Assistant => msg
584                    .content
585                    .iter()
586                    .any(|b| matches!(b, ContentBlock::ToolUse { .. })),
587                _ => false,
588            };
589            if dominated {
590                self.messages.pop();
591            } else {
592                break;
593            }
594        }
595        // Drop any trailing user message to prevent consecutive user messages.
596        // This happens when a previous send_message was cancelled after pushing
597        // the user message but before the assistant could respond.
598        if matches!(self.messages.last(), Some(msg) if msg.role == Role::User) {
599            self.messages.pop();
600        }
601    }
602
603    pub async fn send_message(
604        &mut self,
605        content: &str,
606        event_tx: UnboundedSender<AgentEvent>,
607    ) -> Result<()> {
608        self.send_message_with_images(content, Vec::new(), event_tx)
609            .await
610    }
611
612    pub async fn send_message_with_images(
613        &mut self,
614        content: &str,
615        images: Vec<(String, String)>,
616        event_tx: UnboundedSender<AgentEvent>,
617    ) -> Result<()> {
618        self.sanitize();
619        {
620            let mut ctx = self.event_context(&Event::OnUserInput);
621            ctx.prompt = Some(content.to_string());
622            self.hooks.emit(&Event::OnUserInput, &ctx);
623        }
624        if !self.provider().supports_server_compaction() && self.should_compact() {
625            self.compact(&event_tx).await?;
626        }
627        self.ensure_persisted()?;
628        self.db
629            .add_message(&self.conversation_id, "user", content)?;
630        let mut blocks: Vec<ContentBlock> = Vec::new();
631        for (media_type, data) in images {
632            blocks.push(ContentBlock::Image { media_type, data });
633        }
634        blocks.push(ContentBlock::Text(content.to_string()));
635        self.messages.push(Message {
636            role: Role::User,
637            content: blocks,
638        });
639        let title_rx = if self.messages.len() == 1 {
640            let preview: String = content.chars().take(50).collect();
641            let preview = preview.trim().to_string();
642            if !preview.is_empty() {
643                let _ = self
644                    .db
645                    .update_conversation_title(&self.conversation_id, &preview);
646                let _ = event_tx.send(AgentEvent::TitleGenerated(preview));
647            }
648            let title_messages = vec![Message {
649                role: Role::User,
650                content: vec![ContentBlock::Text(format!(
651                    "Generate a title for this conversation:\n\n{}",
652                    content
653                ))],
654            }];
655            match self
656                .provider()
657                .stream_with_model(
658                    self.title_model(),
659                    &title_messages,
660                    Some(TITLE_SYSTEM_PROMPT),
661                    &[],
662                    100,
663                    0,
664                )
665                .await
666            {
667                Ok(rx) => Some(rx),
668                Err(e) => {
669                    tracing::warn!("title generation stream failed: {e}");
670                    None
671                }
672            }
673        } else {
674            None
675        };
676        let mut final_usage: Option<Usage> = None;
677        let mut total_text = String::new();
678        let mut continuations = 0usize;
679        const MAX_CONTINUATIONS: usize = 10;
680        let mut system_prompt = self
681            .agents_context
682            .apply_to_system_prompt(&self.profile().system_prompt);
683        if let Some(ref store) = self.memory {
684            let query: String = content.chars().take(200).collect();
685            match store.inject_context(&query, self.memory_inject_count) {
686                Ok(ctx) if !ctx.is_empty() => {
687                    system_prompt.push_str("\n\n");
688                    system_prompt.push_str(&ctx);
689                }
690                Err(e) => tracing::warn!("memory injection failed: {e}"),
691                _ => {}
692            }
693            system_prompt.push_str(MEMORY_INSTRUCTIONS);
694        }
695        let tool_filter = self.profile().tool_filter.clone();
696        let thinking_budget = self.thinking_budget;
697        loop {
698            let mut tool_defs = self.tools.definitions_filtered(&tool_filter);
699            tool_defs.push(crate::provider::ToolDefinition {
700                name: "todo_write".to_string(),
701                description: "Create or update the task list for the current session. Use to track progress on multi-step tasks.".to_string(),
702                input_schema: serde_json::json!({
703                    "type": "object",
704                    "properties": {
705                        "todos": {
706                            "type": "array",
707                            "items": {
708                                "type": "object",
709                                "properties": {
710                                    "content": { "type": "string", "description": "Brief description of the task" },
711                                    "status": { "type": "string", "enum": ["pending", "in_progress", "completed"], "description": "Current status" }
712                                },
713                                "required": ["content", "status"]
714                            }
715                        }
716                    },
717                    "required": ["todos"]
718                }),
719            });
720            tool_defs.push(crate::provider::ToolDefinition {
721                name: "question".to_string(),
722                description: "Ask the user a question and wait for their response. Use when you need clarification or a decision from the user.".to_string(),
723                input_schema: serde_json::json!({
724                    "type": "object",
725                    "properties": {
726                        "question": { "type": "string", "description": "The question to ask the user" },
727                        "options": { "type": "array", "items": { "type": "string" }, "description": "Optional list of choices" }
728                    },
729                    "required": ["question"]
730                }),
731            });
732            tool_defs.push(crate::provider::ToolDefinition {
733                name: "snapshot_list".to_string(),
734                description: "List all files that have been created or modified in this session."
735                    .to_string(),
736                input_schema: serde_json::json!({
737                    "type": "object",
738                    "properties": {},
739                }),
740            });
741            tool_defs.push(crate::provider::ToolDefinition {
742                name: "snapshot_restore".to_string(),
743                description: "Restore a file to its original state before this session modified it. Pass a path or omit to restore all files.".to_string(),
744                input_schema: serde_json::json!({
745                    "type": "object",
746                    "properties": {
747                        "path": { "type": "string", "description": "File path to restore (omit to restore all)" }
748                    },
749                }),
750            });
751            if self.subagent_enabled {
752                let profile_names: Vec<String> =
753                    self.profiles.iter().map(|p| p.name.clone()).collect();
754                let profiles_desc = if profile_names.is_empty() {
755                    String::new()
756                } else {
757                    format!(" Available profiles: {}.", profile_names.join(", "))
758                };
759                tool_defs.push(crate::provider::ToolDefinition {
760                    name: "subagent".to_string(),
761                    description: format!(
762                        "Delegate a focused task to a subagent that runs in isolated context with its own conversation. \
763                         The subagent has access to tools and works autonomously without user interaction. \
764                         Use for complex subtasks that benefit from separate context (research, code analysis, multi-file changes). \
765                         Set background=true to run non-blocking (returns immediately with an ID; retrieve results later with subagent_result).{}",
766                        profiles_desc
767                    ),
768                    input_schema: serde_json::json!({
769                        "type": "object",
770                        "properties": {
771                            "description": {
772                                "type": "string",
773                                "description": "What the subagent should do (used as system prompt context)"
774                            },
775                            "task": {
776                                "type": "string",
777                                "description": "The specific task prompt for the subagent"
778                            },
779                            "profile": {
780                                "type": "string",
781                                "description": "Agent profile to use (affects available tools and system prompt)"
782                            },
783                            "background": {
784                                "type": "boolean",
785                                "description": "Run in background (non-blocking). Returns an ID to check later with subagent_result."
786                            }
787                        },
788                        "required": ["description", "task"]
789                    }),
790                });
791                tool_defs.push(crate::provider::ToolDefinition {
792                    name: "subagent_result".to_string(),
793                    description: "Retrieve the result of a background subagent by ID. Returns the output if complete, or a status message if still running.".to_string(),
794                    input_schema: serde_json::json!({
795                        "type": "object",
796                        "properties": {
797                            "id": {
798                                "type": "string",
799                                "description": "The subagent ID returned when it was launched in background mode"
800                            }
801                        },
802                        "required": ["id"]
803                    }),
804                });
805            }
806            if self.memory.is_some() {
807                tool_defs.extend(crate::memory::tools::definitions());
808            }
809            {
810                let mut ctx = self.event_context(&Event::BeforePrompt);
811                ctx.prompt = Some(content.to_string());
812                match self.hooks.emit_blocking(&Event::BeforePrompt, &ctx) {
813                    HookResult::Block(reason) => {
814                        let _ = event_tx.send(AgentEvent::TextComplete(format!(
815                            "[blocked by hook: {}]",
816                            reason.trim()
817                        )));
818                        return Ok(());
819                    }
820                    HookResult::Modify(_modified) => {}
821                    HookResult::Allow => {}
822                }
823            }
824            self.hooks.emit(
825                &Event::OnStreamStart,
826                &self.event_context(&Event::OnStreamStart),
827            );
828            let mut stop_reason = StopReason::EndTurn;
829            let mut stream_rx = self
830                .provider()
831                .stream(
832                    &self.messages,
833                    Some(&system_prompt),
834                    &tool_defs,
835                    8192,
836                    thinking_budget,
837                )
838                .await?;
839            self.hooks.emit(
840                &Event::OnStreamEnd,
841                &self.event_context(&Event::OnStreamEnd),
842            );
843            let mut full_text = String::new();
844            let mut full_thinking = String::new();
845            let mut full_thinking_signature = String::new();
846            let mut compaction_content: Option<String> = None;
847            let mut tool_calls: Vec<PendingToolCall> = Vec::new();
848            let mut current_tool_input = String::new();
849            while let Some(event) = stream_rx.recv().await {
850                match event.event_type {
851                    StreamEventType::TextDelta(text) => {
852                        full_text.push_str(&text);
853                        let _ = event_tx.send(AgentEvent::TextDelta(text));
854                    }
855                    StreamEventType::ThinkingDelta(text) => {
856                        full_thinking.push_str(&text);
857                        let _ = event_tx.send(AgentEvent::ThinkingDelta(text));
858                    }
859                    StreamEventType::ThinkingComplete {
860                        thinking,
861                        signature,
862                    } => {
863                        full_thinking = thinking;
864                        full_thinking_signature = signature;
865                    }
866                    StreamEventType::CompactionComplete(content) => {
867                        let _ = event_tx.send(AgentEvent::Compacting);
868                        compaction_content = Some(content);
869                    }
870                    StreamEventType::ToolUseStart { id, name } => {
871                        current_tool_input.clear();
872                        let _ = event_tx.send(AgentEvent::ToolCallStart {
873                            id: id.clone(),
874                            name: name.clone(),
875                        });
876                        tool_calls.push(PendingToolCall {
877                            id,
878                            name,
879                            input: String::new(),
880                        });
881                    }
882                    StreamEventType::ToolUseInputDelta(delta) => {
883                        current_tool_input.push_str(&delta);
884                        let _ = event_tx.send(AgentEvent::ToolCallInputDelta(delta));
885                    }
886                    StreamEventType::ToolUseEnd => {
887                        if let Some(tc) = tool_calls.last_mut() {
888                            tc.input = current_tool_input.clone();
889                        }
890                        current_tool_input.clear();
891                    }
892                    StreamEventType::MessageEnd {
893                        stop_reason: sr,
894                        usage,
895                    } => {
896                        stop_reason = sr;
897                        self.last_input_tokens = usage.input_tokens;
898                        let _ = self
899                            .db
900                            .update_last_input_tokens(&self.conversation_id, usage.input_tokens);
901                        final_usage = Some(usage);
902                    }
903
904                    _ => {}
905                }
906            }
907
908            total_text.push_str(&full_text);
909
910            let mut content_blocks: Vec<ContentBlock> = Vec::new();
911            if let Some(ref summary) = compaction_content {
912                content_blocks.push(ContentBlock::Compaction {
913                    content: summary.clone(),
914                });
915            }
916            if !full_thinking.is_empty() {
917                content_blocks.push(ContentBlock::Thinking {
918                    thinking: full_thinking.clone(),
919                    signature: full_thinking_signature.clone(),
920                });
921            }
922            if !full_text.is_empty() {
923                content_blocks.push(ContentBlock::Text(full_text.clone()));
924            }
925
926            for tc in &tool_calls {
927                let input_value: serde_json::Value =
928                    serde_json::from_str(&tc.input).unwrap_or_else(|_| serde_json::json!({}));
929                content_blocks.push(ContentBlock::ToolUse {
930                    id: tc.id.clone(),
931                    name: tc.name.clone(),
932                    input: input_value,
933                });
934            }
935
936            self.messages.push(Message {
937                role: Role::Assistant,
938                content: content_blocks,
939            });
940            let stored_text = if !full_text.is_empty() {
941                full_text.clone()
942            } else {
943                String::from("[tool use]")
944            };
945            let assistant_msg_id =
946                self.db
947                    .add_message(&self.conversation_id, "assistant", &stored_text)?;
948            for tc in &tool_calls {
949                let _ = self
950                    .db
951                    .add_tool_call(&assistant_msg_id, &tc.id, &tc.name, &tc.input);
952            }
953            {
954                let mut ctx = self.event_context(&Event::AfterPrompt);
955                ctx.prompt = Some(full_text.clone());
956                self.hooks.emit(&Event::AfterPrompt, &ctx);
957            }
958            self.snapshots.checkpoint();
959            if tool_calls.is_empty() {
960                if matches!(stop_reason, StopReason::MaxTokens) && continuations < MAX_CONTINUATIONS
961                {
962                    continuations += 1;
963                    tracing::info!(
964                        "max_tokens reached, continuing ({continuations}/{MAX_CONTINUATIONS})"
965                    );
966                    self.messages.push(Message {
967                        role: Role::User,
968                        content: vec![ContentBlock::Text("Continue".to_string())],
969                    });
970                    continue;
971                }
972                let _ = event_tx.send(AgentEvent::TextComplete(total_text.clone()));
973                if let Some(usage) = final_usage {
974                    let _ = event_tx.send(AgentEvent::Done { usage });
975                }
976                if self.memory_auto_extract
977                    && let Some(ref store) = self.memory
978                {
979                    let msgs = self.messages.clone();
980                    let provider = self.provider_arc();
981                    let store = Arc::clone(store);
982                    let conv_id = self.conversation_id.clone();
983                    let etx = event_tx.clone();
984                    tokio::spawn(async move {
985                        match crate::memory::extract::extract(&msgs, &*provider, &store, &conv_id)
986                            .await
987                        {
988                            Ok(result)
989                                if result.added > 0 || result.updated > 0 || result.deleted > 0 =>
990                            {
991                                let _ = etx.send(AgentEvent::MemoryExtracted {
992                                    added: result.added,
993                                    updated: result.updated,
994                                    deleted: result.deleted,
995                                });
996                            }
997                            Err(e) => tracing::warn!("memory extraction failed: {e}"),
998                            _ => {}
999                        }
1000                    });
1001                }
1002                break;
1003            }
1004
1005            let mut result_blocks: Vec<ContentBlock> = Vec::new();
1006
1007            for tc in &tool_calls {
1008                let input_value: serde_json::Value =
1009                    serde_json::from_str(&tc.input).unwrap_or_else(|_| serde_json::json!({}));
1010                // Virtual tool: todo_write
1011                if tc.name == "todo_write" {
1012                    if let Some(todos_arr) = input_value.get("todos").and_then(|v| v.as_array()) {
1013                        let items: Vec<TodoItem> = todos_arr
1014                            .iter()
1015                            .filter_map(|t| {
1016                                let content = t.get("content")?.as_str()?.to_string();
1017                                let status = match t
1018                                    .get("status")
1019                                    .and_then(|s| s.as_str())
1020                                    .unwrap_or("pending")
1021                                {
1022                                    "in_progress" => TodoStatus::InProgress,
1023                                    "completed" => TodoStatus::Completed,
1024                                    _ => TodoStatus::Pending,
1025                                };
1026                                Some(TodoItem { content, status })
1027                            })
1028                            .collect();
1029                        let _ = event_tx.send(AgentEvent::TodoUpdate(items));
1030                    }
1031                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1032                        id: tc.id.clone(),
1033                        name: tc.name.clone(),
1034                        output: "ok".to_string(),
1035                        is_error: false,
1036                    });
1037                    result_blocks.push(ContentBlock::ToolResult {
1038                        tool_use_id: tc.id.clone(),
1039                        content: "ok".to_string(),
1040                        is_error: false,
1041                    });
1042                    continue;
1043                }
1044                // Virtual tool: question
1045                if tc.name == "question" {
1046                    let question = input_value
1047                        .get("question")
1048                        .and_then(|v| v.as_str())
1049                        .unwrap_or("?")
1050                        .to_string();
1051                    let options: Vec<String> = input_value
1052                        .get("options")
1053                        .and_then(|v| v.as_array())
1054                        .map(|arr| {
1055                            arr.iter()
1056                                .filter_map(|v| v.as_str().map(String::from))
1057                                .collect()
1058                        })
1059                        .unwrap_or_default();
1060                    let (tx, rx) = tokio::sync::oneshot::channel();
1061                    let _ = event_tx.send(AgentEvent::Question {
1062                        id: tc.id.clone(),
1063                        question: question.clone(),
1064                        options,
1065                        responder: QuestionResponder(tx),
1066                    });
1067                    let answer = match rx.await {
1068                        Ok(a) => a,
1069                        Err(_) => "[cancelled]".to_string(),
1070                    };
1071                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1072                        id: tc.id.clone(),
1073                        name: tc.name.clone(),
1074                        output: answer.clone(),
1075                        is_error: false,
1076                    });
1077                    result_blocks.push(ContentBlock::ToolResult {
1078                        tool_use_id: tc.id.clone(),
1079                        content: answer,
1080                        is_error: false,
1081                    });
1082                    continue;
1083                }
1084                // Virtual tool: snapshot_list
1085                if tc.name == "snapshot_list" {
1086                    let changes = self.snapshots.list_changes();
1087                    let output = if changes.is_empty() {
1088                        "No file changes in this session.".to_string()
1089                    } else {
1090                        changes
1091                            .iter()
1092                            .map(|(p, k)| format!("{} {}", k.icon(), p))
1093                            .collect::<Vec<_>>()
1094                            .join("\n")
1095                    };
1096                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1097                        id: tc.id.clone(),
1098                        name: tc.name.clone(),
1099                        output: output.clone(),
1100                        is_error: false,
1101                    });
1102                    result_blocks.push(ContentBlock::ToolResult {
1103                        tool_use_id: tc.id.clone(),
1104                        content: output,
1105                        is_error: false,
1106                    });
1107                    continue;
1108                }
1109                // Virtual tool: snapshot_restore
1110                if tc.name == "snapshot_restore" {
1111                    let output =
1112                        if let Some(path) = input_value.get("path").and_then(|v| v.as_str()) {
1113                            match self.snapshots.restore(path) {
1114                                Ok(msg) => msg,
1115                                Err(e) => e.to_string(),
1116                            }
1117                        } else {
1118                            match self.snapshots.restore_all() {
1119                                Ok(msgs) => {
1120                                    if msgs.is_empty() {
1121                                        "Nothing to restore.".to_string()
1122                                    } else {
1123                                        msgs.join("\n")
1124                                    }
1125                                }
1126                                Err(e) => e.to_string(),
1127                            }
1128                        };
1129                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1130                        id: tc.id.clone(),
1131                        name: tc.name.clone(),
1132                        output: output.clone(),
1133                        is_error: false,
1134                    });
1135                    result_blocks.push(ContentBlock::ToolResult {
1136                        tool_use_id: tc.id.clone(),
1137                        content: output,
1138                        is_error: false,
1139                    });
1140                    continue;
1141                }
1142                // Virtual tool: batch
1143                if tc.name == "batch" {
1144                    let invocations = input_value
1145                        .get("invocations")
1146                        .and_then(|v| v.as_array())
1147                        .cloned()
1148                        .unwrap_or_default();
1149                    tracing::debug!("batch: {} invocations", invocations.len());
1150                    let results: Vec<serde_json::Value> = invocations
1151                        .iter()
1152                        .map(|inv| {
1153                            let name = inv.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");
1154                            let input = inv.get("input").cloned().unwrap_or(serde_json::Value::Null);
1155                            match self.tools.execute(name, input) {
1156                                Ok(out) => serde_json::json!({ "tool_name": name, "result": out, "is_error": false }),
1157                                Err(e) => serde_json::json!({ "tool_name": name, "result": e.to_string(), "is_error": true }),
1158                            }
1159                        })
1160                        .collect();
1161                    let output = serde_json::to_string(&results).unwrap_or_else(|e| e.to_string());
1162                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1163                        id: tc.id.clone(),
1164                        name: tc.name.clone(),
1165                        output: output.clone(),
1166                        is_error: false,
1167                    });
1168                    result_blocks.push(ContentBlock::ToolResult {
1169                        tool_use_id: tc.id.clone(),
1170                        content: output,
1171                        is_error: false,
1172                    });
1173                    continue;
1174                }
1175                // Virtual tool: subagent
1176                if tc.name == "subagent" {
1177                    let description = input_value
1178                        .get("description")
1179                        .and_then(|v| v.as_str())
1180                        .unwrap_or("subtask")
1181                        .to_string();
1182                    let task = input_value
1183                        .get("task")
1184                        .and_then(|v| v.as_str())
1185                        .unwrap_or("")
1186                        .to_string();
1187                    let profile = input_value
1188                        .get("profile")
1189                        .and_then(|v| v.as_str())
1190                        .map(String::from);
1191                    let background = input_value
1192                        .get("background")
1193                        .and_then(|v| v.as_bool())
1194                        .unwrap_or(false);
1195
1196                    let output = if background {
1197                        match self.spawn_background_subagent(
1198                            &description,
1199                            &task,
1200                            profile.as_deref(),
1201                        ) {
1202                            Ok(id) => format!("Background subagent launched with id: {id}"),
1203                            Err(e) => {
1204                                tracing::error!("background subagent error: {e}");
1205                                format!("[subagent error: {e}]")
1206                            }
1207                        }
1208                    } else {
1209                        match self
1210                            .run_subagent(&description, &task, profile.as_deref(), &event_tx)
1211                            .await
1212                        {
1213                            Ok(text) => text,
1214                            Err(e) => {
1215                                tracing::error!("subagent error: {e}");
1216                                format!("[subagent error: {e}]")
1217                            }
1218                        }
1219                    };
1220                    let is_error = output.starts_with("[subagent error:");
1221                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1222                        id: tc.id.clone(),
1223                        name: tc.name.clone(),
1224                        output: output.clone(),
1225                        is_error,
1226                    });
1227                    result_blocks.push(ContentBlock::ToolResult {
1228                        tool_use_id: tc.id.clone(),
1229                        content: output,
1230                        is_error,
1231                    });
1232                    continue;
1233                }
1234                // Virtual tool: subagent_result
1235                if tc.name == "subagent_result" {
1236                    let id = input_value.get("id").and_then(|v| v.as_str()).unwrap_or("");
1237                    let output = {
1238                        let results = self
1239                            .background_results
1240                            .lock()
1241                            .unwrap_or_else(|e| e.into_inner());
1242                        if let Some(result) = results.get(id) {
1243                            result.clone()
1244                        } else if self.background_handles.contains_key(id) {
1245                            format!("Subagent '{id}' is still running.")
1246                        } else {
1247                            format!("No subagent found with id '{id}'.")
1248                        }
1249                    };
1250                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1251                        id: tc.id.clone(),
1252                        name: tc.name.clone(),
1253                        output: output.clone(),
1254                        is_error: false,
1255                    });
1256                    result_blocks.push(ContentBlock::ToolResult {
1257                        tool_use_id: tc.id.clone(),
1258                        content: output,
1259                        is_error: false,
1260                    });
1261                    continue;
1262                }
1263                // Virtual tools: memory
1264                if let Some(ref store) = self.memory
1265                    && let Some((output, is_error)) = crate::memory::tools::handle(
1266                        &tc.name,
1267                        &input_value,
1268                        store,
1269                        &self.conversation_id,
1270                    )
1271                {
1272                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1273                        id: tc.id.clone(),
1274                        name: tc.name.clone(),
1275                        output: output.clone(),
1276                        is_error,
1277                    });
1278                    result_blocks.push(ContentBlock::ToolResult {
1279                        tool_use_id: tc.id.clone(),
1280                        content: output,
1281                        is_error,
1282                    });
1283                    continue;
1284                }
1285                // Permission check
1286                let perm = self
1287                    .permissions
1288                    .get(&tc.name)
1289                    .map(|s| s.as_str())
1290                    .unwrap_or("allow");
1291                if perm == "deny" {
1292                    let output = format!("Tool '{}' is denied by permissions config.", tc.name);
1293                    let _ = event_tx.send(AgentEvent::ToolCallResult {
1294                        id: tc.id.clone(),
1295                        name: tc.name.clone(),
1296                        output: output.clone(),
1297                        is_error: true,
1298                    });
1299                    result_blocks.push(ContentBlock::ToolResult {
1300                        tool_use_id: tc.id.clone(),
1301                        content: output,
1302                        is_error: true,
1303                    });
1304                    continue;
1305                }
1306                if perm == "ask" {
1307                    let summary = format!("{}: {}", tc.name, &tc.input[..tc.input.len().min(100)]);
1308                    let (ptx, prx) = tokio::sync::oneshot::channel();
1309                    let _ = event_tx.send(AgentEvent::PermissionRequest {
1310                        tool_name: tc.name.clone(),
1311                        input_summary: summary,
1312                        responder: QuestionResponder(ptx),
1313                    });
1314                    let answer = match prx.await {
1315                        Ok(a) => a,
1316                        Err(_) => "deny".to_string(),
1317                    };
1318                    if answer != "allow" {
1319                        let output = format!("Tool '{}' denied by user.", tc.name);
1320                        let _ = event_tx.send(AgentEvent::ToolCallResult {
1321                            id: tc.id.clone(),
1322                            name: tc.name.clone(),
1323                            output: output.clone(),
1324                            is_error: true,
1325                        });
1326                        result_blocks.push(ContentBlock::ToolResult {
1327                            tool_use_id: tc.id.clone(),
1328                            content: output,
1329                            is_error: true,
1330                        });
1331                        continue;
1332                    }
1333                }
1334                // Snapshot before file writes
1335                if tc.name == "write_file" || tc.name == "apply_patch" {
1336                    if tc.name == "write_file" {
1337                        if let Some(path) = input_value.get("path").and_then(|v| v.as_str()) {
1338                            self.snapshots.before_write(path);
1339                        }
1340                    } else if let Some(patches) =
1341                        input_value.get("patches").and_then(|v| v.as_array())
1342                    {
1343                        for patch in patches {
1344                            if let Some(path) = patch.get("path").and_then(|v| v.as_str()) {
1345                                self.snapshots.before_write(path);
1346                            }
1347                        }
1348                    }
1349                }
1350                {
1351                    let mut ctx = self.event_context(&Event::BeforeToolCall);
1352                    ctx.tool_name = Some(tc.name.clone());
1353                    ctx.tool_input = Some(tc.input.clone());
1354                    match self.hooks.emit_blocking(&Event::BeforeToolCall, &ctx) {
1355                        HookResult::Block(reason) => {
1356                            let output = format!("[blocked by hook: {}]", reason.trim());
1357                            let _ = event_tx.send(AgentEvent::ToolCallResult {
1358                                id: tc.id.clone(),
1359                                name: tc.name.clone(),
1360                                output: output.clone(),
1361                                is_error: true,
1362                            });
1363                            result_blocks.push(ContentBlock::ToolResult {
1364                                tool_use_id: tc.id.clone(),
1365                                content: output,
1366                                is_error: true,
1367                            });
1368                            continue;
1369                        }
1370                        HookResult::Modify(_modified) => {}
1371                        HookResult::Allow => {}
1372                    }
1373                }
1374                let _ = event_tx.send(AgentEvent::ToolCallExecuting {
1375                    id: tc.id.clone(),
1376                    name: tc.name.clone(),
1377                    input: tc.input.clone(),
1378                });
1379                let tool_name = tc.name.clone();
1380                let tool_input = input_value.clone();
1381                let exec_result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
1382                    tokio::task::block_in_place(|| self.tools.execute(&tool_name, tool_input))
1383                })
1384                .await;
1385                let (output, is_error) = match exec_result {
1386                    Err(_elapsed) => {
1387                        let msg = format!("Tool '{}' timed out after 30 seconds.", tc.name);
1388                        let mut ctx = self.event_context(&Event::OnToolError);
1389                        ctx.tool_name = Some(tc.name.clone());
1390                        ctx.error = Some(msg.clone());
1391                        self.hooks.emit(&Event::OnToolError, &ctx);
1392                        (msg, true)
1393                    }
1394                    Ok(Err(e)) => {
1395                        let msg = e.to_string();
1396                        let mut ctx = self.event_context(&Event::OnToolError);
1397                        ctx.tool_name = Some(tc.name.clone());
1398                        ctx.error = Some(msg.clone());
1399                        self.hooks.emit(&Event::OnToolError, &ctx);
1400                        (msg, true)
1401                    }
1402                    Ok(Ok(out)) => (out, false),
1403                };
1404                tracing::debug!(
1405                    "Tool '{}' result (error={}): {}",
1406                    tc.name,
1407                    is_error,
1408                    &output[..output.len().min(200)]
1409                );
1410                let _ = self.db.update_tool_result(&tc.id, &output, is_error);
1411                let _ = event_tx.send(AgentEvent::ToolCallResult {
1412                    id: tc.id.clone(),
1413                    name: tc.name.clone(),
1414                    output: output.clone(),
1415                    is_error,
1416                });
1417                {
1418                    let mut ctx = self.event_context(&Event::AfterToolCall);
1419                    ctx.tool_name = Some(tc.name.clone());
1420                    ctx.tool_output = Some(output.clone());
1421                    self.hooks.emit(&Event::AfterToolCall, &ctx);
1422                }
1423                result_blocks.push(ContentBlock::ToolResult {
1424                    tool_use_id: tc.id.clone(),
1425                    content: output,
1426                    is_error,
1427                });
1428            }
1429
1430            self.messages.push(Message {
1431                role: Role::User,
1432                content: result_blocks,
1433            });
1434        }
1435
1436        let title = if let Some(mut rx) = title_rx {
1437            let mut raw = String::new();
1438            while let Some(event) = rx.recv().await {
1439                match event.event_type {
1440                    StreamEventType::TextDelta(text) => raw.push_str(&text),
1441                    StreamEventType::Error(e) => {
1442                        tracing::warn!("title stream error: {e}");
1443                    }
1444                    _ => {}
1445                }
1446            }
1447            let t = raw
1448                .trim()
1449                .trim_matches('"')
1450                .trim_matches('`')
1451                .trim_matches('*')
1452                .replace('\n', " ");
1453            let t: String = t.chars().take(50).collect();
1454            if t.is_empty() {
1455                tracing::warn!("title stream returned empty text");
1456                None
1457            } else {
1458                Some(t)
1459            }
1460        } else {
1461            None
1462        };
1463        let fallback = || -> String {
1464            self.messages
1465                .first()
1466                .and_then(|m| {
1467                    m.content.iter().find_map(|b| {
1468                        if let ContentBlock::Text(t) = b {
1469                            let s: String = t.chars().take(50).collect();
1470                            let s = s.trim().to_string();
1471                            if s.is_empty() { None } else { Some(s) }
1472                        } else {
1473                            None
1474                        }
1475                    })
1476                })
1477                .unwrap_or_else(|| "Chat".to_string())
1478        };
1479        let title = title.unwrap_or_else(fallback);
1480        let _ = self
1481            .db
1482            .update_conversation_title(&self.conversation_id, &title);
1483        let _ = event_tx.send(AgentEvent::TitleGenerated(title.clone()));
1484        {
1485            let mut ctx = self.event_context(&Event::OnTitleGenerated);
1486            ctx.title = Some(title);
1487            self.hooks.emit(&Event::OnTitleGenerated, &ctx);
1488        }
1489
1490        Ok(())
1491    }
1492}