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