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