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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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}