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