zeph_core/agent/
misc_commands.rs1use std::future::Future;
11use std::pin::Pin;
12
13use zeph_commands::{CommandError, MiscAccess};
14
15use super::Agent;
16use crate::channel::Channel;
17
18fn parse_search_args(args: &str) -> (&str, Option<usize>) {
20 if let Some(pos) = args.find("--limit") {
21 let query = args[..pos].trim();
22 let rest = args[pos + "--limit".len()..].trim();
23 let limit = rest
24 .split_whitespace()
25 .next()
26 .and_then(|s| s.parse::<usize>().ok());
27 (query, limit)
28 } else {
29 (args.trim(), None)
30 }
31}
32
33impl<C: Channel + Send + 'static> MiscAccess for Agent<C> {
34 fn handle_loop<'a>(
37 &'a mut self,
38 args: &'a str,
39 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
40 use zeph_commands::handlers::loop_cmd::parse_loop_args;
41
42 let args_owned = args.trim().to_owned();
43 Box::pin(async move {
44 if args_owned == "stop" {
45 return Ok(self.stop_user_loop());
46 }
47 if args_owned == "status" {
48 return Ok(match &self.runtime.lifecycle.user_loop {
49 Some(ls) => format!(
50 "Loop active: \"{}\" (iteration {}, interval every {}s).",
51 ls.prompt,
52 ls.iteration,
53 ls.interval.period().as_secs(),
54 ),
55 None => "No active loop.".to_owned(),
56 });
57 }
58 let (prompt, interval_secs) = parse_loop_args(&args_owned)?;
59
60 if prompt.starts_with('/') {
61 return Err(CommandError::new(
62 "Loop prompt must not start with '/'. Slash commands cannot be used as loop prompts.",
63 ));
64 }
65
66 let min_secs = self.runtime.config.loop_min_interval_secs;
67 if interval_secs < min_secs {
68 return Err(CommandError::new(format!(
69 "Minimum loop interval is {min_secs}s. Got {interval_secs}s."
70 )));
71 }
72 if self.runtime.lifecycle.user_loop.is_some() {
73 return Err(CommandError::new(
74 "A loop is already active. Use /loop stop first.",
75 ));
76 }
77
78 self.start_user_loop(prompt.clone(), interval_secs);
79 Ok(format!(
80 "Loop started: \"{prompt}\" every {interval_secs}s. Use /loop stop to cancel."
81 ))
82 })
83 }
84
85 fn notify_test<'a>(
88 &'a mut self,
89 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
90 let notifier = self.runtime.lifecycle.notifier.clone();
91 Box::pin(async move {
92 let Some(notifier) = notifier else {
93 return Ok(
94 "Notifications are disabled. Set `notifications.enabled = true` in config."
95 .to_owned(),
96 );
97 };
98 match notifier.fire_test().await {
99 Ok(()) => Ok("Test notification sent.".to_owned()),
100 Err(e) => Err(CommandError::new(format!("notification test failed: {e}"))),
101 }
102 })
103 }
104
105 fn handle_web_search<'a>(
108 &'a mut self,
109 args: &'a str,
110 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
111 let (query, limit) = parse_search_args(args);
112 if query.is_empty() {
113 return Box::pin(async move { Ok("Usage: /search <query> [--limit N]".to_owned()) });
114 }
115 let executor = std::sync::Arc::clone(&self.tool_executor);
116 let query = query.to_owned();
117 Box::pin(async move {
118 let mut params = serde_json::Map::new();
119 params.insert("query".to_owned(), serde_json::Value::String(query));
120 if let Some(limit) = limit {
121 params.insert("limit".to_owned(), serde_json::Value::Number(limit.into()));
122 }
123 let call = zeph_tools::ToolCall {
124 tool_id: "web_search".into(),
125 params,
126 caller_id: None,
127 context: None,
128 tool_call_id: String::new(),
129 skill_name: None,
130 };
131 match executor.execute_tool_call_erased(&call).await {
132 Ok(Some(output)) => Ok(output.summary),
133 Ok(None) => Ok(
134 "web_search is not available. Enable it under `[tools.search]` and store \
135 an API key in the vault."
136 .to_owned(),
137 ),
138 Err(e) => Err(CommandError::new(format!("web_search failed: {e}"))),
139 }
140 })
141 }
142}