1use std::fmt::Write as _;
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::Duration;
17
18use tracing::Instrument as _;
19use zeph_commands::CommandError;
20use zeph_commands::traits::agent::AgentAccess;
21use zeph_db;
22use zeph_llm::provider::LlmProvider as _;
23use zeph_memory::semantic::SemanticMemory;
24use zeph_memory::{Edge, Entity, GraphExtractionConfig, GraphStore, MessageId, extract_and_store};
25
26use super::{Agent, error::AgentError};
27use crate::channel::Channel;
28
29impl<C: Channel + Send + 'static> Agent<C> {
30 fn resolve_graph_store(&self) -> Result<(Arc<SemanticMemory>, Arc<GraphStore>), String> {
31 let Some(memory) = self.services.memory.persistence.memory.clone() else {
32 return Err("Graph memory is not enabled.".to_owned());
33 };
34 let Some(store) = memory.graph_store.clone() else {
35 if self.services.memory.extraction.graph_config.enabled {
36 return Err(
37 "Graph memory enabled but vector store unavailable (Qdrant unreachable)."
38 .to_owned(),
39 );
40 }
41 return Err("Graph memory is not enabled.".to_owned());
42 };
43 Ok((memory, store))
44 }
45}
46
47enum EntityLookup {
51 Found(Entity),
52 Message(String),
53}
54
55enum StoreCallOutcome<T> {
58 Completed(T),
59 TimedOut,
60}
61
62async fn with_graph_store_timeout<T>(
67 fut: impl Future<Output = Result<T, zeph_memory::MemoryError>>,
68) -> Result<StoreCallOutcome<T>, CommandError> {
69 match tokio::time::timeout(Duration::from_secs(5), fut).await {
70 Ok(Ok(v)) => Ok(StoreCallOutcome::Completed(v)),
71 Ok(Err(e)) => Err(CommandError::new(e.to_string())),
72 Err(_) => {
73 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
74 Ok(StoreCallOutcome::TimedOut)
75 }
76 }
77}
78
79async fn resolve_entity_by_name(
82 store: &GraphStore,
83 name: &str,
84) -> Result<EntityLookup, CommandError> {
85 let matches = match with_graph_store_timeout(store.find_entity_by_name(name)).await? {
86 StoreCallOutcome::Completed(v) => v,
87 StoreCallOutcome::TimedOut => {
88 return Ok(EntityLookup::Message(
89 "Graph store unavailable (Qdrant unreachable).".to_owned(),
90 ));
91 }
92 };
93 let Some(entity) = matches.into_iter().next() else {
94 return Ok(EntityLookup::Message(format!(
95 "No entity found matching '{name}'."
96 )));
97 };
98 Ok(EntityLookup::Found(entity))
99}
100
101async fn build_entity_name_map(
106 store: &GraphStore,
107 entity: &Entity,
108 edges: &[Edge],
109) -> std::collections::HashMap<i64, String> {
110 let mut entity_names: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
111 entity_names.insert(entity.id.0, entity.name.clone());
112 for edge in edges {
113 entity_names.entry(edge.source_entity_id).or_default();
114 entity_names.entry(edge.target_entity_id).or_default();
115 }
116 for (&id, name_val) in &mut entity_names {
117 if name_val.is_empty() {
118 let result =
119 tokio::time::timeout(Duration::from_secs(5), store.find_entity_by_id(id)).await;
120 if let Ok(Ok(Some(other))) = result {
121 *name_val = other.name;
122 } else {
123 *name_val = format!("#{id}");
124 }
125 }
126 }
127 entity_names
128}
129
130async fn semantic_scan_plugin_add(
142 scanner: &zeph_skills::semantic_scanner::SkillSemanticScanner,
143 source: &str,
144 managed_dir: Option<std::path::PathBuf>,
145 mcp_allowed: Vec<String>,
146 base_shell_allowed: Vec<String>,
147) -> Result<Option<String>, CommandError> {
148 use futures::stream::StreamExt as _;
149 use zeph_skills::semantic_scanner::ScanVerdict;
150
151 let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
152 let mgr_dir =
153 managed_dir.unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
154 let mgr =
155 zeph_plugins::PluginManager::new(plugins_dir, mgr_dir, mcp_allowed, base_shell_allowed);
156
157 let source_owned = source.to_owned();
158 let scan_inputs = tokio::task::spawn_blocking(move || mgr.scan_targets(&source_owned))
159 .await
160 .map_err(|e| CommandError(format!("plugin scan_targets panicked: {e}")))?
161 .map_err(|e| CommandError(format!("plugin add failed: {e}")))?;
162
163 tracing::info!(
164 plugin.source = %source,
165 skills_count = scan_inputs.len(),
166 "plugins.add: running Stage-2 semantic scan"
167 );
168
169 let scan_futs: Vec<_> = scan_inputs
174 .iter()
175 .map(|input| {
176 let name = input.skill_name.clone();
177 let purpose = input.declared_purpose.clone();
178 let md = input.skill_md.clone();
179 async move {
180 let verdict = scanner.scan(&name, &purpose, &md).await;
181 (name, verdict)
182 }
183 })
184 .collect();
185
186 let verdicts: Vec<_> = tokio::time::timeout(
187 std::time::Duration::from_mins(5),
188 futures::stream::iter(scan_futs)
189 .buffer_unordered(4)
190 .collect::<Vec<_>>(),
191 )
192 .await
193 .map_err(|_| CommandError("plugin scan timed out after 300s".to_owned()))?;
194
195 for (skill_name, verdict_result) in verdicts {
196 let verdict = verdict_result.map_err(|e| {
197 CommandError(format!(
198 "plugin add failed: semantic scan error for skill {skill_name:?}: {e}"
199 ))
200 })?;
201 match verdict {
202 ScanVerdict::Allow => {
203 tracing::debug!(
204 skill = %skill_name,
205 "plugins.add: skill passed semantic scan"
206 );
207 }
208 ScanVerdict::Warn(ref reason) => {
209 tracing::warn!(
210 skill = %skill_name,
211 reason = %reason,
212 "plugins.add: skill passed with warning"
213 );
214 }
215 ScanVerdict::Block(reason) => {
216 return Ok(Some(format!(
217 "plugin add failed: skill {skill_name:?} rejected by semantic scan: {reason}"
218 )));
219 }
220 _ => {}
221 }
222 }
223 Ok(None)
224}
225
226macro_rules! delegate_cmd {
231 ($name:ident, $inner:ident $(, $arg:ident : $arg_ty:ty)? => $out:ty) => {
232 fn $name<'a>(
233 &'a mut self,
234 $($arg: $arg_ty)?
235 ) -> Pin<Box<dyn Future<Output = Result<$out, CommandError>> + Send + 'a>> {
236 Box::pin(async move {
237 self.$inner($($arg)?)
238 .await
239 .map_err(|e| CommandError::new(e.to_string()))
240 })
241 }
242 };
243}
244
245impl<C: Channel + Send + 'static> AgentAccess for Agent<C> {
246 fn memory_tiers<'a>(
249 &'a mut self,
250 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
251 Box::pin(
252 async move {
253 let Some(memory) = self.services.memory.persistence.memory.clone() else {
254 return Ok("Memory not configured.".to_owned());
255 };
256 match memory.sqlite().count_messages_by_tier().await {
257 Ok((episodic, semantic)) => {
258 let mut out = String::new();
259 let _ = writeln!(out, "Memory tiers:");
260 let _ = writeln!(out, " Working: (current context window — virtual)");
261 let _ = writeln!(out, " Episodic: {episodic} messages");
262 let _ = writeln!(out, " Semantic: {semantic} facts");
263 Ok(out.trim_end().to_owned())
264 }
265 Err(e) => Ok(format!("Failed to query tier stats: {e}")),
266 }
267 }
268 .instrument(tracing::info_span!("core.agent_access.memory_tiers")),
269 )
270 }
271
272 fn memory_promote<'a>(
273 &'a mut self,
274 ids_str: &'a str,
275 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
276 Box::pin(
277 async move {
278 let Some(memory) = self.services.memory.persistence.memory.clone() else {
279 return Ok("Memory not configured.".to_owned());
280 };
281 let ids: Vec<MessageId> = ids_str
282 .split_whitespace()
283 .filter_map(|s| s.parse::<i64>().ok().map(MessageId))
284 .collect();
285 if ids.is_empty() {
286 return Ok(
287 "Usage: /memory promote <id> [id...]\nExample: /memory promote 42 43 44"
288 .to_owned(),
289 );
290 }
291 match memory.sqlite().manual_promote(&ids).await {
292 Ok(count) => Ok(format!("Promoted {count} message(s) to semantic tier.")),
293 Err(e) => Ok(format!("Promotion failed: {e}")),
294 }
295 }
296 .instrument(tracing::info_span!("core.agent_access.memory_promote")),
297 )
298 }
299
300 fn graph_stats<'a>(
303 &'a mut self,
304 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
305 Box::pin(
306 async move {
307 let (_, store) = match self.resolve_graph_store() {
308 Ok(pair) => pair,
309 Err(msg) => return Ok(msg),
310 };
311
312 let stats_future = async {
313 tokio::join!(
314 store.entity_count(),
315 store.active_edge_count(),
316 store.community_count(),
317 store.edge_type_distribution()
318 )
319 };
320 let Ok((entities, edges, communities, distribution)) =
321 tokio::time::timeout(Duration::from_secs(5), stats_future).await
322 else {
323 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
324 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
325 };
326 let mut msg = format!(
327 "Graph memory: {} entities, {} edges, {} communities",
328 entities.unwrap_or(0),
329 edges.unwrap_or(0),
330 communities.unwrap_or(0)
331 );
332 if let Ok(dist) = distribution
333 && !dist.is_empty()
334 {
335 let dist_str: Vec<String> =
336 dist.iter().map(|(t, c)| format!("{t}={c}")).collect();
337 write!(msg, "\nEdge types: {}", dist_str.join(", ")).unwrap_or(());
338 }
339 Ok(msg)
340 }
341 .instrument(tracing::info_span!("core.agent_access.graph_stats")),
342 )
343 }
344
345 fn graph_entities<'a>(
346 &'a mut self,
347 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
348 Box::pin(
349 async move {
350 let (_, store) = match self.resolve_graph_store() {
351 Ok(pair) => pair,
352 Err(msg) => return Ok(msg),
353 };
354
355 let entities = match tokio::time::timeout(
356 Duration::from_secs(5),
357 store.all_entities(),
358 )
359 .await
360 {
361 Ok(Ok(v)) => v,
362 Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
363 Err(_) => {
364 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
365 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
366 }
367 };
368 if entities.is_empty() {
369 return Ok("No entities found.".to_owned());
370 }
371
372 let total = entities.len();
373 let display: Vec<String> = entities
374 .iter()
375 .take(50)
376 .map(|e| {
377 format!(
378 " {:<40} {:<15} {}",
379 e.name,
380 e.entity_type.as_str(),
381 e.last_seen_at.split('T').next().unwrap_or(&e.last_seen_at)
382 )
383 })
384 .collect();
385 let mut msg = format!(
386 "Entities ({total} total):\n {:<40} {:<15} {}\n{}",
387 "NAME",
388 "TYPE",
389 "LAST SEEN",
390 display.join("\n")
391 );
392 if total > 50 {
393 write!(msg, "\n ...and {} more", total - 50).unwrap_or(());
394 }
395 Ok(msg)
396 }
397 .instrument(tracing::info_span!("core.agent_access.graph_entities")),
398 )
399 }
400
401 fn graph_facts<'a>(
402 &'a mut self,
403 name: &'a str,
404 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
405 Box::pin(
406 async move {
407 let (_, store) = match self.resolve_graph_store() {
408 Ok(pair) => pair,
409 Err(msg) => return Ok(msg),
410 };
411
412 let entity = match resolve_entity_by_name(&store, name).await? {
413 EntityLookup::Found(e) => e,
414 EntityLookup::Message(msg) => return Ok(msg),
415 };
416
417 let edges =
418 match with_graph_store_timeout(store.edges_for_entity(entity.id.0)).await? {
419 StoreCallOutcome::Completed(v) => v,
420 StoreCallOutcome::TimedOut => {
421 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
422 }
423 };
424 if edges.is_empty() {
425 return Ok(format!("Entity '{}' has no known facts.", entity.name));
426 }
427
428 let entity_names = build_entity_name_map(&store, &entity, &edges).await;
429
430 let lines: Vec<String> = edges
431 .iter()
432 .map(|e| {
433 let src = entity_names
434 .get(&e.source_entity_id)
435 .cloned()
436 .unwrap_or_else(|| format!("#{}", e.source_entity_id));
437 let tgt = entity_names
438 .get(&e.target_entity_id)
439 .cloned()
440 .unwrap_or_else(|| format!("#{}", e.target_entity_id));
441 format!(
442 " {} --[{}/{}]--> {}: {} (confidence: {:.2})",
443 src, e.relation, e.edge_type, tgt, e.fact, e.confidence
444 )
445 })
446 .collect();
447 Ok(format!(
448 "Facts for '{}':\n{}",
449 entity.name,
450 lines.join("\n")
451 ))
452 }
453 .instrument(tracing::info_span!("core.agent_access.graph_facts")),
454 )
455 }
456
457 fn graph_history<'a>(
458 &'a mut self,
459 name: &'a str,
460 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
461 Box::pin(
462 async move {
463 let (_, store) = match self.resolve_graph_store() {
464 Ok(pair) => pair,
465 Err(msg) => return Ok(msg),
466 };
467
468 let entity = match resolve_entity_by_name(&store, name).await? {
469 EntityLookup::Found(e) => e,
470 EntityLookup::Message(msg) => return Ok(msg),
471 };
472
473 let edges =
474 match with_graph_store_timeout(store.edge_history_for_entity(entity.id.0, 50))
475 .await?
476 {
477 StoreCallOutcome::Completed(v) => v,
478 StoreCallOutcome::TimedOut => {
479 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
480 }
481 };
482 if edges.is_empty() {
483 return Ok(format!("Entity '{}' has no edge history.", entity.name));
484 }
485
486 let entity_names = build_entity_name_map(&store, &entity, &edges).await;
487
488 let n = edges.len();
489 let lines: Vec<String> = edges
490 .iter()
491 .map(|e| {
492 let status = if e.valid_to.is_some() {
493 let date = e
494 .valid_to
495 .as_deref()
496 .and_then(|s| s.split('T').next().or_else(|| s.split(' ').next()))
497 .unwrap_or("?");
498 format!("[expired {date}]")
499 } else {
500 "[active]".to_string()
501 };
502 let src = entity_names
503 .get(&e.source_entity_id)
504 .cloned()
505 .unwrap_or_else(|| format!("#{}", e.source_entity_id));
506 let tgt = entity_names
507 .get(&e.target_entity_id)
508 .cloned()
509 .unwrap_or_else(|| format!("#{}", e.target_entity_id));
510 format!(
511 " {status} {} --[{}/{}]--> {}: {} (confidence: {:.2})",
512 src, e.relation, e.edge_type, tgt, e.fact, e.confidence
513 )
514 })
515 .collect();
516 Ok(format!(
517 "Edge history for '{}' ({n} edges):\n{}",
518 entity.name,
519 lines.join("\n")
520 ))
521 }
522 .instrument(tracing::info_span!("core.agent_access.graph_history")),
523 )
524 }
525
526 fn graph_communities<'a>(
527 &'a mut self,
528 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
529 Box::pin(
530 async move {
531 let (_, store) = match self.resolve_graph_store() {
532 Ok(pair) => pair,
533 Err(msg) => return Ok(msg),
534 };
535
536 let communities =
537 match tokio::time::timeout(Duration::from_secs(5), store.all_communities())
538 .await
539 {
540 Ok(Ok(v)) => v,
541 Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
542 Err(_) => {
543 tracing::warn!(
544 "graph store call timed out after 5s (Qdrant unreachable)"
545 );
546 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
547 }
548 };
549 if communities.is_empty() {
550 return Ok("No communities detected yet. Run graph backfill first.".to_owned());
551 }
552
553 let lines: Vec<String> = communities
554 .iter()
555 .map(|c| format!(" [{}]: {}", c.name, c.summary))
556 .collect();
557 Ok(format!(
558 "Communities ({}):\n{}",
559 communities.len(),
560 lines.join("\n")
561 ))
562 }
563 .instrument(tracing::info_span!("core.agent_access.graph_communities")),
564 )
565 }
566
567 #[allow(clippy::too_many_lines)]
568 fn graph_backfill<'a>(
569 &'a mut self,
570 limit: Option<usize>,
571 progress_cb: &'a mut (dyn FnMut(String) + Send),
572 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
573 let store = match self.resolve_graph_store() {
574 Ok((_, s)) => s,
575 Err(msg) => return Box::pin(async move { Ok(msg) }),
576 };
577 let graph_cfg = self.services.memory.extraction.graph_config.clone();
578 let embed_timeout_secs = self
579 .services
580 .memory
581 .persistence
582 .memory
583 .as_ref()
584 .map_or(5, |m| m.embed_timeout().as_secs());
585 let provider = if graph_cfg.extract_provider.as_str().is_empty() {
586 self.provider.clone()
587 } else {
588 self.resolve_background_provider(graph_cfg.extract_provider.as_str())
589 };
590 Box::pin(
591 async move {
592 let total = store.unprocessed_message_count().await.unwrap_or(0);
593 let cap = limit.unwrap_or(usize::MAX);
594
595 progress_cb(format!(
596 "Starting graph backfill... ({total} unprocessed messages)"
597 ));
598
599 let batch_size = 50usize;
600 let mut processed = 0usize;
601 let mut total_entities = 0usize;
602 let mut total_edges = 0usize;
603
604 loop {
605 let remaining_cap = cap.saturating_sub(processed);
606 if remaining_cap == 0 {
607 break;
608 }
609 let batch_limit = batch_size.min(remaining_cap);
610 let messages = store
611 .unprocessed_messages_for_backfill(batch_limit)
612 .await
613 .map_err(|e| CommandError::new(e.to_string()))?;
614 if messages.is_empty() {
615 break;
616 }
617
618 let ids: Vec<zeph_memory::types::MessageId> =
619 messages.iter().map(|(id, _)| *id).collect();
620
621 let extraction_cfg = GraphExtractionConfig {
625 max_entities: graph_cfg.max_entities_per_message,
626 max_edges: graph_cfg.max_edges_per_message,
627 extraction_timeout_secs: graph_cfg.extraction_timeout_secs,
628 community_refresh_interval: 0,
629 expired_edge_retention_days: graph_cfg.expired_edge_retention_days,
630 max_entities_cap: graph_cfg.max_entities,
631 community_summary_max_prompt_bytes: graph_cfg
632 .community_summary_max_prompt_bytes,
633 community_summary_concurrency: graph_cfg.community_summary_concurrency,
634 lpa_edge_chunk_size: graph_cfg.lpa_edge_chunk_size,
635 note_linking: zeph_memory::NoteLinkingConfig::default(),
636 link_weight_decay_lambda: graph_cfg.link_weight_decay_lambda,
637 link_weight_decay_interval_secs: graph_cfg.link_weight_decay_interval_secs,
638 belief_revision_enabled: graph_cfg.belief_revision.enabled,
639 belief_revision_similarity_threshold: graph_cfg
640 .belief_revision
641 .similarity_threshold,
642 conversation_id: None,
643 apex_mem_enabled: graph_cfg.apex_mem.enabled,
644 llm_timeout_secs: graph_cfg.llm_timeout_secs,
645 embed_timeout_secs,
646 turn_index: None,
647 write_gate_min_relevance: graph_cfg
648 .write_gate
649 .enabled
650 .then_some(graph_cfg.write_gate.min_edge_relevance),
651 benna_fast_rate: graph_cfg.spreading_activation.benna_fast_rate,
652 benna_slow_rate: graph_cfg.spreading_activation.benna_slow_rate,
653 provenance: None,
654 system_prompt: None,
655 recall_include_imported: graph_cfg.recall_include_imported,
656 };
657
658 {
668 use futures::stream::StreamExt as _;
669
670 let extraction_futs: Vec<_> = messages
671 .iter()
672 .filter_map(|(_id, content)| {
673 if content.trim().is_empty() {
674 return None;
675 }
676 let content = content.clone();
677 let provider = provider.clone();
678 let pool = store.pool().clone();
679 let extraction_cfg = extraction_cfg.clone();
680 Some(extract_and_store(
681 content,
682 vec![],
683 provider,
684 pool,
685 extraction_cfg,
686 None,
687 None,
688 ))
689 })
690 .collect();
691
692 let results: Vec<_> = futures::stream::iter(extraction_futs)
693 .buffer_unordered(4)
694 .collect()
695 .await;
696
697 for result in results {
698 match result {
699 Ok(result) => {
700 total_entities += result.stats.entities_upserted;
701 total_edges += result.stats.edges_inserted;
702 }
703 Err(e) => {
704 tracing::warn!("backfill extraction error: {e:#}");
705 }
706 }
707 }
708 }
709
710 store
711 .mark_messages_graph_processed(&ids)
712 .await
713 .map_err(|e| CommandError::new(e.to_string()))?;
714 processed += messages.len();
715
716 progress_cb(format!(
717 "Backfill progress: {processed} messages processed, \
718 {total_entities} entities, {total_edges} edges"
719 ));
720 }
721
722 Ok(format!(
723 "Backfill complete: {total_entities} entities, {total_edges} edges \
724 extracted from {processed} messages"
725 ))
726 }
727 .instrument(tracing::info_span!("core.agent_access.graph_backfill")),
728 )
729 }
730
731 fn knowledge_status<'a>(
734 &'a mut self,
735 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
736 Box::pin(
737 async move {
738 use zeph_memory::graph::ingest::IngestLedger;
739
740 let Some(memory) = self.services.memory.persistence.memory.clone() else {
741 return Ok("Memory subsystem not available.".to_owned());
742 };
743 let pool = memory.sqlite().pool().clone();
744 let ledger = IngestLedger::new(pool);
745
746 let rows = match ledger.summary().await {
747 Ok(r) => r,
748 Err(e) => return Err(CommandError(e.to_string())),
749 };
750
751 if rows.is_empty() {
752 return Ok("No knowledge has been ingested yet. \
753 Run `zeph knowledge ingest --source <src>`."
754 .to_owned());
755 }
756
757 let mut out = format!("Knowledge ingest ledger ({} entries):\n\n", rows.len());
758 let mut current_batch = String::new();
759 for row in &rows {
760 let batch_short = &row.import_batch_id[..row.import_batch_id.len().min(8)];
761 if current_batch != row.import_batch_id {
762 if !current_batch.is_empty() {
763 out.push('\n');
764 }
765 current_batch.clone_from(&row.import_batch_id);
766 }
767 let uri_display = &row.source_uri[..row.source_uri.floor_char_boundary(40)];
768 let at_display = &row.ingested_at[..row.ingested_at.len().min(19)];
769 let _ = writeln!(
770 out,
771 " {uri_display:<40} batch={batch_short} at={at_display} \
772 e={} edges={}",
773 row.entities, row.edges,
774 );
775 }
776 Ok(out.trim_end().to_owned())
777 }
778 .instrument(tracing::info_span!("core.agent_access.knowledge_status")),
779 )
780 }
781
782 fn knowledge_rollback<'a>(
783 &'a mut self,
784 batch_id: &'a str,
785 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
786 Box::pin(
787 async move {
788 use zeph_memory::graph::ingest::IngestLedger;
789
790 let Some(memory) = self.services.memory.persistence.memory.clone() else {
791 return Ok("Memory subsystem not available.".to_owned());
792 };
793 let pool = memory.sqlite().pool().clone();
794 let ledger = IngestLedger::new(pool.clone());
795
796 match ledger.batch_exists(batch_id).await {
797 Ok(false) => {
798 return Ok(format!("Batch '{batch_id}' not found in ledger."));
799 }
800 Err(e) => return Err(CommandError(e.to_string())),
801 Ok(true) => {}
802 }
803
804 let Some(graph_store) = memory.graph_store.clone() else {
805 return Ok(
806 "Graph store unavailable (Qdrant unreachable or graph not enabled)."
807 .to_owned(),
808 );
809 };
810
811 let mut tx = zeph_db::begin_write(&pool)
812 .await
813 .map_err(|e| CommandError(e.to_string()))?;
814
815 let (edges, entities) = graph_store
816 .delete_batch_in_tx(batch_id, &mut tx)
817 .await
818 .map_err(|e| CommandError(e.to_string()))?;
819 ledger
820 .delete_batch_in_tx(batch_id, &mut tx)
821 .await
822 .map_err(|e| CommandError(e.to_string()))?;
823
824 tx.commit().await.map_err(|e| CommandError(e.to_string()))?;
825
826 let mut msg = format!(
827 "Rolled back batch '{batch_id}': removed {edges} edge(s) and \
828 {entities} entity(ies)."
829 );
830 if edges == 0 && entities == 0 {
831 msg.push_str(
832 "\nNote: no graph rows found. Phase-1 ingest writes to Qdrant notes — \
833 Qdrant embeddings are NOT removed by this rollback.",
834 );
835 }
836 Ok(msg)
837 }
838 .instrument(tracing::info_span!("core.agent_access.knowledge_rollback")),
839 )
840 }
841
842 fn store_command<'a>(
845 &'a mut self,
846 args: &'a str,
847 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
848 Box::pin(
849 async move {
850 const USAGE: &str = "Usage: /store {get <ns> <key> | put <ns> <key> <value...> \
851 | list <ns_prefix> [limit] | delete <ns> <key>}";
852
853 let store_config = self.services.memory.persistence.store_config.clone();
854 if !store_config.enabled {
855 return Ok(
856 "Cross-thread store is disabled ([memory.store].enabled = false)."
857 .to_owned(),
858 );
859 }
860 let Some(memory) = self.services.memory.persistence.memory.clone() else {
861 return Ok("Memory not configured.".to_owned());
862 };
863
864 let owner_key = self.services.session.owner_key.as_str();
865 let mut parts = args.split_whitespace();
866 let Some(sub) = parts.next() else {
867 return Ok(USAGE.to_owned());
868 };
869
870 let result = match sub {
871 "get" => {
872 let (Some(ns), Some(key)) = (parts.next(), parts.next()) else {
873 return Ok("Usage: /store get <namespace> <key>".to_owned());
874 };
875 match memory.sqlite().store_get(owner_key, ns, key).await {
876 Ok(Some(item)) => item.value,
877 Ok(None) => format!("No value found for {ns}/{key}."),
878 Err(e) => return Err(CommandError::new(e.to_string())),
879 }
880 }
881 "put" => {
882 let (Some(ns), Some(key)) = (parts.next(), parts.next()) else {
883 return Ok("Usage: /store put <namespace> <key> <value...>".to_owned());
884 };
885 let value = parts.collect::<Vec<_>>().join(" ");
886 if value.is_empty() {
887 return Ok("Usage: /store put <namespace> <key> <value...>".to_owned());
888 }
889 match memory
890 .sqlite()
891 .store_put(
892 owner_key,
893 ns,
894 key,
895 &value,
896 store_config.max_value_bytes,
897 None,
898 )
899 .await
900 {
901 Ok(item) => format!("Stored {ns}/{key} (version {}).", item.version),
902 Err(e) => return Err(CommandError::new(e.to_string())),
903 }
904 }
905 "list" => {
906 let prefix = parts.next().unwrap_or("");
907 let limit = parts
908 .next()
909 .and_then(|s| s.parse::<usize>().ok())
910 .unwrap_or(0);
911 match memory.sqlite().store_list(owner_key, prefix, limit).await {
912 Ok(items) if items.is_empty() => "No rows found.".to_owned(),
913 Ok(items) => items
914 .iter()
915 .map(|i| {
916 format!(
917 "{}/{} = {} (v{})",
918 i.namespace, i.key, i.value, i.version
919 )
920 })
921 .collect::<Vec<_>>()
922 .join("\n"),
923 Err(e) => return Err(CommandError::new(e.to_string())),
924 }
925 }
926 "delete" => {
927 let (Some(ns), Some(key)) = (parts.next(), parts.next()) else {
928 return Ok("Usage: /store delete <namespace> <key>".to_owned());
929 };
930 match memory.sqlite().store_delete(owner_key, ns, key).await {
931 Ok(true) => format!("Deleted {ns}/{key}."),
932 Ok(false) => format!("No value found for {ns}/{key}."),
933 Err(e) => return Err(CommandError::new(e.to_string())),
934 }
935 }
936 _ => USAGE.to_owned(),
937 };
938 Ok(result)
939 }
940 .instrument(tracing::info_span!("core.agent_access.store_command")),
941 )
942 }
943
944 fn guidelines<'a>(
947 &'a mut self,
948 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
949 Box::pin(
950 async move {
951 const MAX_DISPLAY_CHARS: usize = 4096;
952
953 let Some(memory) = &self.services.memory.persistence.memory else {
954 return Ok("No memory backend initialised.".to_owned());
955 };
956
957 let cid = self.services.memory.persistence.conversation_id;
958 let sqlite = memory.sqlite();
959
960 let (version, text) = sqlite
961 .load_compression_guidelines(cid)
962 .await
963 .map_err(|e: zeph_memory::MemoryError| CommandError::new(e.to_string()))?;
964
965 if version == 0 || text.is_empty() {
966 return Ok("No compression guidelines generated yet.".to_owned());
967 }
968
969 let (_, created_at) = sqlite
970 .load_compression_guidelines_meta(cid)
971 .await
972 .unwrap_or((0, String::new()));
973
974 let (body, truncated) = if text.len() > MAX_DISPLAY_CHARS {
975 let end = text.floor_char_boundary(MAX_DISPLAY_CHARS);
976 (&text[..end], true)
977 } else {
978 (text.as_str(), false)
979 };
980
981 let mut output =
982 format!("Compression Guidelines (v{version}, updated {created_at}):\n\n{body}");
983 if truncated {
984 output.push_str("\n\n[truncated]");
985 }
986 Ok(output)
987 }
988 .instrument(tracing::info_span!("core.agent_access.guidelines")),
989 )
990 }
991
992 fn handle_caveman<'a>(
995 &'a mut self,
996 arg: &'a str,
997 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
998 Box::pin(async move {
999 let active = &mut self.services.session.caveman_active;
1000 match arg.trim() {
1001 "on" | "enable" => {
1002 *active = true;
1003 "caveman: on".to_owned()
1004 }
1005 "off" | "disable" => {
1006 *active = false;
1007 "caveman: off".to_owned()
1008 }
1009 "status" => {
1010 if *active {
1011 "caveman: on".to_owned()
1012 } else {
1013 "caveman: off".to_owned()
1014 }
1015 }
1016 _ => {
1017 *active = !*active;
1018 if *active {
1019 "caveman: on".to_owned()
1020 } else {
1021 "caveman: off".to_owned()
1022 }
1023 }
1024 }
1025 })
1026 }
1027
1028 fn handle_model<'a>(
1031 &'a mut self,
1032 arg: &'a str,
1033 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
1034 Box::pin(async move {
1035 let input = if arg.is_empty() {
1036 "/model".to_owned()
1037 } else {
1038 format!("/model {arg}")
1039 };
1040 self.handle_model_command_as_string(&input).await
1041 })
1042 }
1043
1044 fn handle_provider<'a>(
1045 &'a mut self,
1046 arg: &'a str,
1047 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
1048 Box::pin(async move { self.handle_provider_command_as_string(arg).await })
1049 }
1050
1051 fn handle_think_tokens<'a>(
1054 &'a mut self,
1055 arg: &'a str,
1056 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
1057 Box::pin(async move {
1058 let arg = arg.trim();
1059 let provider_name = self.provider.name().to_owned();
1060 if arg.is_empty() {
1061 return match self.provider.current_thinking_budget() {
1062 Some(n) => format!("think-tokens: {n} (provider: {provider_name})"),
1063 None => format!("think-tokens: off (provider: {provider_name})"),
1064 };
1065 }
1066
1067 let budget = match zeph_commands::handlers::think_tokens::parse_token_budget(arg) {
1068 Ok(b) => b,
1069 Err(e) => return format!("think-tokens: {e}"),
1070 };
1071
1072 let had_reasoning_effort = self.provider.current_reasoning_effort().is_some();
1076 match self.provider.set_thinking_budget(budget) {
1077 Ok(()) => {
1078 let mut msg = match budget {
1079 Some(n) => format!("think-tokens: set to {n} (provider: {provider_name})"),
1080 None => format!("think-tokens: disabled (provider: {provider_name})"),
1081 };
1082 if had_reasoning_effort && self.provider.current_reasoning_effort().is_none() {
1083 msg.push_str(
1084 " Note: this overrides the previously set reasoning-effort level \
1085 — Claude's Extended and Adaptive thinking share one config field.",
1086 );
1087 }
1088 if let Some(advisory) = self.provider.capability_delegation_advisory() {
1089 let _ = write!(msg, " Note: {advisory}.");
1090 }
1091 msg
1092 }
1093 Err(zeph_llm::LlmError::ModelCapabilityMismatch { provider, message }) => {
1094 format!("provider `{provider}` {message}")
1095 }
1096 Err(e) => format!("think-tokens: {e}"),
1097 }
1098 })
1099 }
1100
1101 fn handle_reasoning_effort<'a>(
1102 &'a mut self,
1103 arg: &'a str,
1104 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
1105 Box::pin(async move {
1106 let arg = arg.trim();
1107 let provider_name = self.provider.name().to_owned();
1108 if arg.is_empty() {
1109 return match self.provider.current_reasoning_effort() {
1110 Some(e) => format!("reasoning-effort: {e} (provider: {provider_name})"),
1111 None => format!("reasoning-effort: off (provider: {provider_name})"),
1112 };
1113 }
1114
1115 let effort: zeph_llm::any::ReasoningEffort = match arg.parse() {
1116 Ok(e) => e,
1117 Err(e) => return format!("reasoning-effort: {e}"),
1118 };
1119
1120 let had_thinking_budget = self.provider.current_thinking_budget().is_some();
1122 match self.provider.apply_reasoning_effort(effort) {
1123 Ok(()) => {
1124 let mut msg = format!(
1125 "reasoning-effort: set to {} (provider: {provider_name})",
1126 effort.as_str()
1127 );
1128 if had_thinking_budget && self.provider.current_thinking_budget().is_none() {
1129 msg.push_str(
1130 " Note: this overrides the previously set thinking-token budget \
1131 — Claude's Extended and Adaptive thinking share one config field.",
1132 );
1133 }
1134 if let Some(advisory) = self.provider.capability_delegation_advisory() {
1135 let _ = write!(msg, " Note: {advisory}.");
1136 }
1137 msg
1138 }
1139 Err(zeph_llm::LlmError::ModelCapabilityMismatch { provider, message }) => {
1140 format!("provider `{provider}` {message}")
1141 }
1142 Err(e) => format!("reasoning-effort: {e}"),
1143 }
1144 })
1145 }
1146
1147 fn handle_policy<'a>(
1150 &'a mut self,
1151 args: &'a str,
1152 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1153 Box::pin(async move { Ok(self.handle_policy_command_as_string(args)) })
1154 }
1155
1156 #[cfg(feature = "scheduler")]
1159 fn list_scheduled_tasks<'a>(
1160 &'a mut self,
1161 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1162 Box::pin(async move {
1163 let result = self
1164 .handle_scheduler_list_as_string()
1165 .await
1166 .map_err(|e| CommandError::new(e.to_string()))?;
1167 Ok(Some(result))
1168 })
1169 }
1170
1171 #[cfg(not(feature = "scheduler"))]
1172 fn list_scheduled_tasks<'a>(
1173 &'a mut self,
1174 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1175 Box::pin(async move { Ok(None) })
1176 }
1177
1178 delegate_cmd!(lsp_status, handle_lsp_status_as_string => String);
1181
1182 fn session_recap<'a>(
1185 &'a mut self,
1186 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1187 Box::pin(
1188 async move {
1189 match self.build_recap().await {
1190 Ok(text) => Ok(text),
1191 Err(e) => {
1192 tracing::warn!("session recap command: {}", e.0);
1196 Ok("Recap unavailable — see logs for details".to_string())
1197 }
1198 }
1199 }
1200 .instrument(tracing::info_span!("core.agent_access.session_recap")),
1201 )
1202 }
1203
1204 fn compact_context<'a>(
1207 &'a mut self,
1208 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1209 Box::pin(
1210 self.compact_context_command()
1211 .instrument(tracing::info_span!("core.agent_access.compact_context")),
1212 )
1213 }
1214
1215 fn reset_conversation<'a>(
1218 &'a mut self,
1219 keep_plan: bool,
1220 no_digest: bool,
1221 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1222 Box::pin(async move {
1223 match self.reset_conversation(keep_plan, no_digest).await {
1224 Ok((old_id, new_id)) => {
1225 let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1226 let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1227 let keep_note = if keep_plan { " (plan preserved)" } else { "" };
1228 Ok(format!(
1229 "New conversation started. Previous: {old} → Current: {new}{keep_note}"
1230 ))
1231 }
1232 Err(e) => Ok(format!("Failed to start new conversation: {e}")),
1233 }
1234 })
1235 }
1236
1237 fn cache_stats(&self) -> String {
1240 self.tool_orchestrator.cache_stats()
1241 }
1242
1243 fn session_status<'a>(
1246 &'a mut self,
1247 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1248 Box::pin(async move { Ok(self.handle_status_as_string()) })
1249 }
1250
1251 fn guardrail_status(&self) -> String {
1254 self.format_guardrail_status()
1255 }
1256
1257 fn focus_status(&self) -> String {
1260 self.format_focus_status()
1261 }
1262
1263 fn sidequest_status(&self) -> String {
1266 self.format_sidequest_status()
1267 }
1268
1269 fn load_image<'a>(
1272 &'a mut self,
1273 path: &'a str,
1274 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1275 use zeph_common::path_guard::{PathRejection, classify_relative_path};
1276 use zeph_llm::provider::{ImageData, MessagePart};
1277
1278 match classify_relative_path(path) {
1279 PathRejection::Allowed => {}
1280 PathRejection::Absolute => {
1281 return Box::pin(async move {
1282 Ok(
1283 "Invalid image path: absolute paths are not supported, use a path \
1284 relative to the working directory"
1285 .to_owned(),
1286 )
1287 });
1288 }
1289 PathRejection::Traversal => {
1290 return Box::pin(async move {
1291 Ok("Invalid image path: path traversal ('..') is not allowed".to_owned())
1292 });
1293 }
1294 }
1295
1296 let path_owned = path.to_owned();
1297 Box::pin(async move {
1298 let path_for_task = path_owned.clone();
1299 let read_result = tokio::task::spawn_blocking(move || std::fs::read(&path_for_task))
1300 .await
1301 .map_err(|e| CommandError::new(format!("spawn_blocking join error: {e}")))?;
1302 let data = match read_result {
1303 Ok(d) => d,
1304 Err(e) => return Ok(format!("Cannot read image {path_owned}: {e}")),
1305 };
1306 if data.len() > crate::agent::message_queue::MAX_IMAGE_BYTES {
1307 return Ok(format!(
1308 "Image {path_owned} exceeds size limit ({} MB), skipping",
1309 crate::agent::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
1310 ));
1311 }
1312 let mime_type =
1313 crate::agent::message_queue::detect_image_mime(Some(&path_owned)).to_string();
1314 self.msg
1315 .pending_image_parts
1316 .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
1317 Ok(format!("Image loaded: {path_owned}. Send your message."))
1318 })
1319 }
1320
1321 fn handle_mcp<'a>(
1324 &'a mut self,
1325 args: &'a str,
1326 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1327 let args_owned = args.to_owned();
1330 let parts: Vec<String> = args_owned.split_whitespace().map(str::to_owned).collect();
1331 let sub = parts.first().cloned().unwrap_or_default();
1332
1333 match sub.as_str() {
1334 "list" => {
1335 let manager = self.services.mcp.manager.clone();
1337 let tools_snapshot: Vec<(String, String)> = self
1338 .services
1339 .mcp
1340 .tools
1341 .iter()
1342 .map(|t| (t.server_id.clone(), t.name.clone()))
1343 .collect();
1344 Box::pin(async move {
1345 use std::fmt::Write;
1346 let Some(manager) = manager else {
1347 return Ok("MCP is not enabled.".to_owned());
1348 };
1349 let server_ids = manager.list_servers().await;
1350 if server_ids.is_empty() {
1351 return Ok("No MCP servers connected.".to_owned());
1352 }
1353 let mut output = String::from("Connected MCP servers:\n");
1354 let mut total = 0usize;
1355 for id in &server_ids {
1356 let count = tools_snapshot.iter().filter(|(sid, _)| sid == id).count();
1357 total += count;
1358 let _ = writeln!(output, "- {id} ({count} tools)");
1359 }
1360 let _ = write!(output, "Total: {total} tool(s)");
1361 Ok(output)
1362 })
1363 }
1364 "tools" => {
1365 let server_id = parts.get(1).cloned();
1367 let owned_tools: Vec<(String, String)> = if let Some(ref sid) = server_id {
1368 self.services
1369 .mcp
1370 .tools
1371 .iter()
1372 .filter(|t| &t.server_id == sid)
1373 .map(|t| (t.name.clone(), t.description.clone()))
1374 .collect()
1375 } else {
1376 Vec::new()
1377 };
1378 Box::pin(async move {
1379 use std::fmt::Write;
1380 let Some(server_id) = server_id else {
1381 return Ok("Usage: /mcp tools <server_id>".to_owned());
1382 };
1383 if owned_tools.is_empty() {
1384 return Ok(format!("No tools found for server '{server_id}'."));
1385 }
1386 let mut output =
1387 format!("Tools for '{server_id}' ({} total):\n", owned_tools.len());
1388 for (name, desc) in &owned_tools {
1389 if desc.is_empty() {
1390 let _ = writeln!(output, "- {name}");
1391 } else {
1392 let _ = writeln!(output, "- {name} — {desc}");
1393 }
1394 }
1395 Ok(output)
1396 })
1397 }
1398 _ => Box::pin(async move {
1405 self.handle_mcp_command(&args_owned)
1406 .await
1407 .map_err(|e| CommandError::new(e.to_string()))
1408 }),
1409 }
1410 }
1411
1412 delegate_cmd!(handle_skill, handle_skill_command_as_string, args: &'a str => String);
1415
1416 delegate_cmd!(handle_skills, handle_skills_as_string, args: &'a str => String);
1419
1420 delegate_cmd!(handle_feedback_command, handle_feedback_as_string, args: &'a str => String);
1423
1424 #[cfg(feature = "scheduler")]
1427 delegate_cmd!(handle_plan, dispatch_plan_command_as_string, input: &'a str => String);
1428
1429 #[cfg(not(feature = "scheduler"))]
1430 fn handle_plan<'a>(
1431 &'a mut self,
1432 _input: &'a str,
1433 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1434 Box::pin(async move { Ok(String::new()) })
1435 }
1436
1437 delegate_cmd!(handle_experiment, handle_experiment_command_as_string, input: &'a str => String);
1440
1441 fn handle_agent_dispatch<'a>(
1444 &'a mut self,
1445 input: &'a str,
1446 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1447 Box::pin(async move {
1448 match self.dispatch_agent_command(input).await {
1449 Some(Err(e)) => Err(CommandError::new(e.to_string())),
1450 Some(Ok(())) | None => Ok(None),
1451 }
1452 })
1453 }
1454
1455 fn handle_plugins<'a>(
1458 &'a mut self,
1459 args: &'a str,
1460 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1461 let args_owned = args.to_owned();
1462 let managed_dir = self.services.skill.managed_dir.clone();
1465 let mcp_allowed = self.services.mcp.allowed_commands.clone();
1466 let base_shell_allowed = self.runtime.lifecycle.startup_shell_overlay.allowed.clone();
1467 let reputation_cfg = self.runtime.config.plugins_reputation.clone();
1470 let ephemeral_manifest_paths: Vec<std::path::PathBuf> = self
1473 .runtime
1474 .ephemeral_plugins
1475 .iter()
1476 .map(|tmp| tmp.path().join("plugin.toml"))
1477 .collect();
1478
1479 let semantic_scan_enabled = self.services.skill.semantic_scan;
1483 let maybe_scanner: Option<zeph_skills::semantic_scanner::SkillSemanticScanner> =
1484 if semantic_scan_enabled {
1485 let provider_name = self.services.skill.semantic_scan_provider.as_str();
1486 if provider_name.trim().is_empty() {
1487 return Box::pin(async move {
1488 Err(CommandError::new(
1489 "semantic_scan is enabled but semantic_scan_provider is not set; \
1490 refusing plugin add to maintain fail-closed security posture",
1491 ))
1492 });
1493 }
1494 let provider_known = self
1495 .runtime
1496 .providers
1497 .provider_pool
1498 .iter()
1499 .any(|e| e.effective_name().eq_ignore_ascii_case(provider_name));
1500 if !provider_known {
1501 let name = provider_name.to_owned();
1502 return Box::pin(async move {
1503 Err(CommandError::new(format!(
1504 "semantic_scan is enabled but semantic_scan_provider '{name}' \
1505 is not configured in [[llm.providers]]; \
1506 refusing plugin add to maintain fail-closed security posture",
1507 )))
1508 });
1509 }
1510 let provider = self.resolve_background_provider(provider_name);
1511 Some(zeph_skills::semantic_scanner::SkillSemanticScanner::new(
1512 provider,
1513 ))
1514 } else {
1515 None
1516 };
1517
1518 Box::pin(async move {
1519 let (subcmd, source) = args_owned
1520 .trim()
1521 .split_once(' ')
1522 .unwrap_or((args_owned.trim(), ""));
1523
1524 if subcmd == "add"
1526 && !source.trim().is_empty()
1527 && let Some(ref scanner) = maybe_scanner
1528 && let Some(err) = semantic_scan_plugin_add(
1529 scanner,
1530 source.trim(),
1531 managed_dir.clone(),
1532 mcp_allowed.clone(),
1533 base_shell_allowed.clone(),
1534 )
1535 .instrument(tracing::info_span!("core.agent.scan_plugin", plugin = %source.trim()))
1536 .await?
1537 {
1538 return Ok(err);
1539 }
1540
1541 let ephemeral_names: Vec<String> = {
1543 use futures::future::join_all;
1544 let futs = ephemeral_manifest_paths.into_iter().map(|p| async move {
1545 tokio::fs::read_to_string(&p)
1546 .await
1547 .ok()
1548 .and_then(|s| toml::from_str::<zeph_plugins::PluginManifest>(&s).ok())
1549 .map(|m| m.plugin.name.to_string())
1550 });
1551 join_all(futs).await.into_iter().flatten().collect()
1552 };
1553
1554 tokio::task::spawn_blocking(move || {
1557 Self::run_plugin_command(
1558 &args_owned,
1559 managed_dir,
1560 mcp_allowed,
1561 base_shell_allowed,
1562 ephemeral_names,
1563 &reputation_cfg,
1564 )
1565 })
1566 .await
1567 .map_err(|e| CommandError(format!("plugin task panicked: {e}")))
1568 })
1569 }
1570
1571 fn handle_acp<'a>(
1574 &'a mut self,
1575 args: &'a str,
1576 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1577 Box::pin(async move {
1578 self.handle_acp_as_string(args)
1579 .map_err(|e| CommandError::new(e.to_string()))
1580 })
1581 }
1582
1583 #[cfg(feature = "cocoon")]
1586 delegate_cmd!(handle_cocoon, handle_cocoon_as_string, args: &'a str => String);
1587
1588 #[cfg(not(feature = "cocoon"))]
1589 fn handle_cocoon<'a>(
1590 &'a mut self,
1591 _args: &'a str,
1592 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1593 Box::pin(async {
1594 Ok("Cocoon support is not compiled in. Rebuild with `--features cocoon`.".to_owned())
1595 })
1596 }
1597
1598 fn handle_loop<'a>(
1601 &'a mut self,
1602 args: &'a str,
1603 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1604 use zeph_commands::handlers::loop_cmd::parse_loop_args;
1605
1606 let args_owned = args.trim().to_owned();
1607 Box::pin(async move {
1608 if args_owned == "stop" {
1609 return Ok(self.stop_user_loop());
1610 }
1611 if args_owned == "status" {
1612 return Ok(match &self.runtime.lifecycle.user_loop {
1613 Some(ls) => format!(
1614 "Loop active: \"{}\" (iteration {}, interval every {}s).",
1615 ls.prompt,
1616 ls.iteration,
1617 ls.interval.period().as_secs(),
1618 ),
1619 None => "No active loop.".to_owned(),
1620 });
1621 }
1622 let (prompt, interval_secs) = parse_loop_args(&args_owned)?;
1623
1624 if prompt.starts_with('/') {
1625 return Err(CommandError::new(
1626 "Loop prompt must not start with '/'. Slash commands cannot be used as loop prompts.",
1627 ));
1628 }
1629
1630 let min_secs = self.runtime.config.loop_min_interval_secs;
1631 if interval_secs < min_secs {
1632 return Err(CommandError::new(format!(
1633 "Minimum loop interval is {min_secs}s. Got {interval_secs}s."
1634 )));
1635 }
1636 if self.runtime.lifecycle.user_loop.is_some() {
1637 return Err(CommandError::new(
1638 "A loop is already active. Use /loop stop first.",
1639 ));
1640 }
1641
1642 self.start_user_loop(prompt.clone(), interval_secs);
1643 Ok(format!(
1644 "Loop started: \"{prompt}\" every {interval_secs}s. Use /loop stop to cancel."
1645 ))
1646 })
1647 }
1648
1649 fn notify_test<'a>(
1650 &'a mut self,
1651 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1652 let notifier = self.runtime.lifecycle.notifier.clone();
1653 Box::pin(async move {
1654 let Some(notifier) = notifier else {
1655 return Ok(
1656 "Notifications are disabled. Set `notifications.enabled = true` in config."
1657 .to_owned(),
1658 );
1659 };
1660 match notifier.fire_test().await {
1661 Ok(()) => Ok("Test notification sent.".to_owned()),
1662 Err(e) => Err(CommandError::new(format!("notification test failed: {e}"))),
1663 }
1664 })
1665 }
1666
1667 fn handle_trajectory(&mut self, args: &str) -> String {
1668 self.handle_trajectory_command_as_string(args)
1669 }
1670
1671 fn handle_scope(&self, args: &str) -> String {
1672 self.handle_scope_command_as_string(args)
1673 }
1674
1675 fn handle_goal<'a>(
1678 &'a mut self,
1679 args: &'a str,
1680 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1681 if self.services.goal_accounting.is_none() {
1683 if !self.runtime.config.goals.enabled {
1684 return Box::pin(async {
1685 Ok("Goals are disabled. Set `[goals] enabled = true` in config.".to_owned())
1686 });
1687 }
1688 let pool = match self.services.memory.persistence.memory.as_ref() {
1689 Some(m) => std::sync::Arc::new(m.sqlite().pool().clone()),
1690 None => {
1691 return Box::pin(async {
1692 Ok("Goals require a database backend (memory not configured).".to_owned())
1693 });
1694 }
1695 };
1696 let store = std::sync::Arc::new(crate::goal::GoalStore::new(pool));
1697 let accounting = std::sync::Arc::new(crate::goal::GoalAccounting::new(store));
1698 self.services.goal_accounting = Some(accounting);
1699 }
1700
1701 let accounting =
1702 self.services.goal_accounting.clone().expect(
1703 "invariant: goal_accounting is always Some at this point (initialized above)",
1704 );
1705 let max_chars = self.runtime.config.goals.max_text_chars;
1706 let default_budget = self.runtime.config.goals.default_token_budget;
1707 let autonomous_enabled = self.runtime.config.goals.autonomous_enabled;
1708 let autonomous_max_turns = self.runtime.config.goals.autonomous_max_turns;
1709 let args_owned = args.to_owned();
1710
1711 let pending_start_arc = std::sync::Arc::clone(&self.services.autonomous.pending_start_arc);
1716
1717 Box::pin(async move {
1718 let _ = accounting.refresh().await;
1719 let store = accounting.get_store();
1720 let args = args_owned.as_str();
1721
1722 match args {
1723 "" | "status" => goal_status(&accounting).await,
1724 "pause" => goal_pause(&accounting, &store).await,
1725 "resume" => goal_resume(&accounting, &store).await,
1726 "complete" => goal_complete(&accounting, &store).await,
1727 "clear" => goal_clear(&accounting, &store).await,
1728 "list" => goal_list(&store).await,
1729 _ if args.starts_with("create") => {
1730 let (msg, auto_req) = goal_create(
1731 args,
1732 &accounting,
1733 &store,
1734 max_chars,
1735 default_budget,
1736 autonomous_enabled,
1737 autonomous_max_turns,
1738 )
1739 .await?;
1740 if let Some(req) = auto_req {
1741 *pending_start_arc.lock() = Some(req);
1742 }
1743 Ok(msg)
1744 }
1745 _ => Ok(
1746 "Unknown /goal subcommand. Try: create, pause, resume, complete, clear, status, list."
1747 .to_owned(),
1748 ),
1749 }
1750 })
1751 }
1752
1753 fn active_goal_snapshot(&self) -> Option<zeph_commands::GoalSnapshot> {
1754 let accounting = self.services.goal_accounting.as_ref()?;
1755 let snap = accounting.snapshot()?;
1756 Some(zeph_commands::GoalSnapshot {
1757 id: snap.id,
1758 text: snap.text,
1759 status: match snap.status {
1760 crate::goal::GoalStatus::Active => zeph_commands::GoalStatusView::Active,
1761 crate::goal::GoalStatus::Paused => zeph_commands::GoalStatusView::Paused,
1762 crate::goal::GoalStatus::Completed => zeph_commands::GoalStatusView::Completed,
1763 crate::goal::GoalStatus::Cleared => zeph_commands::GoalStatusView::Cleared,
1764 },
1765 turns_used: snap.turns_used,
1766 tokens_used: snap.tokens_used,
1767 token_budget: snap.token_budget,
1768 })
1769 }
1770
1771 fn handle_undo<'a>(
1774 &'a mut self,
1775 args: &'a str,
1776 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1777 let executor = std::sync::Arc::clone(&self.tool_executor);
1778 let args_owned = args.trim().to_owned();
1779 Box::pin(async move {
1780 if args_owned == "list" {
1781 let result = executor.checkpoint_list_erased();
1782 if !result.supported {
1783 return Ok(
1784 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1785 );
1786 }
1787 if result.entries.is_empty() {
1788 return Ok("Undo stack is empty.".to_owned());
1789 }
1790 let mut lines = vec![format!("Undo stack ({} entries):", result.entries.len())];
1791 for e in &result.entries {
1792 lines.push(format!(
1793 " [{}] {} ({} file(s))",
1794 e.index, e.command, e.file_count
1795 ));
1796 }
1797 if result.redo_depth > 0 {
1798 lines.push(format!("Redo depth: {}", result.redo_depth));
1799 }
1800 return Ok(lines.join("\n"));
1801 }
1802
1803 let n: usize = if args_owned.is_empty() {
1804 1
1805 } else {
1806 match args_owned.parse::<usize>() {
1807 Ok(v) if v > 0 => v,
1808 _ => {
1809 return Err(CommandError::new(format!(
1810 "Invalid argument: expected a positive integer or 'list', got '{args_owned}'"
1811 )));
1812 }
1813 }
1814 };
1815
1816 let result = tokio::task::spawn_blocking(move || executor.checkpoint_undo_erased(n))
1817 .await
1818 .map_err(|e| CommandError::new(format!("undo task panicked: {e}")))?;
1819 if !result.supported {
1820 return Ok(
1821 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1822 );
1823 }
1824 Ok(result.message)
1825 })
1826 }
1827
1828 fn handle_redo<'a>(
1829 &'a mut self,
1830 args: &'a str,
1831 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1832 let _ = args;
1833 let executor = std::sync::Arc::clone(&self.tool_executor);
1834 Box::pin(async move {
1835 let result = tokio::task::spawn_blocking(move || executor.checkpoint_redo_erased())
1836 .await
1837 .map_err(|e| CommandError::new(format!("redo task panicked: {e}")))?;
1838 if !result.supported {
1839 return Ok(
1840 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1841 );
1842 }
1843 Ok(result.message)
1844 })
1845 }
1846
1847 fn handle_web_search<'a>(
1850 &'a mut self,
1851 args: &'a str,
1852 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1853 let (query, limit) = parse_search_args(args);
1854 if query.is_empty() {
1855 return Box::pin(async move { Ok("Usage: /search <query> [--limit N]".to_owned()) });
1856 }
1857 let executor = std::sync::Arc::clone(&self.tool_executor);
1858 let query = query.to_owned();
1859 Box::pin(async move {
1860 let mut params = serde_json::Map::new();
1861 params.insert("query".to_owned(), serde_json::Value::String(query));
1862 if let Some(limit) = limit {
1863 params.insert("limit".to_owned(), serde_json::Value::Number(limit.into()));
1864 }
1865 let call = zeph_tools::ToolCall {
1866 tool_id: "web_search".into(),
1867 params,
1868 caller_id: None,
1869 context: None,
1870 tool_call_id: String::new(),
1871 skill_name: None,
1872 };
1873 match executor.execute_tool_call_erased(&call).await {
1874 Ok(Some(output)) => Ok(output.summary),
1875 Ok(None) => Ok(
1876 "web_search is not available. Enable it under `[tools.search]` and store \
1877 an API key in the vault."
1878 .to_owned(),
1879 ),
1880 Err(e) => Err(CommandError::new(format!("web_search failed: {e}"))),
1881 }
1882 })
1883 }
1884
1885 fn handle_agents<'a>(
1888 &'a mut self,
1889 args: &'a str,
1890 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1891 use zeph_commands::handlers::agents_fleet::{FleetEntry, format_fleet_section};
1892 use zeph_subagent::AgentsCommand;
1893
1894 let args_owned = args.trim().to_owned();
1895 Box::pin(async move {
1896 let show_fleet = args_owned.is_empty() || args_owned == "fleet";
1898
1899 let fleet_section = if show_fleet {
1900 let snapshots = self.services.autonomous_registry.list();
1901 let entries: Vec<FleetEntry> = snapshots
1902 .into_iter()
1903 .map(|s| FleetEntry {
1904 goal_id: s.goal_id,
1905 goal_text_short: s.goal_text_short,
1906 state: s.state,
1907 turns_executed: s.turns_executed,
1908 max_turns: s.max_turns,
1909 elapsed: s.elapsed,
1910 })
1911 .collect();
1912 format_fleet_section(&entries)
1913 } else {
1914 String::new()
1915 };
1916
1917 let definitions_section = if show_fleet || args_owned == "list" {
1919 self.handle_agents_definitions_list()
1920 } else {
1921 match AgentsCommand::parse(&format!("/agents {args_owned}")) {
1923 Ok(cmd) => self.handle_agents_crud(cmd),
1924 Err(e) => e.to_string(),
1925 }
1926 };
1927
1928 let mut out = fleet_section;
1929 if !definitions_section.is_empty() {
1930 if !out.is_empty() {
1931 out.push('\n');
1932 }
1933 out.push_str(&definitions_section);
1934 }
1935
1936 if out.is_empty() {
1937 "No active autonomous sessions or sub-agent definitions found."
1938 .clone_into(&mut out);
1939 }
1940
1941 Ok(out)
1942 })
1943 }
1944
1945 fn handle_conv<'a>(
1948 &'a mut self,
1949 args: &'a str,
1950 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1951 let args_owned = args.trim().to_owned();
1952 Box::pin(async move {
1953 if let Some(id) = args_owned.strip_prefix("resume ") {
1956 return self.handle_conv_resume(id.trim()).await;
1957 }
1958 if let Some(id) = args_owned.strip_prefix("fork ") {
1959 return self.handle_conv_fork(id.trim()).await;
1960 }
1961
1962 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1963 return Ok(
1964 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1965 .to_owned(),
1966 );
1967 };
1968 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1969
1970 if let Some(id) = args_owned.strip_prefix("show ") {
1971 return handle_conv_show(&store, id.trim()).await;
1972 }
1973 if args_owned.is_empty() || args_owned == "list" {
1974 return handle_conv_list(&store).await;
1975 }
1976 Ok(format!(
1977 "Unknown /conv subcommand '{args_owned}'. Usage: /conv [list | show <id> | resume <id> | fork <id>]"
1978 ))
1979 })
1980 }
1981
1982 delegate_cmd!(list_worktrees, handle_worktree_list_as_string => Option<String>);
1985
1986 delegate_cmd!(clean_worktrees, handle_worktree_clean_as_string, force: bool => Option<String>);
1987
1988 fn change_working_directory<'a>(
1991 &'a mut self,
1992 path: &'a str,
1993 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1994 Box::pin(
1995 async move {
1996 let path = path.trim();
1997 if path.is_empty() {
1998 let cwd = std::env::current_dir().map_err(|e| {
1999 CommandError::new(format!("failed to read current working directory: {e}"))
2000 })?;
2001 return Ok(format!("Current working directory: {}", cwd.display()));
2002 }
2003 let allowed_paths: Vec<std::path::PathBuf> =
2009 if self.services.tool_state.allowed_paths.is_empty() {
2010 std::env::current_dir()
2014 .map(|p| p.canonicalize().unwrap_or(p))
2015 .into_iter()
2016 .collect()
2017 } else {
2018 self.services.tool_state.allowed_paths.clone()
2019 };
2020 let new_cwd = zeph_tools::resolve_and_set_cwd(path, &allowed_paths)
2021 .map_err(|e| CommandError::new(format!("cannot change to '{path}': {e}")))?;
2022 self.check_cwd_changed().await;
2027 Ok(format!(
2028 "Working directory changed to: {}",
2029 new_cwd.display()
2030 ))
2031 }
2032 .instrument(tracing::info_span!("core.commands.cd")),
2033 )
2034 }
2035}
2036
2037impl<C: Channel> Agent<C> {
2038 async fn handle_conv_resume(&mut self, id: &str) -> Result<String, CommandError> {
2044 if id.is_empty() {
2045 return Ok("Usage: /conv resume <id>".to_owned());
2046 }
2047 if let Some(sink) = &self.services.session.session_sink
2053 && sink.session_id().as_str() == id
2054 {
2055 return Ok(format!("Already in session '{id}'."));
2056 }
2057 let Some(memory) = self.services.memory.persistence.memory.clone() else {
2058 return Ok(
2059 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
2060 .to_owned(),
2061 );
2062 };
2063 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
2064 let Some(metadata) = store
2065 .get(id)
2066 .await
2067 .map_err(|e| CommandError::new(e.to_string()))?
2068 else {
2069 return Ok(format!("Session '{id}' not found."));
2070 };
2071
2072 let conversation_id = if let Some(cid) = metadata.conversation_id {
2073 zeph_memory::ConversationId(cid)
2074 } else {
2075 let cid = memory
2076 .sqlite()
2077 .create_conversation()
2078 .await
2079 .map_err(|e| CommandError::new(e.to_string()))?;
2080 store
2081 .link_conversation(id, cid.0)
2082 .await
2083 .map_err(|e| CommandError::new(e.to_string()))?;
2084 cid
2085 };
2086
2087 let session_id = zeph_common::SessionId::new(id);
2088 self.load_and_resume_conversation(&session_id, conversation_id)
2089 .await
2090 .map_err(|e| CommandError::new(e.to_string()))?;
2091
2092 Ok(format!(
2093 "Resumed session {id} ({} event(s) replayed).",
2094 metadata.event_count
2095 ))
2096 }
2097
2098 async fn handle_conv_fork(&mut self, id: &str) -> Result<String, CommandError> {
2103 if id.is_empty() {
2104 return Ok("Usage: /conv fork <id>".to_owned());
2105 }
2106 let Some(memory) = self.services.memory.persistence.memory.clone() else {
2107 return Ok(
2108 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
2109 .to_owned(),
2110 );
2111 };
2112 let Some(session_persistence_config) =
2113 self.services.session.session_persistence_config.clone()
2114 else {
2115 return Ok(
2116 "Conversation-session persistence is not enabled ([session] enabled = true)."
2117 .to_owned(),
2118 );
2119 };
2120 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
2121 let data_dir = std::path::PathBuf::from(&session_persistence_config.data_dir);
2122 let new_id = zeph_common::SessionId::generate();
2123
2124 let fork_result =
2125 zeph_session::ForkEngine::fork(&data_dir, id, new_id.as_str(), None, &store, None)
2126 .await
2127 .map_err(|e| CommandError::new(e.to_string()))?;
2128
2129 let conversation_id = memory
2130 .sqlite()
2131 .create_conversation()
2132 .await
2133 .map_err(|e| CommandError::new(e.to_string()))?;
2134
2135 self.load_and_resume_conversation(&new_id, conversation_id)
2136 .await
2137 .map_err(|e| CommandError::new(e.to_string()))?;
2138
2139 Ok(format!(
2140 "Forked session {id} -> {} ({} event(s) copied); now the active conversation.",
2141 fork_result.new_session_id, fork_result.events_copied
2142 ))
2143 }
2144}
2145
2146async fn handle_conv_list(store: &zeph_session::SessionStore) -> Result<String, CommandError> {
2149 use std::fmt::Write as _;
2150
2151 let sessions = store
2152 .list(&zeph_session::SessionFilter::default())
2153 .await
2154 .map_err(|e| CommandError::new(format!("failed to list sessions: {e}")))?;
2155
2156 if sessions.is_empty() {
2157 return Ok("No conversation-sessions found.".to_owned());
2158 }
2159
2160 let mut out = format!(
2161 "{:<38} {:<30} {:<9} {:>6} {:<24}\n",
2162 "ID", "TITLE", "STATUS", "EVENTS", "UPDATED"
2163 );
2164 out.push_str(&"-".repeat(110));
2165 out.push('\n');
2166 for s in &sessions {
2167 let title = s.title.as_deref().unwrap_or("(untitled)");
2168 let _ = writeln!(
2169 out,
2170 "{:<38} {:<30} {:<9} {:>6} {:<24}",
2171 s.session_id,
2172 crate::text::truncate_to_chars(title, 30),
2173 s.status.as_str(),
2174 s.event_count,
2175 s.updated_at
2176 );
2177 }
2178 Ok(out.trim_end().to_owned())
2179}
2180
2181async fn handle_conv_show(
2185 store: &zeph_session::SessionStore,
2186 id: &str,
2187) -> Result<String, CommandError> {
2188 if id.is_empty() {
2189 return Ok("Usage: /conv show <id>".to_owned());
2190 }
2191 let metadata = store
2192 .get(id)
2193 .await
2194 .map_err(|e| CommandError::new(format!("failed to read session metadata: {e}")))?;
2195 let Some(m) = metadata else {
2196 return Ok(format!("Session '{id}' not found."));
2197 };
2198 Ok(format!(
2199 "Session {}\n title: {}\n status: {}\n events: {} (last_seq={})\n forked_from: {}\n created: {}\n updated: {}",
2200 m.session_id,
2201 m.title.as_deref().unwrap_or("(untitled)"),
2202 m.status.as_str(),
2203 m.event_count,
2204 m.last_seq,
2205 m.forked_from.as_deref().unwrap_or("-"),
2206 m.created_at,
2207 m.updated_at
2208 ))
2209}
2210
2211type GoalStore = crate::goal::GoalStore;
2212type GoalAccounting = crate::goal::GoalAccounting;
2213
2214const AUTONOMOUS_MAX_TURNS_CAP: u32 = 1000;
2216
2217async fn goal_status(accounting: &GoalAccounting) -> Result<String, CommandError> {
2218 match accounting.get_active().await {
2219 Ok(Some(g)) => {
2220 let budget_line = g.token_budget.map_or_else(
2221 || format!(" tokens used: {}", g.tokens_used),
2222 |b| format!(" budget: {}/{b}", g.tokens_used),
2223 );
2224 Ok(format!(
2225 "Active goal [{}]: {}\n status: {}\n turns: {}\n{}",
2226 &g.id[..8],
2227 g.text,
2228 g.status,
2229 g.turns_used,
2230 budget_line
2231 ))
2232 }
2233 Ok(None) => Ok("No active goal. Use `/goal create <text>` to set one.".to_owned()),
2234 Err(e) => Ok(format!("Goal lookup failed: {e}")),
2235 }
2236}
2237
2238async fn goal_create(
2244 args: &str,
2245 accounting: &GoalAccounting,
2246 store: &GoalStore,
2247 max_chars: usize,
2248 default_budget: Option<u64>,
2249 autonomous_enabled: bool,
2250 autonomous_max_turns: u32,
2251) -> Result<(String, Option<(String, String, u32)>), CommandError> {
2252 let rest = args.strip_prefix("create").unwrap_or("").trim();
2253
2254 let (stripped, is_auto, explicit_turns) = parse_auto_flags(rest);
2256 let (text, explicit_budget) = parse_goal_create_args(&stripped);
2257
2258 if text.is_empty() {
2259 return Ok((
2260 "Usage: /goal create <text> [--budget N] [--auto [--turns N]]".to_owned(),
2261 None,
2262 ));
2263 }
2264 if is_auto && !autonomous_enabled {
2265 return Ok((
2266 "Autonomous mode is disabled. Set `[goals] autonomous_enabled = true` in config."
2267 .to_owned(),
2268 None,
2269 ));
2270 }
2271 let budget = explicit_budget.or(default_budget.filter(|&b| b > 0));
2272
2273 let max_turns = explicit_turns
2274 .unwrap_or(autonomous_max_turns)
2275 .min(AUTONOMOUS_MAX_TURNS_CAP);
2276 if explicit_turns.is_some_and(|t| t > AUTONOMOUS_MAX_TURNS_CAP) {
2277 tracing::warn!(
2278 requested = explicit_turns,
2279 capped = AUTONOMOUS_MAX_TURNS_CAP,
2280 "autonomous max_turns capped to {AUTONOMOUS_MAX_TURNS_CAP}"
2281 );
2282 }
2283
2284 match store.create(text, budget, max_chars).await {
2285 Ok(g) => {
2286 let _ = accounting.refresh().await;
2287 let auto_start = if is_auto {
2288 Some((g.id.clone(), g.text.clone(), max_turns))
2289 } else {
2290 None
2291 };
2292 let auto_note = if is_auto {
2293 " Autonomous mode enabled — use `/goal clear` to stop."
2294 } else {
2295 ""
2296 };
2297 Ok((
2298 format!("Goal created [{}]: {}{auto_note}", &g.id[..8], g.text),
2299 auto_start,
2300 ))
2301 }
2302 Err(crate::goal::store::GoalError::TextTooLong { max }) => Ok((
2303 format!("Goal text exceeds {max} characters. Please shorten it."),
2304 None,
2305 )),
2306 Err(e) => Ok((format!("Failed to create goal: {e}"), None)),
2307 }
2308}
2309
2310async fn goal_pause(
2311 accounting: &GoalAccounting,
2312 store: &GoalStore,
2313) -> Result<String, CommandError> {
2314 match accounting.get_active().await {
2315 Ok(Some(g)) => {
2316 match store
2317 .transition(&g.id, crate::goal::GoalStatus::Paused, g.updated_at)
2318 .await
2319 {
2320 Ok(_) => {
2321 let _ = accounting.refresh().await;
2322 Ok(format!("Goal [{}] paused.", &g.id[..8]))
2323 }
2324 Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
2325 let current = accounting.get_active().await.ok().flatten();
2326 Ok(format!(
2327 "Goal state changed concurrently. Current: {}",
2328 current.map_or_else(|| "none".into(), |g| g.status.to_string())
2329 ))
2330 }
2331 Err(e) => Ok(format!("Pause failed: {e}")),
2332 }
2333 }
2334 Ok(None) => Ok("No active goal to pause.".to_owned()),
2335 Err(e) => Ok(format!("Failed: {e}")),
2336 }
2337}
2338
2339async fn goal_resume(
2340 accounting: &GoalAccounting,
2341 store: &GoalStore,
2342) -> Result<String, CommandError> {
2343 let goals = store.list(10).await.unwrap_or_default();
2344 let paused = goals
2345 .into_iter()
2346 .find(|g| g.status == crate::goal::GoalStatus::Paused);
2347 match paused {
2348 Some(g) => {
2349 match store
2350 .transition(&g.id, crate::goal::GoalStatus::Active, g.updated_at)
2351 .await
2352 {
2353 Ok(_) => {
2354 let _ = accounting.refresh().await;
2355 Ok(format!("Goal [{}] resumed: {}", &g.id[..8], g.text))
2356 }
2357 Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
2358 Ok("Goal state changed concurrently — please retry.".to_owned())
2359 }
2360 Err(e) => Ok(format!("Resume failed: {e}")),
2361 }
2362 }
2363 None => Ok("No paused goal to resume.".to_owned()),
2364 }
2365}
2366
2367async fn goal_complete(
2368 accounting: &GoalAccounting,
2369 store: &GoalStore,
2370) -> Result<String, CommandError> {
2371 match accounting.get_active().await {
2372 Ok(Some(g)) => {
2373 match store
2374 .transition(&g.id, crate::goal::GoalStatus::Completed, g.updated_at)
2375 .await
2376 {
2377 Ok(_) => {
2378 let _ = accounting.refresh().await;
2379 Ok(format!("Goal [{}] marked complete.", &g.id[..8]))
2380 }
2381 Err(e) => Ok(format!("Complete failed: {e}")),
2382 }
2383 }
2384 Ok(None) => Ok("No active goal.".to_owned()),
2385 Err(e) => Ok(format!("Failed: {e}")),
2386 }
2387}
2388
2389async fn goal_clear(
2390 accounting: &GoalAccounting,
2391 store: &GoalStore,
2392) -> Result<String, CommandError> {
2393 let goals = store.list(10).await.unwrap_or_default();
2394 let target = goals.into_iter().find(|g| {
2395 g.status == crate::goal::GoalStatus::Active || g.status == crate::goal::GoalStatus::Paused
2396 });
2397 match target {
2398 Some(g) => {
2399 match store
2400 .transition(&g.id, crate::goal::GoalStatus::Cleared, g.updated_at)
2401 .await
2402 {
2403 Ok(_) => {
2404 let _ = accounting.refresh().await;
2405 Ok(format!("Goal [{}] cleared.", &g.id[..8]))
2406 }
2407 Err(e) => Ok(format!("Clear failed: {e}")),
2408 }
2409 }
2410 None => Ok("No active or paused goal to clear.".to_owned()),
2411 }
2412}
2413
2414async fn goal_list(store: &GoalStore) -> Result<String, CommandError> {
2415 let goals = store.list(20).await.unwrap_or_default();
2416 if goals.is_empty() {
2417 return Ok("No goals recorded.".to_owned());
2418 }
2419 let mut out = String::from("Goals:\n");
2420 for g in goals {
2421 let _ = std::fmt::Write::write_fmt(
2422 &mut out,
2423 format_args!(
2424 " {} [{}] {} — {} turns\n",
2425 g.status.badge_symbol(),
2426 &g.id[..8],
2427 g.text,
2428 g.turns_used
2429 ),
2430 );
2431 }
2432 Ok(out.trim_end().to_owned())
2433}
2434
2435fn parse_search_args(args: &str) -> (&str, Option<usize>) {
2437 if let Some(pos) = args.find("--limit") {
2438 let query = args[..pos].trim();
2439 let rest = args[pos + "--limit".len()..].trim();
2440 let limit = rest
2441 .split_whitespace()
2442 .next()
2443 .and_then(|s| s.parse::<usize>().ok());
2444 (query, limit)
2445 } else {
2446 (args.trim(), None)
2447 }
2448}
2449
2450fn parse_goal_create_args(args: &str) -> (&str, Option<u64>) {
2451 if let Some(pos) = args.find("--budget") {
2452 let text = args[..pos].trim();
2453 let rest = args[pos + "--budget".len()..].trim();
2454 let budget = rest
2455 .split_whitespace()
2456 .next()
2457 .and_then(|s| s.parse::<u64>().ok());
2458 (text, budget)
2459 } else {
2460 (args, None)
2461 }
2462}
2463
2464fn parse_auto_flags(args: &str) -> (String, bool, Option<u32>) {
2468 let mut is_auto = false;
2469 let mut turns: Option<u32> = None;
2470 let mut text_words: Vec<&str> = Vec::new();
2471 let mut words = args.split_whitespace();
2472
2473 while let Some(w) = words.next() {
2474 if w == "--auto" {
2475 is_auto = true;
2476 } else if w == "--turns" {
2477 turns = words.next().and_then(|n| n.parse::<u32>().ok());
2478 } else {
2479 text_words.push(w);
2480 }
2481 }
2482
2483 (text_words.join(" "), is_auto, turns)
2484}
2485
2486impl From<AgentError> for CommandError {
2488 fn from(e: AgentError) -> Self {
2489 Self(e.to_string())
2490 }
2491}
2492
2493#[cfg(test)]
2494mod tests {
2495 use super::super::agent_tests::{
2496 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
2497 };
2498 use super::*;
2499 use zeph_commands::traits::agent::AgentAccess;
2500 use zeph_memory::semantic::SemanticMemory;
2501
2502 async fn memory_without_qdrant() -> SemanticMemory {
2503 SemanticMemory::new(
2504 ":memory:",
2505 "http://127.0.0.1:1",
2506 None,
2507 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
2508 "test-model",
2509 )
2510 .await
2511 .unwrap()
2512 }
2513
2514 #[tokio::test]
2518 async fn graph_stats_enabled_but_no_store_reports_unavailable() {
2519 let cfg = crate::config::GraphConfig {
2520 enabled: true,
2521 ..Default::default()
2522 };
2523 let memory = memory_without_qdrant().await;
2524 let cid = memory.sqlite().create_conversation().await.unwrap();
2525 let mut agent = Agent::new(
2526 mock_provider(vec![]),
2527 MockChannel::new(vec![]),
2528 create_test_registry(),
2529 None,
2530 5,
2531 MockToolExecutor::no_tools(),
2532 )
2533 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2534 .with_graph_config(cfg);
2535
2536 let result = agent.graph_stats().await.unwrap();
2537 assert!(
2538 result.contains("unavailable"),
2539 "expected 'unavailable' but got: {result}"
2540 );
2541 assert!(
2542 !result.contains("not enabled"),
2543 "must not report 'not enabled' when graph is enabled: {result}"
2544 );
2545 }
2546
2547 #[tokio::test]
2548 async fn graph_stats_disabled_reports_not_enabled() {
2549 let cfg = crate::config::GraphConfig {
2550 enabled: false,
2551 ..Default::default()
2552 };
2553 let memory = memory_without_qdrant().await;
2554 let cid = memory.sqlite().create_conversation().await.unwrap();
2555 let mut agent = Agent::new(
2556 mock_provider(vec![]),
2557 MockChannel::new(vec![]),
2558 create_test_registry(),
2559 None,
2560 5,
2561 MockToolExecutor::no_tools(),
2562 )
2563 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2564 .with_graph_config(cfg);
2565
2566 let result = agent.graph_stats().await.unwrap();
2567 assert!(
2568 result.contains("not enabled"),
2569 "expected 'not enabled' but got: {result}"
2570 );
2571 }
2572
2573 #[tokio::test]
2579 async fn graph_backfill_with_extract_provider_resolves_without_panic() {
2580 let cfg = crate::config::GraphConfig {
2581 enabled: true,
2582 extract_provider: zeph_config::providers::ProviderName::new("nonexistent-provider"),
2583 ..Default::default()
2584 };
2585 let mut memory = memory_without_qdrant().await;
2586 let pool = memory.sqlite().pool().clone();
2588 memory.graph_store = Some(std::sync::Arc::new(zeph_memory::GraphStore::new(pool)));
2589 let cid = memory.sqlite().create_conversation().await.unwrap();
2590 let mut agent = Agent::new(
2591 mock_provider(vec![]),
2592 MockChannel::new(vec![]),
2593 create_test_registry(),
2594 None,
2595 5,
2596 MockToolExecutor::no_tools(),
2597 )
2598 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2599 .with_graph_config(cfg);
2600
2601 let mut progress = vec![];
2602 let result = agent
2603 .graph_backfill(Some(10), &mut |msg| progress.push(msg))
2604 .await
2605 .unwrap();
2606
2607 assert!(
2609 result.contains("Backfill complete"),
2610 "expected 'Backfill complete' but got: {result}"
2611 );
2612 }
2613
2614 #[tokio::test]
2621 async fn graph_backfill_concurrent_extraction_aggregates_stats_without_dropping_results() {
2622 let n = 6;
2623 let cfg = crate::config::GraphConfig {
2624 enabled: true,
2625 ..Default::default()
2626 };
2627 let mut memory = memory_without_qdrant().await;
2628 let store = install_graph_store(&mut memory);
2629 let cid = memory.sqlite().create_conversation().await.unwrap();
2630
2631 for i in 0..n {
2632 sqlx::query(zeph_db::sql!(
2633 "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
2634 ))
2635 .bind(cid.0)
2636 .bind(format!("message body {i}"))
2637 .execute(memory.sqlite().pool())
2638 .await
2639 .unwrap();
2640 }
2641
2642 let responses: Vec<String> = (0..n)
2646 .map(|i| {
2647 format!(
2648 r#"{{"entities":[{{"name":"Entity{i}","type":"concept","summary":""}}],"edges":[]}}"#
2649 )
2650 })
2651 .collect();
2652
2653 let mut agent = Agent::new(
2654 mock_provider(responses),
2655 MockChannel::new(vec![]),
2656 create_test_registry(),
2657 None,
2658 5,
2659 MockToolExecutor::no_tools(),
2660 )
2661 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2662 .with_graph_config(cfg);
2663
2664 let mut progress = vec![];
2665 let result = agent
2666 .graph_backfill(None, &mut |msg| progress.push(msg))
2667 .await
2668 .unwrap();
2669
2670 assert!(
2671 result.contains(&format!("{n} entities")),
2672 "expected all {n} entities aggregated in the result, got: {result}"
2673 );
2674 assert!(
2675 result.contains(&format!("from {n} messages")),
2676 "expected all {n} messages counted as processed, got: {result}"
2677 );
2678
2679 for i in 0..n {
2681 let name = format!("entity{i}");
2682 let found = store
2683 .find_entity(&name, zeph_memory::EntityType::Concept)
2684 .await
2685 .unwrap();
2686 assert!(found.is_some(), "entity{i} must have been upserted");
2687 }
2688
2689 let remaining = store.unprocessed_message_count().await.unwrap();
2692 assert_eq!(remaining, 0, "all messages must be marked graph_processed");
2693 }
2694
2695 #[tokio::test]
2708 async fn graph_backfill_concurrent_extraction_survives_real_sqlite_write_contention() {
2709 let n = 8;
2710 let tmp = tempfile::NamedTempFile::new().expect("tempfile");
2711 let path = tmp.path().to_str().expect("valid utf-8 path").to_owned();
2712
2713 let cfg = crate::config::GraphConfig {
2714 enabled: true,
2715 ..Default::default()
2716 };
2717 let mut memory = SemanticMemory::new(
2718 &path,
2719 "http://127.0.0.1:1",
2720 None,
2721 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
2722 "test-model",
2723 )
2724 .await
2725 .unwrap();
2726 let store = install_graph_store(&mut memory);
2727 let cid = memory.sqlite().create_conversation().await.unwrap();
2728
2729 for i in 0..n {
2730 sqlx::query(zeph_db::sql!(
2731 "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
2732 ))
2733 .bind(cid.0)
2734 .bind(format!("contention message body {i}"))
2735 .execute(memory.sqlite().pool())
2736 .await
2737 .unwrap();
2738 }
2739
2740 let responses: Vec<String> = (0..n)
2744 .map(|i| {
2745 format!(
2746 r#"{{"entities":[{{"name":"ContentionEntity{i}","type":"concept","summary":""}}],"edges":[]}}"#
2747 )
2748 })
2749 .collect();
2750 let mut provider = zeph_llm::mock::MockProvider::with_responses(responses);
2751 provider.delay_ms = 15;
2752 let provider = zeph_llm::any::AnyProvider::Mock(provider);
2753
2754 let mut agent = Agent::new(
2755 provider,
2756 MockChannel::new(vec![]),
2757 create_test_registry(),
2758 None,
2759 5,
2760 MockToolExecutor::no_tools(),
2761 )
2762 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2763 .with_graph_config(cfg);
2764
2765 let mut progress = vec![];
2766 let result = agent
2767 .graph_backfill(None, &mut |msg| progress.push(msg))
2768 .await
2769 .unwrap();
2770
2771 assert!(
2772 result.contains(&format!("{n} entities")),
2773 "expected all {n} entities aggregated despite concurrent SQLite writers, got: {result}"
2774 );
2775
2776 for i in 0..n {
2782 let name = format!("contentionentity{i}");
2783 let found = store
2784 .find_entity(&name, zeph_memory::EntityType::Concept)
2785 .await
2786 .unwrap();
2787 assert!(
2788 found.is_some(),
2789 "entity {i} must have been upserted; a missing entity indicates a dropped/failed \
2790 concurrent write (e.g. a 'database is locked' error) under real multi-connection \
2791 contention"
2792 );
2793 }
2794
2795 let remaining = store.unprocessed_message_count().await.unwrap();
2796 assert_eq!(remaining, 0, "all messages must be marked graph_processed");
2797 }
2798
2799 #[tokio::test]
2802 async fn graph_entities_enabled_but_no_store_reports_unavailable() {
2803 let cfg = crate::config::GraphConfig {
2804 enabled: true,
2805 ..Default::default()
2806 };
2807 let memory = memory_without_qdrant().await;
2808 let cid = memory.sqlite().create_conversation().await.unwrap();
2809 let mut agent = Agent::new(
2810 mock_provider(vec![]),
2811 MockChannel::new(vec![]),
2812 create_test_registry(),
2813 None,
2814 5,
2815 MockToolExecutor::no_tools(),
2816 )
2817 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2818 .with_graph_config(cfg);
2819
2820 let result = agent.graph_entities().await.unwrap();
2821 assert!(
2822 result.contains("unavailable"),
2823 "expected 'unavailable' but got: {result}"
2824 );
2825 }
2826
2827 #[tokio::test]
2829 async fn graph_communities_enabled_but_no_store_reports_unavailable() {
2830 let cfg = crate::config::GraphConfig {
2831 enabled: true,
2832 ..Default::default()
2833 };
2834 let memory = memory_without_qdrant().await;
2835 let cid = memory.sqlite().create_conversation().await.unwrap();
2836 let mut agent = Agent::new(
2837 mock_provider(vec![]),
2838 MockChannel::new(vec![]),
2839 create_test_registry(),
2840 None,
2841 5,
2842 MockToolExecutor::no_tools(),
2843 )
2844 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2845 .with_graph_config(cfg);
2846
2847 let result = agent.graph_communities().await.unwrap();
2848 assert!(
2849 result.contains("unavailable"),
2850 "expected 'unavailable' but got: {result}"
2851 );
2852 }
2853
2854 #[tokio::test]
2859 async fn graph_store_timeout_pattern_fires_on_pending_future() {
2860 use std::future;
2861 let result = tokio::time::timeout(
2862 Duration::from_millis(10),
2863 future::pending::<Result<Vec<()>, String>>(),
2864 )
2865 .await;
2866 assert!(
2867 result.is_err(),
2868 "timeout must fire on a never-resolving future"
2869 );
2870 }
2871
2872 #[tokio::test]
2879 async fn with_graph_store_timeout_completes_on_success() {
2880 let result = with_graph_store_timeout(async { Ok::<_, zeph_memory::MemoryError>(42) })
2881 .await
2882 .unwrap();
2883 assert!(matches!(result, StoreCallOutcome::Completed(42)));
2884 }
2885
2886 #[tokio::test]
2887 async fn with_graph_store_timeout_maps_store_error_to_command_error() {
2888 let result = with_graph_store_timeout(async {
2889 Err::<i32, _>(zeph_memory::MemoryError::GraphStore("boom".to_owned()))
2890 })
2891 .await;
2892 assert!(result.is_err(), "store error must surface as CommandError");
2893 }
2894
2895 #[tokio::test]
2896 async fn with_graph_store_timeout_times_out_on_pending_future() {
2897 tokio::time::pause();
2898 let fut = with_graph_store_timeout(std::future::pending::<
2899 Result<i32, zeph_memory::MemoryError>,
2900 >());
2901 let handle = tokio::spawn(fut); tokio::time::advance(std::time::Duration::from_secs(6)).await;
2903 let result = handle.await.expect("task panicked");
2904 assert!(
2905 matches!(result, Ok(StoreCallOutcome::TimedOut)),
2906 "call must resolve to TimedOut once the 5s deadline elapses"
2907 );
2908 }
2909
2910 fn install_graph_store(memory: &mut SemanticMemory) -> std::sync::Arc<zeph_memory::GraphStore> {
2916 let pool = memory.sqlite().pool().clone();
2917 let store = std::sync::Arc::new(zeph_memory::GraphStore::new(pool));
2918 memory.graph_store = Some(store.clone());
2919 store
2920 }
2921
2922 #[tokio::test]
2923 async fn graph_facts_happy_path_returns_formatted_facts() {
2924 let mut memory = memory_without_qdrant().await;
2925 let store = install_graph_store(&mut memory);
2926 let cid = memory.sqlite().create_conversation().await.unwrap();
2927
2928 let alice = store
2929 .upsert_entity(
2930 "Alice",
2931 "alice",
2932 zeph_memory::EntityType::Person,
2933 None,
2934 None,
2935 )
2936 .await
2937 .unwrap();
2938 let bob = store
2939 .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
2940 .await
2941 .unwrap();
2942 store
2943 .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
2944 .await
2945 .unwrap();
2946
2947 let cfg = crate::config::GraphConfig {
2948 enabled: true,
2949 ..Default::default()
2950 };
2951 let mut agent = Agent::new(
2952 mock_provider(vec![]),
2953 MockChannel::new(vec![]),
2954 create_test_registry(),
2955 None,
2956 5,
2957 MockToolExecutor::no_tools(),
2958 )
2959 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2960 .with_graph_config(cfg);
2961
2962 let result = agent.graph_facts("Alice").await.unwrap();
2963 assert!(
2964 result.contains("Facts for 'Alice'"),
2965 "expected facts header, got: {result}"
2966 );
2967 assert!(
2968 result.contains("Bob"),
2969 "expected target entity name, got: {result}"
2970 );
2971 assert!(result.contains("knows"), "expected relation, got: {result}");
2972 assert!(
2973 result.contains("Alice knows Bob"),
2974 "expected fact text, got: {result}"
2975 );
2976 }
2977
2978 #[tokio::test]
2979 async fn graph_facts_entity_not_found_returns_message() {
2980 let mut memory = memory_without_qdrant().await;
2981 install_graph_store(&mut memory);
2982 let cid = memory.sqlite().create_conversation().await.unwrap();
2983
2984 let cfg = crate::config::GraphConfig {
2985 enabled: true,
2986 ..Default::default()
2987 };
2988 let mut agent = Agent::new(
2989 mock_provider(vec![]),
2990 MockChannel::new(vec![]),
2991 create_test_registry(),
2992 None,
2993 5,
2994 MockToolExecutor::no_tools(),
2995 )
2996 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2997 .with_graph_config(cfg);
2998
2999 let result = agent.graph_facts("Nobody").await.unwrap();
3000 assert_eq!(result, "No entity found matching 'Nobody'.");
3001 }
3002
3003 #[tokio::test]
3007 async fn graph_facts_enabled_but_no_store_reports_unavailable() {
3008 let cfg = crate::config::GraphConfig {
3009 enabled: true,
3010 ..Default::default()
3011 };
3012 let memory = memory_without_qdrant().await;
3013 let cid = memory.sqlite().create_conversation().await.unwrap();
3014 let mut agent = Agent::new(
3015 mock_provider(vec![]),
3016 MockChannel::new(vec![]),
3017 create_test_registry(),
3018 None,
3019 5,
3020 MockToolExecutor::no_tools(),
3021 )
3022 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3023 .with_graph_config(cfg);
3024
3025 let result = agent.graph_facts("Alice").await.unwrap();
3026 assert!(
3027 result.contains("unavailable"),
3028 "expected 'unavailable' but got: {result}"
3029 );
3030 }
3031
3032 #[tokio::test]
3039 async fn graph_facts_self_loop_edge_does_not_panic() {
3040 let mut memory = memory_without_qdrant().await;
3041 let store = install_graph_store(&mut memory);
3042 let cid = memory.sqlite().create_conversation().await.unwrap();
3043
3044 let self_entity = store
3045 .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
3046 .await
3047 .unwrap();
3048 let pool = memory.sqlite().pool().clone();
3049 zeph_db::query(zeph_db::sql!(
3050 "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
3051 ))
3052 .execute(&pool)
3053 .await
3054 .unwrap();
3055 zeph_db::query(zeph_db::sql!(
3056 "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
3057 VALUES (?, ?, ?, ?, ?)"
3058 ))
3059 .bind(self_entity.0)
3060 .bind(self_entity.0)
3061 .bind("refers_to")
3062 .bind("Self refers to itself")
3063 .bind(1.0_f64)
3064 .execute(&pool)
3065 .await
3066 .unwrap();
3067
3068 let cfg = crate::config::GraphConfig {
3069 enabled: true,
3070 ..Default::default()
3071 };
3072 let mut agent = Agent::new(
3073 mock_provider(vec![]),
3074 MockChannel::new(vec![]),
3075 create_test_registry(),
3076 None,
3077 5,
3078 MockToolExecutor::no_tools(),
3079 )
3080 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3081 .with_graph_config(cfg);
3082
3083 let result = agent.graph_facts("Self").await.unwrap();
3084 assert!(
3085 result.contains("Facts for 'Self'"),
3086 "expected facts header, got: {result}"
3087 );
3088 assert!(
3089 result.contains("refers_to"),
3090 "expected self-loop relation, got: {result}"
3091 );
3092 assert!(
3093 !result.contains('#'),
3094 "self-loop endpoint must resolve to the entity's own name, not a raw #id \
3095 placeholder: {result}"
3096 );
3097 }
3098
3099 #[tokio::test]
3100 async fn graph_history_happy_path_returns_formatted_history() {
3101 let mut memory = memory_without_qdrant().await;
3102 let store = install_graph_store(&mut memory);
3103 let cid = memory.sqlite().create_conversation().await.unwrap();
3104
3105 let alice = store
3106 .upsert_entity(
3107 "Alice",
3108 "alice",
3109 zeph_memory::EntityType::Person,
3110 None,
3111 None,
3112 )
3113 .await
3114 .unwrap();
3115 let bob = store
3116 .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
3117 .await
3118 .unwrap();
3119 store
3120 .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
3121 .await
3122 .unwrap();
3123
3124 let cfg = crate::config::GraphConfig {
3125 enabled: true,
3126 ..Default::default()
3127 };
3128 let mut agent = Agent::new(
3129 mock_provider(vec![]),
3130 MockChannel::new(vec![]),
3131 create_test_registry(),
3132 None,
3133 5,
3134 MockToolExecutor::no_tools(),
3135 )
3136 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3137 .with_graph_config(cfg);
3138
3139 let result = agent.graph_history("Alice").await.unwrap();
3140 assert!(
3141 result.contains("Edge history for 'Alice'"),
3142 "expected history header, got: {result}"
3143 );
3144 assert!(
3145 result.contains("[active]"),
3146 "expected active tag, got: {result}"
3147 );
3148 assert!(
3149 result.contains("Bob"),
3150 "expected target entity name, got: {result}"
3151 );
3152 }
3153
3154 #[tokio::test]
3155 async fn graph_history_entity_not_found_returns_message() {
3156 let mut memory = memory_without_qdrant().await;
3157 install_graph_store(&mut memory);
3158 let cid = memory.sqlite().create_conversation().await.unwrap();
3159
3160 let cfg = crate::config::GraphConfig {
3161 enabled: true,
3162 ..Default::default()
3163 };
3164 let mut agent = Agent::new(
3165 mock_provider(vec![]),
3166 MockChannel::new(vec![]),
3167 create_test_registry(),
3168 None,
3169 5,
3170 MockToolExecutor::no_tools(),
3171 )
3172 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3173 .with_graph_config(cfg);
3174
3175 let result = agent.graph_history("Nobody").await.unwrap();
3176 assert_eq!(result, "No entity found matching 'Nobody'.");
3177 }
3178
3179 #[tokio::test]
3180 async fn graph_history_enabled_but_no_store_reports_unavailable() {
3181 let cfg = crate::config::GraphConfig {
3182 enabled: true,
3183 ..Default::default()
3184 };
3185 let memory = memory_without_qdrant().await;
3186 let cid = memory.sqlite().create_conversation().await.unwrap();
3187 let mut agent = Agent::new(
3188 mock_provider(vec![]),
3189 MockChannel::new(vec![]),
3190 create_test_registry(),
3191 None,
3192 5,
3193 MockToolExecutor::no_tools(),
3194 )
3195 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3196 .with_graph_config(cfg);
3197
3198 let result = agent.graph_history("Alice").await.unwrap();
3199 assert!(
3200 result.contains("unavailable"),
3201 "expected 'unavailable' but got: {result}"
3202 );
3203 }
3204
3205 #[tokio::test]
3207 async fn graph_history_self_loop_edge_does_not_panic() {
3208 let mut memory = memory_without_qdrant().await;
3209 let store = install_graph_store(&mut memory);
3210 let cid = memory.sqlite().create_conversation().await.unwrap();
3211
3212 let self_entity = store
3213 .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
3214 .await
3215 .unwrap();
3216 let pool = memory.sqlite().pool().clone();
3217 zeph_db::query(zeph_db::sql!(
3218 "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
3219 ))
3220 .execute(&pool)
3221 .await
3222 .unwrap();
3223 zeph_db::query(zeph_db::sql!(
3224 "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
3225 VALUES (?, ?, ?, ?, ?)"
3226 ))
3227 .bind(self_entity.0)
3228 .bind(self_entity.0)
3229 .bind("refers_to")
3230 .bind("Self refers to itself")
3231 .bind(1.0_f64)
3232 .execute(&pool)
3233 .await
3234 .unwrap();
3235
3236 let cfg = crate::config::GraphConfig {
3237 enabled: true,
3238 ..Default::default()
3239 };
3240 let mut agent = Agent::new(
3241 mock_provider(vec![]),
3242 MockChannel::new(vec![]),
3243 create_test_registry(),
3244 None,
3245 5,
3246 MockToolExecutor::no_tools(),
3247 )
3248 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3249 .with_graph_config(cfg);
3250
3251 let result = agent.graph_history("Self").await.unwrap();
3252 assert!(
3253 result.contains("Edge history for 'Self'"),
3254 "expected history header, got: {result}"
3255 );
3256 assert!(
3257 result.contains("refers_to"),
3258 "expected self-loop relation, got: {result}"
3259 );
3260 assert!(
3261 !result.contains('#'),
3262 "self-loop endpoint must resolve to the entity's own name, not a raw #id \
3263 placeholder: {result}"
3264 );
3265 }
3266
3267 #[tokio::test]
3272 async fn plugin_add_semantic_scan_enabled_empty_provider_returns_error() {
3273 let mut agent = Agent::new(
3274 mock_provider(vec![]),
3275 MockChannel::new(vec![]),
3276 create_test_registry(),
3277 None,
3278 5,
3279 MockToolExecutor::no_tools(),
3280 )
3281 .with_semantic_scan(true, "");
3282
3283 let result = agent.handle_plugins("add some-plugin").await;
3284 assert!(
3285 result.is_err(),
3286 "expected CommandError for missing semantic_scan_provider, got: {result:?}"
3287 );
3288 let msg = result.unwrap_err().to_string();
3289 assert!(
3290 msg.contains("semantic_scan_provider"),
3291 "error message must mention semantic_scan_provider, got: {msg}"
3292 );
3293 }
3294
3295 #[tokio::test]
3298 async fn plugin_list_semantic_scan_disabled_succeeds() {
3299 let mut agent = Agent::new(
3300 mock_provider(vec![]),
3301 MockChannel::new(vec![]),
3302 create_test_registry(),
3303 None,
3304 5,
3305 MockToolExecutor::no_tools(),
3306 )
3307 .with_semantic_scan(false, "");
3308
3309 let result = agent.handle_plugins("list").await;
3311 assert!(
3312 result.is_ok(),
3313 "plugin list must succeed when semantic_scan is disabled, got: {result:?}"
3314 );
3315 }
3316
3317 #[tokio::test]
3321 async fn plugin_add_semantic_scan_disabled_no_scan_error() {
3322 let mut agent = Agent::new(
3323 mock_provider(vec![]),
3324 MockChannel::new(vec![]),
3325 create_test_registry(),
3326 None,
3327 5,
3328 MockToolExecutor::no_tools(),
3329 )
3330 .with_semantic_scan(false, "");
3331
3332 let result = agent.handle_plugins("add some-plugin").await;
3333 if let Err(ref e) = result {
3336 assert!(
3337 !e.to_string().contains("semantic_scan_provider"),
3338 "must not fail with scan error when semantic_scan is disabled, got: {e}"
3339 );
3340 }
3341 }
3342
3343 #[tokio::test]
3347 async fn semantic_scan_plugin_add_concurrent_all_allow_returns_none() {
3348 use zeph_llm::any::AnyProvider;
3349 use zeph_llm::mock::MockProvider;
3350 use zeph_skills::semantic_scanner::SkillSemanticScanner;
3351
3352 let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
3354 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
3355 allow_json.clone(),
3356 allow_json,
3357 ]));
3358 let scanner = SkillSemanticScanner::new(provider);
3359
3360 let tmp = tempfile::tempdir().unwrap();
3363 let plugin_toml = r#"
3364[plugin]
3365name = "test-plugin"
3366version = "0.1.0"
3367description = "test"
3368
3369[[skills]]
3370path = "skill-a"
3371
3372[[skills]]
3373path = "skill-b"
3374"#;
3375 std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
3376 for name in ["skill-a", "skill-b"] {
3377 let skill_dir = tmp.path().join(name);
3378 std::fs::create_dir_all(&skill_dir).unwrap();
3379 std::fs::write(
3380 skill_dir.join("SKILL.md"),
3381 format!("# {name}\n\n## Purpose\nTest skill.\n"),
3382 )
3383 .unwrap();
3384 }
3385
3386 let result =
3387 semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
3388 .await;
3389
3390 assert!(result.is_ok(), "expected Ok, got: {result:?}");
3392 assert!(
3393 result.unwrap().is_none(),
3394 "expected None (all passed) but got Some(err)"
3395 );
3396 }
3397
3398 #[tokio::test]
3403 async fn semantic_scan_plugin_add_block_names_correct_skill() {
3404 use zeph_llm::any::AnyProvider;
3405 use zeph_llm::mock::MockProvider;
3406 use zeph_skills::semantic_scanner::SkillSemanticScanner;
3407
3408 let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
3410 let block_json = r#"{"verdict":"block","reason":"malicious"}"#.to_owned();
3411 let provider =
3412 AnyProvider::Mock(MockProvider::with_responses(vec![allow_json, block_json]));
3413 let scanner = SkillSemanticScanner::new(provider);
3414
3415 let tmp = tempfile::tempdir().unwrap();
3416 let plugin_toml = r#"
3417[plugin]
3418name = "test-plugin-block"
3419version = "0.1.0"
3420description = "test"
3421
3422[[skills]]
3423path = "skill-first"
3424
3425[[skills]]
3426path = "skill-second"
3427"#;
3428 std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
3429 for name in ["skill-first", "skill-second"] {
3430 let skill_dir = tmp.path().join(name);
3431 std::fs::create_dir_all(&skill_dir).unwrap();
3432 std::fs::write(
3433 skill_dir.join("SKILL.md"),
3434 format!("# {name}\n\n## Purpose\nTest skill.\n"),
3435 )
3436 .unwrap();
3437 }
3438
3439 let result =
3440 semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
3441 .await;
3442
3443 assert!(result.is_ok(), "expected Ok(_), got: {result:?}");
3444 let msg = result
3445 .unwrap()
3446 .expect("expected Some(err) for blocked skill");
3447 assert!(
3448 msg.contains("skill-second"),
3449 "rejection must name the blocked skill 'skill-second', got: {msg}"
3450 );
3451 assert!(
3452 !msg.contains("skill-first"),
3453 "rejection must NOT name the allowed skill 'skill-first', got: {msg}"
3454 );
3455 }
3456
3457 #[tokio::test]
3460 async fn plugin_add_semantic_scan_unknown_provider_returns_error() {
3461 let mut agent = Agent::new(
3462 mock_provider(vec![]),
3463 MockChannel::new(vec![]),
3464 create_test_registry(),
3465 None,
3466 5,
3467 MockToolExecutor::no_tools(),
3468 )
3469 .with_semantic_scan(true, "nonexistent_provider");
3470
3471 let result = agent.handle_plugins("add some-plugin").await;
3472 assert!(
3473 result.is_err(),
3474 "expected CommandError for unknown semantic_scan_provider, got: {result:?}"
3475 );
3476 let msg = result.unwrap_err().to_string();
3477 assert!(
3478 msg.contains("semantic_scan_provider"),
3479 "error message must mention semantic_scan_provider, got: {msg}"
3480 );
3481 }
3482
3483 #[tokio::test]
3489 async fn handle_conv_resume_same_session_short_circuits() {
3490 let memory = memory_without_qdrant().await;
3491 let cid = memory.sqlite().create_conversation().await.unwrap();
3492 let dir = tempfile::tempdir().unwrap();
3493 let data_dir = dir.path().to_path_buf();
3494 let session_id = zeph_common::SessionId::new("s1");
3495 let session_path = zeph_session::session_dir(&data_dir, session_id.as_str());
3496 let log = zeph_session::SessionEventLog::open_exclusive(&session_path)
3497 .await
3498 .unwrap();
3499 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3500 let sink = zeph_agent_persistence::SessionSink::new(
3501 std::sync::Arc::new(log),
3502 store,
3503 session_id.clone(),
3504 );
3505 let session_config = zeph_config::SessionConfig {
3506 enabled: true,
3507 data_dir: data_dir.to_string_lossy().into_owned(),
3508 ..Default::default()
3509 };
3510
3511 let mut agent = Agent::new(
3512 mock_provider(vec![]),
3513 MockChannel::new(vec![]),
3514 create_test_registry(),
3515 None,
3516 5,
3517 MockToolExecutor::no_tools(),
3518 )
3519 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3520 .with_session_sink(Some(std::sync::Arc::new(sink)))
3521 .with_session_persistence_config(Some(session_config));
3522
3523 let result = agent.handle_conv("resume s1").await.unwrap();
3524 assert_eq!(
3525 result, "Already in session 's1'.",
3526 "resuming into the currently-active session must short-circuit, not attempt \
3527 hydration/lock acquisition"
3528 );
3529 }
3530
3531 #[tokio::test]
3534 async fn handle_conv_resume_different_session_still_hydrates() {
3535 let memory = memory_without_qdrant().await;
3536 let cid = memory.sqlite().create_conversation().await.unwrap();
3537 let dir = tempfile::tempdir().unwrap();
3538 let data_dir = dir.path().to_path_buf();
3539
3540 let active_session_id = zeph_common::SessionId::new("s1");
3542 let active_session_path = zeph_session::session_dir(&data_dir, active_session_id.as_str());
3543 let active_log = zeph_session::SessionEventLog::open_exclusive(&active_session_path)
3544 .await
3545 .unwrap();
3546 let active_store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3547 let active_sink = zeph_agent_persistence::SessionSink::new(
3548 std::sync::Arc::new(active_log),
3549 active_store,
3550 active_session_id,
3551 );
3552
3553 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3556 store.create("s2").await.unwrap();
3557
3558 let session_config = zeph_config::SessionConfig {
3559 enabled: true,
3560 data_dir: data_dir.to_string_lossy().into_owned(),
3561 ..Default::default()
3562 };
3563
3564 let mut agent = Agent::new(
3565 mock_provider(vec![]),
3566 MockChannel::new(vec![]),
3567 create_test_registry(),
3568 None,
3569 5,
3570 MockToolExecutor::no_tools(),
3571 )
3572 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3573 .with_session_sink(Some(std::sync::Arc::new(active_sink)))
3574 .with_session_persistence_config(Some(session_config));
3575
3576 let result = agent.handle_conv("resume s2").await.unwrap();
3577 assert!(
3578 result.starts_with("Resumed session s2"),
3579 "resuming into a different, unlocked session must still hydrate normally, got: {result}"
3580 );
3581 }
3582
3583 #[tokio::test]
3586 async fn handle_conv_fork_creates_child_session_and_switches_to_it() {
3587 let memory = memory_without_qdrant().await;
3588 let cid = memory.sqlite().create_conversation().await.unwrap();
3589 let dir = tempfile::tempdir().unwrap();
3590 let data_dir = dir.path().to_path_buf();
3591
3592 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3593 store.create("s1").await.unwrap();
3594 let src_dir = zeph_session::session_dir(&data_dir, "s1");
3595 let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
3596 log.append(
3597 None,
3598 None,
3599 zeph_session::SessionEvent::SessionStarted {
3600 session_id: "s1".to_owned(),
3601 cwd: "/repo".to_owned(),
3602 provider_name: "claude".to_owned(),
3603 model: "opus".to_owned(),
3604 forked_from: None,
3605 },
3606 )
3607 .await
3608 .unwrap();
3609 store
3610 .update_seq("s1", log.last_seq().unwrap(), 1)
3611 .await
3612 .unwrap();
3613 drop(log);
3614
3615 let session_config = zeph_config::SessionConfig {
3616 enabled: true,
3617 data_dir: data_dir.to_string_lossy().into_owned(),
3618 ..Default::default()
3619 };
3620
3621 let mut agent = Agent::new(
3622 mock_provider(vec![]),
3623 MockChannel::new(vec![]),
3624 create_test_registry(),
3625 None,
3626 5,
3627 MockToolExecutor::no_tools(),
3628 )
3629 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3630 .with_session_persistence_config(Some(session_config));
3631
3632 let result = agent.handle_conv("fork s1").await.unwrap();
3633 assert!(
3634 result.starts_with("Forked session s1 ->"),
3635 "expected fork confirmation message, got: {result}"
3636 );
3637 assert!(
3638 result.contains("event(s) copied"),
3639 "expected copied-event count in confirmation, got: {result}"
3640 );
3641 }
3642
3643 #[tokio::test]
3648 async fn handle_conv_fork_sends_resume_banner_for_non_empty_history() {
3649 let memory = memory_without_qdrant().await;
3650 let cid = memory.sqlite().create_conversation().await.unwrap();
3651 let dir = tempfile::tempdir().unwrap();
3652 let data_dir = dir.path().to_path_buf();
3653
3654 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3655 store.create("s1").await.unwrap();
3656 let src_dir = zeph_session::session_dir(&data_dir, "s1");
3657 let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
3658 log.append(
3659 None,
3660 None,
3661 zeph_session::SessionEvent::SessionStarted {
3662 session_id: "s1".to_owned(),
3663 cwd: "/repo".to_owned(),
3664 provider_name: "claude".to_owned(),
3665 model: "opus".to_owned(),
3666 forked_from: None,
3667 },
3668 )
3669 .await
3670 .unwrap();
3671 log.append(
3672 None,
3673 None,
3674 zeph_session::SessionEvent::UserMessage {
3675 text: "hello".to_owned(),
3676 image_refs: vec![],
3677 },
3678 )
3679 .await
3680 .unwrap();
3681 store
3682 .update_seq("s1", log.last_seq().unwrap(), 2)
3683 .await
3684 .unwrap();
3685 drop(log);
3686
3687 let session_config = zeph_config::SessionConfig {
3688 enabled: true,
3689 data_dir: data_dir.to_string_lossy().into_owned(),
3690 ..Default::default()
3691 };
3692
3693 let mut agent = Agent::new(
3694 mock_provider(vec![]),
3695 MockChannel::new(vec![]),
3696 create_test_registry(),
3697 None,
3698 5,
3699 MockToolExecutor::no_tools(),
3700 )
3701 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3702 .with_session_persistence_config(Some(session_config));
3703
3704 let result = agent.handle_conv("fork s1").await.unwrap();
3705 assert!(
3706 result.starts_with("Forked session s1 ->"),
3707 "expected fork confirmation message, got: {result}"
3708 );
3709
3710 let sent = agent.channel.sent_messages();
3711 assert!(
3712 sent.iter().any(|m| m.contains("Resuming session")),
3713 "forking a session with non-empty prior history must send the resume banner \
3714 through the channel, got sent messages: {sent:?}"
3715 );
3716 }
3717
3718 #[tokio::test]
3723 async fn load_image_rejects_absolute_path() {
3724 let mut agent = Agent::new(
3725 mock_provider(vec![]),
3726 MockChannel::new(vec![]),
3727 create_test_registry(),
3728 None,
3729 5,
3730 MockToolExecutor::no_tools(),
3731 );
3732
3733 let result = AgentAccess::load_image(&mut agent, "/etc/passwd")
3734 .await
3735 .unwrap();
3736 assert!(result.contains("absolute paths are not supported"));
3737 }
3738
3739 #[tokio::test]
3740 async fn load_image_rejects_parent_dir_traversal() {
3741 let mut agent = Agent::new(
3742 mock_provider(vec![]),
3743 MockChannel::new(vec![]),
3744 create_test_registry(),
3745 None,
3746 5,
3747 MockToolExecutor::no_tools(),
3748 );
3749
3750 let result = AgentAccess::load_image(&mut agent, "../../etc/passwd")
3751 .await
3752 .unwrap();
3753 assert!(result.contains("path traversal") && result.contains("not allowed"));
3754 }
3755
3756 fn claude_agent() -> Agent<MockChannel> {
3759 let provider = zeph_llm::any::AnyProvider::Claude(zeph_llm::claude::ClaudeProvider::new(
3760 "key".into(),
3761 "claude-sonnet-5".into(),
3762 4096,
3763 ));
3764 Agent::new(
3765 provider,
3766 MockChannel::new(vec![]),
3767 create_test_registry(),
3768 None,
3769 5,
3770 MockToolExecutor::no_tools(),
3771 )
3772 }
3773
3774 #[tokio::test]
3775 async fn handle_think_tokens_empty_arg_displays_off_by_default() {
3776 let mut agent = claude_agent();
3777 let out = agent.handle_think_tokens("").await;
3778 assert!(out.contains("off"), "{out}");
3779 assert!(out.contains("claude"), "{out}");
3780 }
3781
3782 #[tokio::test]
3783 async fn handle_think_tokens_sets_and_displays_budget() {
3784 let mut agent = claude_agent();
3785 let set = agent.handle_think_tokens("8k").await;
3786 assert!(set.contains("8000"), "{set}");
3787
3788 let show = agent.handle_think_tokens("").await;
3789 assert!(show.contains("8000"), "{show}");
3790 }
3791
3792 #[tokio::test]
3793 async fn handle_think_tokens_off_disables() {
3794 let mut agent = claude_agent();
3795 agent.handle_think_tokens("8k").await;
3796 let out = agent.handle_think_tokens("off").await;
3797 assert!(out.contains("disabled"), "{out}");
3798 assert!(agent.provider.current_thinking_budget().is_none());
3799 }
3800
3801 #[tokio::test]
3802 async fn handle_think_tokens_invalid_parse_returns_error_no_mutation() {
3803 let mut agent = claude_agent();
3804 let out = agent.handle_think_tokens("1.2.3k").await;
3805 assert!(out.contains("think-tokens"), "{out}");
3806 assert!(agent.provider.current_thinking_budget().is_none());
3807 }
3808
3809 #[tokio::test]
3810 async fn handle_think_tokens_unsupported_provider_returns_explicit_message() {
3811 let mut agent = Agent::new(
3812 mock_provider(vec![]),
3813 MockChannel::new(vec![]),
3814 create_test_registry(),
3815 None,
3816 5,
3817 MockToolExecutor::no_tools(),
3818 );
3819 let out = agent.handle_think_tokens("8k").await;
3820 assert!(out.contains("does not support"), "{out}");
3821 assert!(out.contains("mock"), "{out}");
3822 }
3823
3824 #[tokio::test]
3825 async fn handle_think_tokens_cross_override_note_when_reasoning_effort_was_active() {
3826 let mut agent = claude_agent();
3827 agent.handle_reasoning_effort("high").await;
3828 let out = agent.handle_think_tokens("8k").await;
3829 assert!(
3830 out.contains("overrides the previously set reasoning-effort"),
3831 "{out}"
3832 );
3833 }
3834
3835 #[tokio::test]
3836 async fn handle_reasoning_effort_empty_arg_displays_off_by_default() {
3837 let mut agent = claude_agent();
3838 let out = agent.handle_reasoning_effort("").await;
3839 assert!(out.contains("off"), "{out}");
3840 }
3841
3842 #[tokio::test]
3843 async fn handle_reasoning_effort_sets_and_displays_level() {
3844 let mut agent = claude_agent();
3845 let set = agent.handle_reasoning_effort("high").await;
3846 assert!(set.contains("high"), "{set}");
3847
3848 let show = agent.handle_reasoning_effort("").await;
3849 assert!(show.contains("high"), "{show}");
3850 }
3851
3852 #[tokio::test]
3853 async fn handle_reasoning_effort_invalid_parse_returns_error_no_mutation() {
3854 let mut agent = claude_agent();
3855 let out = agent.handle_reasoning_effort("minimal").await;
3856 assert!(out.contains("reasoning-effort"), "{out}");
3857 assert!(agent.provider.current_reasoning_effort().is_none());
3858 }
3859
3860 #[tokio::test]
3861 async fn handle_reasoning_effort_unsupported_provider_returns_explicit_message() {
3862 let mut agent = Agent::new(
3863 mock_provider(vec![]),
3864 MockChannel::new(vec![]),
3865 create_test_registry(),
3866 None,
3867 5,
3868 MockToolExecutor::no_tools(),
3869 );
3870 let out = agent.handle_reasoning_effort("high").await;
3871 assert!(out.contains("does not support"), "{out}");
3872 }
3873
3874 #[tokio::test]
3875 async fn handle_reasoning_effort_cross_override_note_when_think_tokens_was_active() {
3876 let mut agent = claude_agent();
3877 agent.handle_think_tokens("8k").await;
3878 let out = agent.handle_reasoning_effort("high").await;
3879 assert!(
3880 out.contains("overrides the previously set thinking-token budget"),
3881 "{out}"
3882 );
3883 }
3884}