Skip to main content

zeph_core/agent/
agent_access_impl.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Implementation of [`zeph_commands::traits::agent::AgentAccess`] for [`Agent<C>`].
5//!
6//! Each method in `AgentAccess` returns a formatted `String` result (without sending to the
7//! channel directly), so that `CommandContext::sink` does not conflict with this borrow.
8//! The one exception is methods for subsystems that are already channel-free (memory, graph).
9//!
10//! [`Agent<C>`]: super::Agent
11
12use std::fmt::Write as _;
13use std::future::Future;
14use std::pin::Pin;
15
16use zeph_commands::CommandError;
17use zeph_commands::traits::agent::AgentAccess;
18use zeph_memory::{GraphExtractionConfig, MessageId, extract_and_store};
19
20use super::{Agent, error::AgentError};
21use crate::channel::Channel;
22
23impl<C: Channel + Send + 'static> AgentAccess for Agent<C> {
24    // ----- /memory -----
25
26    fn memory_tiers<'a>(
27        &'a mut self,
28    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
29        Box::pin(async move {
30            let Some(memory) = self.memory_state.persistence.memory.clone() else {
31                return Ok("Memory not configured.".to_owned());
32            };
33            match memory.sqlite().count_messages_by_tier().await {
34                Ok((episodic, semantic)) => {
35                    let mut out = String::new();
36                    let _ = writeln!(out, "Memory tiers:");
37                    let _ = writeln!(out, "  Working:  (current context window — virtual)");
38                    let _ = writeln!(out, "  Episodic: {episodic} messages");
39                    let _ = writeln!(out, "  Semantic: {semantic} facts");
40                    Ok(out.trim_end().to_owned())
41                }
42                Err(e) => Ok(format!("Failed to query tier stats: {e}")),
43            }
44        })
45    }
46
47    fn memory_promote<'a>(
48        &'a mut self,
49        ids_str: &'a str,
50    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
51        Box::pin(async move {
52            let Some(memory) = self.memory_state.persistence.memory.clone() else {
53                return Ok("Memory not configured.".to_owned());
54            };
55            let ids: Vec<MessageId> = ids_str
56                .split_whitespace()
57                .filter_map(|s| s.parse::<i64>().ok().map(MessageId))
58                .collect();
59            if ids.is_empty() {
60                return Ok(
61                    "Usage: /memory promote <id> [id...]\nExample: /memory promote 42 43 44"
62                        .to_owned(),
63                );
64            }
65            match memory.sqlite().manual_promote(&ids).await {
66                Ok(count) => Ok(format!("Promoted {count} message(s) to semantic tier.")),
67                Err(e) => Ok(format!("Promotion failed: {e}")),
68            }
69        })
70    }
71
72    // ----- /graph -----
73
74    fn graph_stats<'a>(
75        &'a mut self,
76    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
77        Box::pin(async move {
78            let Some(memory) = self.memory_state.persistence.memory.as_ref() else {
79                return Ok("Graph memory is not enabled.".to_owned());
80            };
81            let Some(store) = memory.graph_store.as_ref() else {
82                return Ok("Graph memory is not enabled.".to_owned());
83            };
84
85            let (entities, edges, communities, distribution) = tokio::join!(
86                store.entity_count(),
87                store.active_edge_count(),
88                store.community_count(),
89                store.edge_type_distribution()
90            );
91            let mut msg = format!(
92                "Graph memory: {} entities, {} edges, {} communities",
93                entities.unwrap_or(0),
94                edges.unwrap_or(0),
95                communities.unwrap_or(0)
96            );
97            if let Ok(dist) = distribution
98                && !dist.is_empty()
99            {
100                let dist_str: Vec<String> = dist.iter().map(|(t, c)| format!("{t}={c}")).collect();
101                write!(msg, "\nEdge types: {}", dist_str.join(", ")).unwrap_or(());
102            }
103            Ok(msg)
104        })
105    }
106
107    fn graph_entities<'a>(
108        &'a mut self,
109    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
110        Box::pin(async move {
111            let Some(memory) = self.memory_state.persistence.memory.as_ref() else {
112                return Ok("Graph memory is not enabled.".to_owned());
113            };
114            let Some(store) = memory.graph_store.as_ref() else {
115                return Ok("Graph memory is not enabled.".to_owned());
116            };
117
118            let entities = store
119                .all_entities()
120                .await
121                .map_err(|e| CommandError::new(e.to_string()))?;
122            if entities.is_empty() {
123                return Ok("No entities found.".to_owned());
124            }
125
126            let total = entities.len();
127            let display: Vec<String> = entities
128                .iter()
129                .take(50)
130                .map(|e| {
131                    format!(
132                        "  {:<40}  {:<15}  {}",
133                        e.name,
134                        e.entity_type.as_str(),
135                        e.last_seen_at.split('T').next().unwrap_or(&e.last_seen_at)
136                    )
137                })
138                .collect();
139            let mut msg = format!(
140                "Entities ({total} total):\n  {:<40}  {:<15}  {}\n{}",
141                "NAME",
142                "TYPE",
143                "LAST SEEN",
144                display.join("\n")
145            );
146            if total > 50 {
147                write!(msg, "\n  ...and {} more", total - 50).unwrap_or(());
148            }
149            Ok(msg)
150        })
151    }
152
153    fn graph_facts<'a>(
154        &'a mut self,
155        name: &'a str,
156    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
157        Box::pin(async move {
158            let Some(memory) = self.memory_state.persistence.memory.as_ref() else {
159                return Ok("Graph memory is not enabled.".to_owned());
160            };
161            let Some(store) = memory.graph_store.as_ref() else {
162                return Ok("Graph memory is not enabled.".to_owned());
163            };
164
165            let matches = store
166                .find_entity_by_name(name)
167                .await
168                .map_err(|e| CommandError::new(e.to_string()))?;
169            if matches.is_empty() {
170                return Ok(format!("No entity found matching '{name}'."));
171            }
172
173            let entity = &matches[0];
174            let edges = store
175                .edges_for_entity(entity.id)
176                .await
177                .map_err(|e| CommandError::new(e.to_string()))?;
178            if edges.is_empty() {
179                return Ok(format!("Entity '{}' has no known facts.", entity.name));
180            }
181
182            let mut entity_names: std::collections::HashMap<i64, String> =
183                std::collections::HashMap::new();
184            entity_names.insert(entity.id, entity.name.clone());
185            for edge in &edges {
186                let other_id = if edge.source_entity_id == entity.id {
187                    edge.target_entity_id
188                } else {
189                    edge.source_entity_id
190                };
191                entity_names.entry(other_id).or_default();
192            }
193            for (&id, name_val) in &mut entity_names {
194                if name_val.is_empty() {
195                    if let Ok(Some(other)) = store.find_entity_by_id(id).await {
196                        *name_val = other.name;
197                    } else {
198                        *name_val = format!("#{id}");
199                    }
200                }
201            }
202
203            let lines: Vec<String> = edges
204                .iter()
205                .map(|e| {
206                    let src = entity_names
207                        .get(&e.source_entity_id)
208                        .cloned()
209                        .unwrap_or_else(|| format!("#{}", e.source_entity_id));
210                    let tgt = entity_names
211                        .get(&e.target_entity_id)
212                        .cloned()
213                        .unwrap_or_else(|| format!("#{}", e.target_entity_id));
214                    format!(
215                        "  {} --[{}/{}]--> {}: {} (confidence: {:.2})",
216                        src, e.relation, e.edge_type, tgt, e.fact, e.confidence
217                    )
218                })
219                .collect();
220            Ok(format!(
221                "Facts for '{}':\n{}",
222                entity.name,
223                lines.join("\n")
224            ))
225        })
226    }
227
228    fn graph_history<'a>(
229        &'a mut self,
230        name: &'a str,
231    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
232        Box::pin(async move {
233            let Some(memory) = self.memory_state.persistence.memory.as_ref() else {
234                return Ok("Graph memory is not enabled.".to_owned());
235            };
236            let Some(store) = memory.graph_store.as_ref() else {
237                return Ok("Graph memory is not enabled.".to_owned());
238            };
239
240            let matches = store
241                .find_entity_by_name(name)
242                .await
243                .map_err(|e| CommandError::new(e.to_string()))?;
244            if matches.is_empty() {
245                return Ok(format!("No entity found matching '{name}'."));
246            }
247
248            let entity = &matches[0];
249            let edges = store
250                .edge_history_for_entity(entity.id, 50)
251                .await
252                .map_err(|e| CommandError::new(e.to_string()))?;
253            if edges.is_empty() {
254                return Ok(format!("Entity '{}' has no edge history.", entity.name));
255            }
256
257            let mut entity_names: std::collections::HashMap<i64, String> =
258                std::collections::HashMap::new();
259            entity_names.insert(entity.id, entity.name.clone());
260            for edge in &edges {
261                for &id in &[edge.source_entity_id, edge.target_entity_id] {
262                    entity_names.entry(id).or_default();
263                }
264            }
265            for (&id, name_val) in &mut entity_names {
266                if name_val.is_empty() {
267                    if let Ok(Some(other)) = store.find_entity_by_id(id).await {
268                        *name_val = other.name;
269                    } else {
270                        *name_val = format!("#{id}");
271                    }
272                }
273            }
274
275            let n = edges.len();
276            let lines: Vec<String> = edges
277                .iter()
278                .map(|e| {
279                    let status = if e.valid_to.is_some() {
280                        let date = e
281                            .valid_to
282                            .as_deref()
283                            .and_then(|s| s.split('T').next().or_else(|| s.split(' ').next()))
284                            .unwrap_or("?");
285                        format!("[expired {date}]")
286                    } else {
287                        "[active]".to_string()
288                    };
289                    let src = entity_names
290                        .get(&e.source_entity_id)
291                        .cloned()
292                        .unwrap_or_else(|| format!("#{}", e.source_entity_id));
293                    let tgt = entity_names
294                        .get(&e.target_entity_id)
295                        .cloned()
296                        .unwrap_or_else(|| format!("#{}", e.target_entity_id));
297                    format!(
298                        "  {status} {} --[{}/{}]--> {}: {} (confidence: {:.2})",
299                        src, e.relation, e.edge_type, tgt, e.fact, e.confidence
300                    )
301                })
302                .collect();
303            Ok(format!(
304                "Edge history for '{}' ({n} edges):\n{}",
305                entity.name,
306                lines.join("\n")
307            ))
308        })
309    }
310
311    fn graph_communities<'a>(
312        &'a mut self,
313    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
314        Box::pin(async move {
315            let Some(memory) = self.memory_state.persistence.memory.as_ref() else {
316                return Ok("Graph memory is not enabled.".to_owned());
317            };
318            let Some(store) = memory.graph_store.as_ref() else {
319                return Ok("Graph memory is not enabled.".to_owned());
320            };
321
322            let communities = store
323                .all_communities()
324                .await
325                .map_err(|e| CommandError::new(e.to_string()))?;
326            if communities.is_empty() {
327                return Ok("No communities detected yet. Run graph backfill first.".to_owned());
328            }
329
330            let lines: Vec<String> = communities
331                .iter()
332                .map(|c| format!("  [{}]: {}", c.name, c.summary))
333                .collect();
334            Ok(format!(
335                "Communities ({}):\n{}",
336                communities.len(),
337                lines.join("\n")
338            ))
339        })
340    }
341
342    fn graph_backfill<'a>(
343        &'a mut self,
344        limit: Option<usize>,
345        progress_cb: &'a mut (dyn FnMut(String) + Send),
346    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
347        Box::pin(async move {
348            let Some(memory) = self.memory_state.persistence.memory.clone() else {
349                return Ok("Graph memory is not enabled.".to_owned());
350            };
351            let Some(store) = memory.graph_store.clone() else {
352                return Ok("Graph memory is not enabled.".to_owned());
353            };
354
355            let total = store.unprocessed_message_count().await.unwrap_or(0);
356            let cap = limit.unwrap_or(usize::MAX);
357
358            progress_cb(format!(
359                "Starting graph backfill... ({total} unprocessed messages)"
360            ));
361
362            let batch_size = 50usize;
363            let mut processed = 0usize;
364            let mut total_entities = 0usize;
365            let mut total_edges = 0usize;
366
367            let graph_cfg = self.memory_state.extraction.graph_config.clone();
368            let provider = self.provider.clone();
369
370            loop {
371                let remaining_cap = cap.saturating_sub(processed);
372                if remaining_cap == 0 {
373                    break;
374                }
375                let batch_limit = batch_size.min(remaining_cap);
376                let messages = store
377                    .unprocessed_messages_for_backfill(batch_limit)
378                    .await
379                    .map_err(|e| CommandError::new(e.to_string()))?;
380                if messages.is_empty() {
381                    break;
382                }
383
384                let ids: Vec<zeph_memory::types::MessageId> =
385                    messages.iter().map(|(id, _)| *id).collect();
386
387                for (_id, content) in &messages {
388                    if content.trim().is_empty() {
389                        continue;
390                    }
391                    let extraction_cfg = GraphExtractionConfig {
392                        max_entities: graph_cfg.max_entities_per_message,
393                        max_edges: graph_cfg.max_edges_per_message,
394                        extraction_timeout_secs: graph_cfg.extraction_timeout_secs,
395                        community_refresh_interval: 0,
396                        expired_edge_retention_days: graph_cfg.expired_edge_retention_days,
397                        max_entities_cap: graph_cfg.max_entities,
398                        community_summary_max_prompt_bytes: graph_cfg
399                            .community_summary_max_prompt_bytes,
400                        community_summary_concurrency: graph_cfg.community_summary_concurrency,
401                        lpa_edge_chunk_size: graph_cfg.lpa_edge_chunk_size,
402                        note_linking: zeph_memory::NoteLinkingConfig::default(),
403                        link_weight_decay_lambda: graph_cfg.link_weight_decay_lambda,
404                        link_weight_decay_interval_secs: graph_cfg.link_weight_decay_interval_secs,
405                        belief_revision_enabled: graph_cfg.belief_revision.enabled,
406                        belief_revision_similarity_threshold: graph_cfg
407                            .belief_revision
408                            .similarity_threshold,
409                        conversation_id: None,
410                    };
411                    let pool = store.pool().clone();
412                    match extract_and_store(
413                        content.clone(),
414                        vec![],
415                        provider.clone(),
416                        pool,
417                        extraction_cfg,
418                        None,
419                        None,
420                    )
421                    .await
422                    {
423                        Ok(result) => {
424                            total_entities += result.stats.entities_upserted;
425                            total_edges += result.stats.edges_inserted;
426                        }
427                        Err(e) => {
428                            tracing::warn!("backfill extraction error: {e:#}");
429                        }
430                    }
431                }
432
433                store
434                    .mark_messages_graph_processed(&ids)
435                    .await
436                    .map_err(|e| CommandError::new(e.to_string()))?;
437                processed += messages.len();
438
439                progress_cb(format!(
440                    "Backfill progress: {processed} messages processed, \
441                     {total_entities} entities, {total_edges} edges"
442                ));
443            }
444
445            Ok(format!(
446                "Backfill complete: {total_entities} entities, {total_edges} edges \
447                 extracted from {processed} messages"
448            ))
449        })
450    }
451
452    // ----- /guidelines -----
453
454    fn guidelines<'a>(
455        &'a mut self,
456    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
457        Box::pin(async move {
458            const MAX_DISPLAY_CHARS: usize = 4096;
459
460            let Some(memory) = &self.memory_state.persistence.memory else {
461                return Ok("No memory backend initialised.".to_owned());
462            };
463
464            let cid = self.memory_state.persistence.conversation_id;
465            let sqlite = memory.sqlite();
466
467            let (version, text) = sqlite
468                .load_compression_guidelines(cid)
469                .await
470                .map_err(|e: zeph_memory::MemoryError| CommandError::new(e.to_string()))?;
471
472            if version == 0 || text.is_empty() {
473                return Ok("No compression guidelines generated yet.".to_owned());
474            }
475
476            let (_, created_at) = sqlite
477                .load_compression_guidelines_meta(cid)
478                .await
479                .unwrap_or((0, String::new()));
480
481            let (body, truncated) = if text.len() > MAX_DISPLAY_CHARS {
482                let end = text.floor_char_boundary(MAX_DISPLAY_CHARS);
483                (&text[..end], true)
484            } else {
485                (text.as_str(), false)
486            };
487
488            let mut output =
489                format!("Compression Guidelines (v{version}, updated {created_at}):\n\n{body}");
490            if truncated {
491                output.push_str("\n\n[truncated]");
492            }
493            Ok(output)
494        })
495    }
496
497    // ----- /model, /provider -----
498
499    fn handle_model<'a>(
500        &'a mut self,
501        arg: &'a str,
502    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
503        Box::pin(async move {
504            let input = if arg.is_empty() {
505                "/model".to_owned()
506            } else {
507                format!("/model {arg}")
508            };
509            self.handle_model_command_as_string(&input).await
510        })
511    }
512
513    fn handle_provider<'a>(
514        &'a mut self,
515        arg: &'a str,
516    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
517        Box::pin(async move { self.handle_provider_command_as_string(arg) })
518    }
519
520    // ----- /policy -----
521
522    fn handle_policy<'a>(
523        &'a mut self,
524        args: &'a str,
525    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
526        Box::pin(async move { Ok(self.handle_policy_command_as_string(args)) })
527    }
528
529    // ----- /scheduler -----
530
531    #[cfg(feature = "scheduler")]
532    fn list_scheduled_tasks<'a>(
533        &'a mut self,
534    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
535        Box::pin(async move {
536            let result = self
537                .handle_scheduler_list_as_string()
538                .await
539                .map_err(|e| CommandError::new(e.to_string()))?;
540            Ok(Some(result))
541        })
542    }
543
544    #[cfg(not(feature = "scheduler"))]
545    fn list_scheduled_tasks<'a>(
546        &'a mut self,
547    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
548        Box::pin(async move { Ok(None) })
549    }
550
551    // ----- /lsp -----
552
553    fn lsp_status<'a>(
554        &'a mut self,
555    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
556        Box::pin(async move {
557            self.handle_lsp_status_as_string()
558                .await
559                .map_err(|e| CommandError::new(e.to_string()))
560        })
561    }
562
563    // ----- /compact -----
564
565    fn compact_context<'a>(
566        &'a mut self,
567    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
568        Box::pin(self.compact_context_command())
569    }
570
571    // ----- /new -----
572
573    fn reset_conversation<'a>(
574        &'a mut self,
575        keep_plan: bool,
576        no_digest: bool,
577    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
578        Box::pin(async move {
579            match self.reset_conversation(keep_plan, no_digest).await {
580                Ok((old_id, new_id)) => {
581                    let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
582                    let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
583                    let keep_note = if keep_plan { " (plan preserved)" } else { "" };
584                    Ok(format!(
585                        "New conversation started. Previous: {old} → Current: {new}{keep_note}"
586                    ))
587                }
588                Err(e) => Ok(format!("Failed to start new conversation: {e}")),
589            }
590        })
591    }
592
593    // ----- /cache-stats -----
594
595    fn cache_stats(&self) -> String {
596        self.tool_orchestrator.cache_stats()
597    }
598
599    // ----- /status -----
600
601    fn session_status<'a>(
602        &'a mut self,
603    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
604        Box::pin(async move { Ok(self.handle_status_as_string()) })
605    }
606
607    // ----- /guardrail -----
608
609    fn guardrail_status(&self) -> String {
610        self.format_guardrail_status()
611    }
612
613    // ----- /focus -----
614
615    fn focus_status(&self) -> String {
616        self.format_focus_status()
617    }
618
619    // ----- /sidequest -----
620
621    fn sidequest_status(&self) -> String {
622        self.format_sidequest_status()
623    }
624
625    // ----- /image -----
626
627    fn load_image<'a>(
628        &'a mut self,
629        path: &'a str,
630    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
631        Box::pin(async move { Ok(self.handle_image_as_string(path)) })
632    }
633
634    // ----- /mcp -----
635
636    fn handle_mcp<'a>(
637        &'a mut self,
638        args: &'a str,
639    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
640        // Extract all owned data before the async block so no &mut self reference is
641        // held across an .await point, satisfying the `for<'a>` Send bound.
642        let args_owned = args.to_owned();
643        let parts: Vec<String> = args_owned.split_whitespace().map(str::to_owned).collect();
644        let sub = parts.first().cloned().unwrap_or_default();
645
646        match sub.as_str() {
647            "list" => {
648                // Read-only: clone all data before async.
649                let manager = self.mcp.manager.clone();
650                let tools_snapshot: Vec<(String, String)> = self
651                    .mcp
652                    .tools
653                    .iter()
654                    .map(|t| (t.server_id.clone(), t.name.clone()))
655                    .collect();
656                Box::pin(async move {
657                    use std::fmt::Write;
658                    let Some(manager) = manager else {
659                        return Ok("MCP is not enabled.".to_owned());
660                    };
661                    let server_ids = manager.list_servers().await;
662                    if server_ids.is_empty() {
663                        return Ok("No MCP servers connected.".to_owned());
664                    }
665                    let mut output = String::from("Connected MCP servers:\n");
666                    let mut total = 0usize;
667                    for id in &server_ids {
668                        let count = tools_snapshot.iter().filter(|(sid, _)| sid == id).count();
669                        total += count;
670                        let _ = writeln!(output, "- {id} ({count} tools)");
671                    }
672                    let _ = write!(output, "Total: {total} tool(s)");
673                    Ok(output)
674                })
675            }
676            "tools" => {
677                // Read-only: collect tool info before async.
678                let server_id = parts.get(1).cloned();
679                let owned_tools: Vec<(String, String)> = if let Some(ref sid) = server_id {
680                    self.mcp
681                        .tools
682                        .iter()
683                        .filter(|t| &t.server_id == sid)
684                        .map(|t| (t.name.clone(), t.description.clone()))
685                        .collect()
686                } else {
687                    Vec::new()
688                };
689                Box::pin(async move {
690                    use std::fmt::Write;
691                    let Some(server_id) = server_id else {
692                        return Ok("Usage: /mcp tools <server_id>".to_owned());
693                    };
694                    if owned_tools.is_empty() {
695                        return Ok(format!("No tools found for server '{server_id}'."));
696                    }
697                    let mut output =
698                        format!("Tools for '{server_id}' ({} total):\n", owned_tools.len());
699                    for (name, desc) in &owned_tools {
700                        if desc.is_empty() {
701                            let _ = writeln!(output, "- {name}");
702                        } else {
703                            let _ = writeln!(output, "- {name} — {desc}");
704                        }
705                    }
706                    Ok(output)
707                })
708            }
709            // add/remove require mutating self after async I/O.
710            // handle_mcp_command is structured so the only .await crossing a &mut self
711            // boundary goes through a cloned Arc<McpManager> — no &self fields are held
712            // across that .await.  The subsequent state-change methods (rebuild_semantic_index,
713            // sync_mcp_registry) are also async fn(&mut self), but they only hold owned locals
714            // across their own .await points (cloned tools Vec, cloned Arcs).
715            _ => Box::pin(async move {
716                self.handle_mcp_command(&args_owned)
717                    .await
718                    .map_err(|e| CommandError::new(e.to_string()))
719            }),
720        }
721    }
722
723    // ----- /skill -----
724
725    fn handle_skill<'a>(
726        &'a mut self,
727        args: &'a str,
728    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
729        let args_owned = args.to_owned();
730        Box::pin(async move {
731            self.handle_skill_command_as_string(&args_owned)
732                .await
733                .map_err(|e| CommandError::new(e.to_string()))
734        })
735    }
736
737    // ----- /skills -----
738
739    fn handle_skills<'a>(
740        &'a mut self,
741        args: &'a str,
742    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
743        let args_owned = args.to_owned();
744        Box::pin(async move {
745            self.handle_skills_as_string(&args_owned)
746                .await
747                .map_err(|e| CommandError::new(e.to_string()))
748        })
749    }
750
751    // ----- /feedback -----
752
753    fn handle_feedback_command<'a>(
754        &'a mut self,
755        args: &'a str,
756    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
757        let args_owned = args.to_owned();
758        Box::pin(async move {
759            self.handle_feedback_as_string(&args_owned)
760                .await
761                .map_err(|e| CommandError::new(e.to_string()))
762        })
763    }
764
765    // ----- /plan -----
766
767    #[cfg(feature = "scheduler")]
768    fn handle_plan<'a>(
769        &'a mut self,
770        input: &'a str,
771    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
772        Box::pin(async move {
773            self.dispatch_plan_command_as_string(input)
774                .await
775                .map_err(|e| CommandError::new(e.to_string()))
776        })
777    }
778
779    #[cfg(not(feature = "scheduler"))]
780    fn handle_plan<'a>(
781        &'a mut self,
782        _input: &'a str,
783    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
784        Box::pin(async move { Ok(String::new()) })
785    }
786
787    // ----- /experiment -----
788
789    fn handle_experiment<'a>(
790        &'a mut self,
791        input: &'a str,
792    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
793        Box::pin(async move {
794            self.handle_experiment_command_as_string(input)
795                .await
796                .map_err(|e| CommandError::new(e.to_string()))
797        })
798    }
799
800    // ----- /agent, @mention -----
801
802    fn handle_agent_dispatch<'a>(
803        &'a mut self,
804        input: &'a str,
805    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
806        Box::pin(async move {
807            match self.dispatch_agent_command(input).await {
808                Some(Err(e)) => Err(CommandError::new(e.to_string())),
809                Some(Ok(())) | None => Ok(None),
810            }
811        })
812    }
813}
814
815/// Convert `AgentError` to `CommandError` for the trait boundary.
816impl From<AgentError> for CommandError {
817    fn from(e: AgentError) -> Self {
818        Self(e.to_string())
819    }
820}