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