Skip to main content

zeph_core/agent/
misc_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`zeph_commands::MiscAccess`] implementation for [`Agent<C>`]: `/loop`, `/notify-test`,
5//! and `/search` — a small residual set of commands that do not share a subsystem with any
6//! other sub-trait.
7//!
8//! [`Agent<C>`]: super::Agent
9
10use std::future::Future;
11use std::pin::Pin;
12
13use zeph_commands::{CommandError, MiscAccess};
14
15use super::Agent;
16use crate::channel::Channel;
17
18/// Parse `<query> [--limit N]` for `/search`. Returns `(trimmed_query, limit)`.
19fn 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    // ----- /loop -----
35
36    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    // ----- /notify-test -----
86
87    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    // ----- /search -----
106
107    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}