1use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
6use std::sync::Arc;
7use anyhow::Result;
8use tokio::sync::mpsc;
9
10use crate::event::{AgentEvent, EventType, EventData};
11use crate::providers::{ChatRequest, ChatResponse, ContentBlock, Message, MessageContent, Provider, Role, StopReason, Usage};
12use crate::tools::{Tool, ToolDefinition};
13use crate::approval::{ApproveMode, needs_approval};
14use crate::compress::{CompressionConfig, should_compress};
15use crate::cancel::CancellationToken;
16
17const MAX_ITERATIONS: usize = 50;
18
19#[allow(dead_code)] pub struct Agent {
22 provider: Box<dyn Provider>,
23 model_name: String, tools: Vec<Arc<dyn Tool>>,
25 messages: Vec<Message>,
26 system_prompt: String,
27 max_tokens: u32,
28 think: bool,
29 approve_mode: Arc<AtomicU8>,
30 event_tx: mpsc::Sender<AgentEvent>,
31
32 skills: Vec<crate::skills::Skill>,
34 profile: crate::prompt::PromptProfile,
35 project_overview: Option<String>,
36 memory_summary: Option<String>,
37
38 total_input_tokens: AtomicU64,
40 total_output_tokens: AtomicU64,
41 last_input_tokens: AtomicU64,
43 cancel_token: Option<CancellationToken>,
44 compression_config: CompressionConfig,
45
46 ask_rx: Option<mpsc::Receiver<String>>,
48}
49
50pub struct AgentBuilder {
52 provider: Box<dyn Provider>,
53 model_name: String,
54 tools: Vec<Arc<dyn Tool>>,
55 system_prompt: String,
56 max_tokens: u32,
57 think: bool,
58 approve_mode: ApproveMode,
59 event_tx: Option<mpsc::Sender<AgentEvent>>,
60 skills: Vec<crate::skills::Skill>,
62 profile: crate::prompt::PromptProfile,
63 project_overview: Option<String>,
64 memory_summary: Option<String>,
65}
66
67impl AgentBuilder {
68 pub fn new(provider: Box<dyn Provider>) -> Self {
69 Self {
70 provider,
71 model_name: "unknown".to_string(),
72 tools: Vec::new(),
73 system_prompt: "You are a helpful AI coding assistant.".to_string(),
74 max_tokens: 4096,
75 think: false,
76 approve_mode: ApproveMode::Ask,
77 event_tx: None,
78 skills: Vec::new(),
79 profile: crate::prompt::PromptProfile::Default,
80 project_overview: None,
81 memory_summary: None,
82 }
83 }
84
85 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
86 self.system_prompt = prompt.into();
87 self
88 }
89
90 pub fn model_name(mut self, name: impl Into<String>) -> Self {
91 self.model_name = name.into();
92 self
93 }
94
95 pub fn max_tokens(mut self, tokens: u32) -> Self {
96 self.max_tokens = tokens;
97 self
98 }
99
100 pub fn think(mut self, enabled: bool) -> Self {
101 self.think = enabled;
102 self
103 }
104
105 pub fn approve_mode(mut self, mode: ApproveMode) -> Self {
106 self.approve_mode = mode;
107 self
108 }
109
110 pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
111 self.tools.push(tool);
112 self
113 }
114
115 pub fn tools(mut self, tools: Vec<Box<dyn Tool>>) -> Self {
117 self.tools.extend(tools.into_iter().map(Arc::from));
118 self
119 }
120
121 pub fn event_tx(mut self, tx: mpsc::Sender<AgentEvent>) -> Self {
123 self.event_tx = Some(tx);
124 self
125 }
126
127 pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
129 self.skills = skills;
130 self
131 }
132
133 pub fn profile(mut self, profile: crate::prompt::PromptProfile) -> Self {
135 self.profile = profile;
136 self
137 }
138
139 pub fn overview(mut self, overview: impl Into<String>) -> Self {
141 self.project_overview = Some(overview.into());
142 self
143 }
144
145 pub fn memory(mut self, summary: impl Into<String>) -> Self {
147 self.memory_summary = Some(summary.into());
148 self
149 }
150
151 pub fn build(self) -> Agent {
152 Agent::new(self)
153 }
154}
155
156impl Agent {
157 fn new(builder: AgentBuilder) -> Self {
158 let event_tx = builder.event_tx.unwrap_or_else(|| {
160 let (tx, _) = mpsc::channel(100);
161 tx
162 });
163
164 Self {
165 provider: builder.provider,
166 model_name: builder.model_name,
167 tools: builder.tools,
168 messages: Vec::new(),
169 system_prompt: builder.system_prompt,
170 max_tokens: builder.max_tokens,
171 think: builder.think,
172 approve_mode: Arc::new(AtomicU8::new(builder.approve_mode.to_u8())),
173 event_tx,
174 skills: builder.skills,
175 profile: builder.profile,
176 project_overview: builder.project_overview,
177 memory_summary: builder.memory_summary,
178 total_input_tokens: AtomicU64::new(0),
179 total_output_tokens: AtomicU64::new(0),
180 last_input_tokens: AtomicU64::new(0),
181 cancel_token: None,
182 compression_config: CompressionConfig::default(),
183 ask_rx: None,
184 }
185 }
186
187 pub fn event_sender(&self) -> mpsc::Sender<AgentEvent> {
189 self.event_tx.clone()
190 }
191
192 pub fn set_ask_channel(&mut self, rx: mpsc::Receiver<String>) {
194 self.ask_rx = Some(rx);
195 }
196
197 pub fn set_cancel_token(&mut self, token: CancellationToken) {
199 self.cancel_token = Some(token);
200 }
201
202 pub fn set_approve_mode(&mut self, mode: ApproveMode) {
204 let old = ApproveMode::from_u8(self.approve_mode.load(Ordering::Relaxed));
205 log::info!("Agent approve mode changed: {} -> {}", old, mode);
206 self.approve_mode.store(mode.to_u8(), Ordering::Relaxed);
207 }
208
209 pub fn approve_mode_shared(&self) -> Arc<AtomicU8> {
212 self.approve_mode.clone()
213 }
214
215 pub fn set_approve_mode_shared(&mut self, shared: Arc<AtomicU8>) {
219 self.approve_mode = shared;
220 }
221
222 pub fn update_memory_summary(&mut self, summary: Option<String>) {
225 self.memory_summary = summary;
226 self.system_prompt = crate::prompt::build_system_prompt(
228 &self.profile,
229 &self.skills,
230 self.project_overview.as_deref(),
231 self.memory_summary.as_deref(),
232 );
233 }
234
235 pub async fn run(&mut self, user_input: String) -> Result<Vec<AgentEvent>> {
240 self.emit(AgentEvent::session_started())?;
242
243 self.messages.push(Message {
245 role: Role::User,
246 content: MessageContent::Text(user_input.clone()),
247 });
248
249 let mut iterations = 0;
251 let mut should_continue = true;
252
253 while should_continue && iterations < MAX_ITERATIONS {
254 iterations += 1;
255
256 if let Some(token) = &self.cancel_token
258 && token.is_cancelled()
259 {
260 self.emit(AgentEvent::error("Operation cancelled".to_string(), None, None))?;
261 break;
262 }
263
264 let tool_defs: Vec<ToolDefinition> = self.tools.iter().map(|t| t.definition()).collect();
266 let request = ChatRequest {
267 system: Some(self.system_prompt.clone()),
268 messages: self.messages.clone(),
269 max_tokens: self.max_tokens,
270 tools: tool_defs,
271 think: self.think,
272 enable_caching: true,
273 server_tools: Vec::new(),
274 };
275
276 let response = self.call_streaming(&request).await?;
280
281 self.track_usage(&response.usage);
283
284 crate::debug::debug_log().api_call(
286 &self.model_name,
287 response.usage.input_tokens,
288 response.usage.cache_read_input_tokens > 0
289 );
290
291 should_continue = self.process_response(&response).await?;
293
294 let context_size = self.provider.context_size();
296
297 let api_tokens = self.last_input_tokens.load(Ordering::Relaxed) as u32;
300 let estimated_tokens = crate::compress::estimate_total_tokens(&self.messages);
301
302 let current_tokens = if api_tokens > 0 && api_tokens >= estimated_tokens / 2 {
304 api_tokens } else {
306 estimated_tokens };
308
309 crate::debug::debug_log().log(
311 "compression",
312 &format!("check: api={}, estimated={}, using={}, context={}, threshold={}",
313 api_tokens, estimated_tokens, current_tokens, context_size.unwrap_or(0), self.compression_config.threshold)
314 );
315
316 if should_compress(current_tokens, context_size, &self.compression_config) {
317 self.emit(AgentEvent::progress("Compressing context...", None))?;
318
319 let _original_count = self.messages.len();
320 let original_tokens = current_tokens;
321
322 match crate::compress::compress_messages(
324 &self.messages,
325 crate::compress::CompressionStrategy::SlidingWindow,
326 &self.compression_config,
327 ) {
328 Ok(compressed) => {
329 let compressed_tokens = crate::compress::estimate_total_tokens(&compressed);
330 self.messages = compressed;
331 self.total_input_tokens.store(compressed_tokens as u64, Ordering::Relaxed);
332 self.last_input_tokens.store(compressed_tokens as u64, Ordering::Relaxed);
333
334 let ratio = compressed_tokens as f32 / original_tokens as f32;
336 crate::debug::debug_log().compression(original_tokens, compressed_tokens, ratio);
337
338 self.emit(AgentEvent::with_data(
339 crate::event::EventType::CompressionCompleted,
340 crate::event::EventData::Compression {
341 original_tokens: original_tokens as u64,
342 compressed_tokens: compressed_tokens as u64,
343 ratio: compressed_tokens as f32 / original_tokens as f32,
344 },
345 ))?;
346 }
347 Err(e) => {
348 self.emit(AgentEvent::progress(
349 format!("Compression failed: {}", e),
350 None,
351 ))?;
352 }
353 }
354 }
355 }
356
357 self.emit(AgentEvent::usage_with_cache(
359 self.total_input_tokens.load(Ordering::Relaxed),
360 self.total_output_tokens.load(Ordering::Relaxed),
361 0, 0, ))?;
363
364 self.emit(AgentEvent::session_ended())?;
366
367 Ok(Vec::new())
368 }
369
370 async fn call_streaming(&mut self, request: &ChatRequest) -> Result<ChatResponse> {
372 use crate::providers::StreamEvent;
373
374 const MAX_RETRIES: u32 = 5;
375 const RETRY_DELAY_MS: u64 = 1000; let mut attempt = 0;
378
379 loop {
380 attempt += 1;
381
382 if let Some(token) = &self.cancel_token && token.is_cancelled() {
384 return Err(anyhow::anyhow!("Operation cancelled"));
385 }
386
387 let rx_result = self.provider.chat_stream(request.clone()).await;
389
390 match rx_result {
391 Ok(mut rx) => {
392 let mut response_content: Vec<ContentBlock> = Vec::new();
394 let mut current_text = String::new();
395 let mut current_thinking = String::new();
396 let mut usage = Usage {
397 input_tokens: 0,
398 output_tokens: 0,
399 cache_creation_input_tokens: 0,
400 cache_read_input_tokens: 0,
401 };
402 let mut should_retry = false;
403
404 loop {
406 if let Some(token) = &self.cancel_token && token.is_cancelled() {
408 return Err(anyhow::anyhow!("Operation cancelled"));
409 }
410
411 let event = tokio::select! {
413 event = rx.recv() => event,
414 _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
415 continue;
417 }
418 };
419
420 match event {
421 None => {
422 break;
424 }
425 Some(StreamEvent::FirstByte) => {
426 }
428 Some(StreamEvent::ThinkingDelta(delta)) => {
429 if current_thinking.is_empty() {
430 self.emit(AgentEvent::thinking_start())?;
431 }
432 current_thinking.push_str(&delta);
433 self.emit(AgentEvent::thinking_delta(delta, None))?;
434 }
435 Some(StreamEvent::TextDelta(delta)) => {
436 if current_text.is_empty() {
437 self.emit(AgentEvent::text_start())?;
438 }
439 current_text.push_str(&delta);
440 self.emit(AgentEvent::text_delta(delta))?;
441 }
442 Some(StreamEvent::ToolUseStart { id, name }) => {
443 if !current_thinking.is_empty() {
445 self.emit(AgentEvent::thinking_end())?;
446 response_content.push(ContentBlock::Thinking {
447 thinking: current_thinking.clone(),
448 signature: None,
449 });
450 current_thinking.clear();
451 }
452 if !current_text.is_empty() {
454 self.emit(AgentEvent::text_end())?;
455 response_content.push(ContentBlock::Text { text: current_text.clone() });
456 current_text.clear();
457 }
458 self.emit(AgentEvent::tool_use_start(&id, &name, None))?;
459 }
460 Some(StreamEvent::ToolInputDelta { bytes_so_far: _ }) => {
461 }
463 Some(StreamEvent::Usage { output_tokens }) => {
464 self.emit(AgentEvent::usage_with_cache(
466 0, output_tokens as u64,
468 0, 0 ))?;
470 usage.output_tokens = output_tokens;
471 }
472 Some(StreamEvent::Done(resp)) => {
473 if !current_thinking.is_empty() {
475 self.emit(AgentEvent::thinking_end())?;
476 response_content.push(ContentBlock::Thinking {
477 thinking: current_thinking.clone(),
478 signature: None,
479 });
480 }
481 if !current_text.is_empty() {
483 self.emit(AgentEvent::text_end())?;
484 response_content.push(ContentBlock::Text { text: current_text.clone() });
485 }
486 for block in &resp.content {
488 if !response_content.iter().any(|b| b == block) {
489 response_content.push(block.clone());
490 }
491 }
492 usage = resp.usage;
493 }
494 Some(StreamEvent::Error(msg)) => {
495 if attempt < MAX_RETRIES {
497 self.emit(AgentEvent::progress(
498 format!("⚠️ Stream error, retrying ({}/{}): {}", attempt, MAX_RETRIES, &msg),
499 None,
500 ))?;
501 let delay = RETRY_DELAY_MS * (1 << (attempt - 1));
503 tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
504 should_retry = true;
505 break; } else {
507 self.emit(AgentEvent::error(msg.clone(), None, None))?;
508 return Err(anyhow::anyhow!("Stream error after {} retries: {}", MAX_RETRIES, msg));
509 }
510 }
511 }
512 }
513
514 if should_retry {
515 continue; }
517
518 return Ok(ChatResponse {
519 content: response_content,
520 stop_reason: StopReason::EndTurn,
521 usage,
522 });
523 }
524 Err(e) => {
525 if attempt < MAX_RETRIES {
527 let error_msg = e.to_string();
528 self.emit(AgentEvent::progress(
529 format!("⚠️ API error, retrying ({}/{}): {}", attempt, MAX_RETRIES, &error_msg),
530 None,
531 ))?;
532 let delay = RETRY_DELAY_MS * (1 << (attempt - 1));
534 tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
535 } else {
536 return Err(anyhow::anyhow!("API error after {} retries: {}", MAX_RETRIES, e));
537 }
538 }
539 }
540 }
541 }
542
543 async fn process_response(&mut self, response: &ChatResponse) -> Result<bool> {
545 let mut has_tool_use = false;
546 let mut assistant_content: Vec<ContentBlock> = Vec::new();
547 let mut tool_results: Vec<Message> = Vec::new();
548
549 for block in &response.content {
550 match block {
551 ContentBlock::Text { text } => {
553 assistant_content.push(ContentBlock::Text { text: text.clone() });
554 }
555
556 ContentBlock::Thinking { thinking, signature } => {
557 assistant_content.push(ContentBlock::Thinking {
558 thinking: thinking.clone(),
559 signature: signature.clone(),
560 });
561 }
562
563 ContentBlock::ToolUse { id, name, input } => {
564 if let Some(token) = &self.cancel_token && token.is_cancelled() {
566 return Err(anyhow::anyhow!("Operation cancelled"));
567 }
568
569 has_tool_use = true;
570
571 let result = self.execute_tool(name, input.clone()).await;
576
577 let (content, is_error) = match result {
578 Ok(output) => (output, false),
579 Err(e) => (e.to_string(), true),
580 };
581
582 self.emit(AgentEvent::tool_result(id.clone(), name.clone(), extract_tool_detail(name, input), content.clone(), is_error))?;
583
584 assistant_content.push(ContentBlock::ToolUse {
586 id: id.clone(),
587 name: name.clone(),
588 input: input.clone(),
589 });
590
591 tool_results.push(Message {
593 role: Role::User,
594 content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
595 tool_use_id: id.clone(),
596 content: format!("{}: {}", if is_error { "Error" } else { "Result" }, content),
597 }]),
598 });
599 }
600
601 _ => {}
602 }
603 }
604
605 if !assistant_content.is_empty() {
607 self.messages.push(Message {
608 role: Role::Assistant,
609 content: MessageContent::Blocks(assistant_content),
610 });
611 }
612
613 for msg in tool_results {
615 self.messages.push(msg);
616 }
617
618 Ok(has_tool_use)
620 }
621
622 async fn execute_tool(&mut self, name: &str, input: serde_json::Value) -> Result<String> {
624 let tool = self.tools.iter().find(|t| t.definition().name == name);
625
626 if let Some(tool) = tool {
627 let current_mode = ApproveMode::from_u8(self.approve_mode.load(Ordering::Relaxed));
629
630 log::debug!(
632 "Tool '{}' approval check: mode={}, risk={}, needs_approval={}",
633 name, current_mode, tool.risk_level(),
634 needs_approval(current_mode, tool.risk_level())
635 );
636
637 if needs_approval(current_mode, tool.risk_level()) {
639 if self.ask_rx.is_some() {
641 let detail = match name {
643 "bash" => format!("Command: {}", input["command"].as_str().unwrap_or("?")),
644 "write" => format!("File: {}", input["path"].as_str().unwrap_or("?")),
645 "edit" | "multi_edit" => format!("File: {}", input["path"].as_str().unwrap_or("?")),
646 _ => format!("Tool: {}", name),
647 };
648
649 let question = format!(
650 "⚠️ Tool '{}' requires approval (risk: {})\n{}\n\nAllow? (y/n)",
651 name, tool.risk_level(), detail
652 );
653
654 self.emit(AgentEvent::with_data(
656 EventType::AskQuestion,
657 EventData::AskQuestion { question, options: None },
658 ))?;
659
660 if let Some(rx) = &mut self.ask_rx {
662 match rx.recv().await {
663 Some(answer) => {
664 let answer_lower = answer.trim().to_lowercase();
665 if matches!(answer_lower.as_str(), "a" | "abort" | "q" | "quit" | "stop") {
667 self.emit(AgentEvent::with_data(
668 EventType::Error,
669 EventData::Error { message: "Aborted by user".into(), code: None, source: None },
670 ))?;
671 return Err(anyhow::anyhow!("Session aborted by user"));
672 }
673 let approved = matches!(
675 answer_lower.as_str(),
676 "y" | "yes" | "ok" | "approve" | ""
677 );
678 if !approved {
679 return Err(anyhow::anyhow!(
681 "Tool '{}' rejected by user (answer: '{}')", name, answer_lower
682 ));
683 }
684 }
685 None => {
686 return Err(anyhow::anyhow!("Approval channel closed"));
687 }
688 }
689 }
690 } else {
691 return Err(anyhow::anyhow!(
693 "Tool '{}' requires manual approval (risk: {}). Use --approve-mode auto to auto-approve.",
694 name, tool.risk_level()
695 ));
696 }
697 }
698
699 if name == "ask" && self.ask_rx.is_some() {
701 let question = input["question"].as_str().unwrap_or("").to_string();
702 let options = input.get("options").cloned();
703
704 self.emit(AgentEvent::with_data(
706 EventType::AskQuestion,
707 EventData::AskQuestion { question, options },
708 ))?;
709
710 if let Some(rx) = &mut self.ask_rx {
712 match rx.recv().await {
713 Some(answer) => return Ok(answer),
714 None => return Err(anyhow::anyhow!("Ask channel closed")),
715 }
716 }
717 }
718
719 self.emit(AgentEvent::progress(format!("Executing: {}", name), None))?;
721 tool.execute(input).await
722 } else {
723 Err(anyhow::anyhow!("Tool '{}' not found", name))
724 }
725 }
726
727 fn track_usage(&self, usage: &Usage) {
729 self.total_input_tokens.fetch_add(usage.input_tokens as u64, Ordering::Relaxed);
730 self.total_output_tokens.fetch_add(usage.output_tokens as u64, Ordering::Relaxed);
731 self.last_input_tokens.store(usage.input_tokens as u64, Ordering::Relaxed);
733
734 crate::debug::debug_log().log(
736 "usage",
737 &format!("tracked: input_tokens={}, output_tokens={}, cache_read={}, cache_created={}",
738 usage.input_tokens, usage.output_tokens, usage.cache_read_input_tokens, usage.cache_creation_input_tokens)
739 );
740
741 let _ = self.event_tx.try_send(AgentEvent::usage_with_cache(
743 self.total_input_tokens.load(Ordering::Relaxed),
744 usage.output_tokens as u64,
745 usage.cache_read_input_tokens as u64,
746 usage.cache_creation_input_tokens as u64,
747 ));
748 }
749
750 #[allow(dead_code)]
752 fn estimate_context_size(&self) -> u32 {
753 (self.messages.len() as u32) * 100 + self.total_input_tokens.load(Ordering::Relaxed) as u32
755 }
756
757 fn emit(&self, event: AgentEvent) -> Result<()> {
759 match self.event_tx.try_send(event) {
761 Ok(_) => Ok(()),
762 Err(mpsc::error::TrySendError::Full(_)) => {
763 Ok(())
765 }
766 Err(mpsc::error::TrySendError::Closed(_)) => {
767 Err(anyhow::anyhow!("Event channel closed"))
769 }
770 }
771 }
772
773 pub fn set_messages(&mut self, messages: Vec<Message>) {
775 self.messages = messages;
776 }
777
778 pub fn get_messages(&self) -> &[Message] {
780 &self.messages
781 }
782
783 pub fn get_token_counts(&self) -> (u64, u64) {
785 (
786 self.total_input_tokens.load(Ordering::Relaxed),
787 self.total_output_tokens.load(Ordering::Relaxed),
788 )
789 }
790
791 pub fn clear_history(&mut self) {
793 self.messages.clear();
794 self.total_input_tokens.store(0, Ordering::Relaxed);
795 self.total_output_tokens.store(0, Ordering::Relaxed);
796 self.last_input_tokens.store(0, Ordering::Relaxed);
797 }
798
799 pub fn message_count(&self) -> usize {
801 self.messages.len()
802 }
803}
804
805fn extract_tool_detail(tool_name: &str, input: &serde_json::Value) -> Option<String> {
807 match tool_name.to_lowercase().as_str() {
808 "read" => input.get("path").and_then(|v| v.as_str())
809 .map(|s| truncate_str(s, 50)),
810 "write" => input.get("path").and_then(|v| v.as_str())
811 .map(|s| truncate_str(s, 50)),
812 "edit" | "multi_edit" => {
813 let path = input.get("path").and_then(|v| v.as_str());
814 let old = input.get("old_string").and_then(|v| v.as_str());
815 match (path, old) {
816 (Some(p), Some(o)) => Some(format!("{}: \"{}\"", truncate_str(p, 30), truncate_str(o, 20))),
817 (Some(p), None) => Some(truncate_str(p, 50)),
818 _ => None,
819 }
820 }
821 "bash" => input.get("command").and_then(|v| v.as_str())
822 .map(|s| truncate_str(s, 60)),
823 "search" | "grep" => input.get("pattern").and_then(|v| v.as_str())
824 .map(|s| format!("\"{}\"", truncate_str(s, 30))),
825 "glob" => input.get("pattern").and_then(|v| v.as_str())
826 .map(|s| truncate_str(s, 40)),
827 "ls" => input.get("path").and_then(|v| v.as_str())
828 .map(|s| truncate_str(s, 50)),
829 "websearch" => input.get("query").and_then(|v| v.as_str())
830 .map(|s| truncate_str(s, 40)),
831 "webfetch" => input.get("url").and_then(|v| v.as_str())
832 .map(|s| truncate_str(s, 50)),
833 "task" => input.get("description").and_then(|v| v.as_str())
834 .map(|s| truncate_str(s, 40)),
835 "task_create" => input.get("description").and_then(|v| v.as_str())
836 .map(|s| truncate_str(s, 40)),
837 "task_get" | "task_stop" => input.get("task_id").and_then(|v| v.as_str())
838 .map(|s| s.to_string()),
839 _ => None,
840 }
841}
842
843fn truncate_str(s: &str, max: usize) -> String {
845 if s.chars().count() <= max { s.to_string() }
846 else { s.chars().take(max.saturating_sub(3)).collect::<String>() + "..." }
847}
848