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