Skip to main content

zeph_core/agent/
trajectory_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `/trajectory` command handler, plus the [`zeph_commands::TrackingAccess`] implementation
5//! for [`Agent<C>`] (`/trajectory`, `/scope`, `/goal`).
6//!
7//! Operator-only: score, level, and alert data MUST NOT appear in LLM context.
8//!
9//! [`Agent<C>`]: super::Agent
10
11use std::future::Future;
12use std::pin::Pin;
13
14use zeph_commands::{CommandError, TrackingAccess};
15
16use super::Agent;
17use crate::channel::Channel;
18
19impl<C: Channel> Agent<C> {
20    /// Handle `/trajectory [status|reset]` and return a user-visible result.
21    pub(super) fn handle_trajectory_command_as_string(&mut self, args: &str) -> String {
22        let subcmd = args.split_whitespace().next().unwrap_or("status");
23        if subcmd == "reset" {
24            // S-HIGH-02: reset is operator-only; refuse from ACP/LLM-callable sessions.
25            if self.services.security.is_acp_session {
26                return "Permission denied: /trajectory reset is operator-only.".to_owned();
27            }
28            self.services.security.trajectory.reset();
29            *self.services.security.trajectory_risk_slot.write() = 0;
30            "Trajectory sentinel reset.".to_owned()
31        } else {
32            let level = self.services.security.trajectory.current_risk();
33            let score = self.services.security.trajectory.score_now();
34            let turn = self.services.security.trajectory.current_turn();
35            let signals = self.services.security.trajectory.signal_count();
36            format!(
37                "Trajectory: level={level:?}, score={score:.2}, turn={turn}, signals_in_window={signals}"
38            )
39        }
40    }
41}
42
43type GoalStore = crate::goal::GoalStore;
44type GoalAccounting = crate::goal::GoalAccounting;
45
46/// Hard cap on `--turns` to prevent runaway autonomous loops (Security Low).
47const AUTONOMOUS_MAX_TURNS_CAP: u32 = 1000;
48
49async fn goal_status(accounting: &GoalAccounting) -> Result<String, CommandError> {
50    match accounting.get_active().await {
51        Ok(Some(g)) => {
52            let budget_line = g.token_budget.map_or_else(
53                || format!("  tokens used: {}", g.tokens_used),
54                |b| format!("  budget: {}/{b}", g.tokens_used),
55            );
56            Ok(format!(
57                "Active goal [{}]: {}\n  status: {}\n  turns: {}\n{}",
58                &g.id[..8],
59                g.text,
60                g.status,
61                g.turns_used,
62                budget_line
63            ))
64        }
65        Ok(None) => Ok("No active goal. Use `/goal create <text>` to set one.".to_owned()),
66        Err(e) => Ok(format!("Goal lookup failed: {e}")),
67    }
68}
69
70/// Returns `(display_message, auto_start_request)`.
71///
72/// `auto_start_request` is `Some((goal_id, goal_text, max_turns))` when `--auto` was passed and
73/// the goal was successfully created. The caller must relay this to `AutonomousDriver` via the
74/// `pending_start_arc` side-channel before the future resolves.
75async fn goal_create(
76    args: &str,
77    accounting: &GoalAccounting,
78    store: &GoalStore,
79    max_chars: usize,
80    default_budget: Option<u64>,
81    autonomous_enabled: bool,
82    autonomous_max_turns: u32,
83) -> Result<(String, Option<(String, String, u32)>), CommandError> {
84    let rest = args.strip_prefix("create").unwrap_or("").trim();
85
86    // Strip --auto / --turns before passing text to the budget parser.
87    let (stripped, is_auto, explicit_turns) = parse_auto_flags(rest);
88    let (text, explicit_budget) = parse_goal_create_args(&stripped);
89
90    if text.is_empty() {
91        return Ok((
92            "Usage: /goal create <text> [--budget N] [--auto [--turns N]]".to_owned(),
93            None,
94        ));
95    }
96    if is_auto && !autonomous_enabled {
97        return Ok((
98            "Autonomous mode is disabled. Set `[goals] autonomous_enabled = true` in config."
99                .to_owned(),
100            None,
101        ));
102    }
103    let budget = explicit_budget.or(default_budget.filter(|&b| b > 0));
104
105    let max_turns = explicit_turns
106        .unwrap_or(autonomous_max_turns)
107        .min(AUTONOMOUS_MAX_TURNS_CAP);
108    if explicit_turns.is_some_and(|t| t > AUTONOMOUS_MAX_TURNS_CAP) {
109        tracing::warn!(
110            requested = explicit_turns,
111            capped = AUTONOMOUS_MAX_TURNS_CAP,
112            "autonomous max_turns capped to {AUTONOMOUS_MAX_TURNS_CAP}"
113        );
114    }
115
116    match store.create(text, budget, max_chars).await {
117        Ok(g) => {
118            let _ = accounting.refresh().await;
119            let auto_start = if is_auto {
120                Some((g.id.clone(), g.text.clone(), max_turns))
121            } else {
122                None
123            };
124            let auto_note = if is_auto {
125                " Autonomous mode enabled — use `/goal clear` to stop."
126            } else {
127                ""
128            };
129            Ok((
130                format!("Goal created [{}]: {}{auto_note}", &g.id[..8], g.text),
131                auto_start,
132            ))
133        }
134        Err(crate::goal::store::GoalError::TextTooLong { max }) => Ok((
135            format!("Goal text exceeds {max} characters. Please shorten it."),
136            None,
137        )),
138        Err(e) => Ok((format!("Failed to create goal: {e}"), None)),
139    }
140}
141
142async fn goal_pause(
143    accounting: &GoalAccounting,
144    store: &GoalStore,
145) -> Result<String, CommandError> {
146    match accounting.get_active().await {
147        Ok(Some(g)) => {
148            match store
149                .transition(&g.id, crate::goal::GoalStatus::Paused, g.updated_at)
150                .await
151            {
152                Ok(_) => {
153                    let _ = accounting.refresh().await;
154                    Ok(format!("Goal [{}] paused.", &g.id[..8]))
155                }
156                Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
157                    let current = accounting.get_active().await.ok().flatten();
158                    Ok(format!(
159                        "Goal state changed concurrently. Current: {}",
160                        current.map_or_else(|| "none".into(), |g| g.status.to_string())
161                    ))
162                }
163                Err(e) => Ok(format!("Pause failed: {e}")),
164            }
165        }
166        Ok(None) => Ok("No active goal to pause.".to_owned()),
167        Err(e) => Ok(format!("Failed: {e}")),
168    }
169}
170
171async fn goal_resume(
172    accounting: &GoalAccounting,
173    store: &GoalStore,
174) -> Result<String, CommandError> {
175    let goals = store.list(10).await.unwrap_or_default();
176    let paused = goals
177        .into_iter()
178        .find(|g| g.status == crate::goal::GoalStatus::Paused);
179    match paused {
180        Some(g) => {
181            match store
182                .transition(&g.id, crate::goal::GoalStatus::Active, g.updated_at)
183                .await
184            {
185                Ok(_) => {
186                    let _ = accounting.refresh().await;
187                    Ok(format!("Goal [{}] resumed: {}", &g.id[..8], g.text))
188                }
189                Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
190                    Ok("Goal state changed concurrently — please retry.".to_owned())
191                }
192                Err(e) => Ok(format!("Resume failed: {e}")),
193            }
194        }
195        None => Ok("No paused goal to resume.".to_owned()),
196    }
197}
198
199async fn goal_complete(
200    accounting: &GoalAccounting,
201    store: &GoalStore,
202) -> Result<String, CommandError> {
203    match accounting.get_active().await {
204        Ok(Some(g)) => {
205            match store
206                .transition(&g.id, crate::goal::GoalStatus::Completed, g.updated_at)
207                .await
208            {
209                Ok(_) => {
210                    let _ = accounting.refresh().await;
211                    Ok(format!("Goal [{}] marked complete.", &g.id[..8]))
212                }
213                Err(e) => Ok(format!("Complete failed: {e}")),
214            }
215        }
216        Ok(None) => Ok("No active goal.".to_owned()),
217        Err(e) => Ok(format!("Failed: {e}")),
218    }
219}
220
221async fn goal_clear(
222    accounting: &GoalAccounting,
223    store: &GoalStore,
224) -> Result<String, CommandError> {
225    let goals = store.list(10).await.unwrap_or_default();
226    let target = goals.into_iter().find(|g| {
227        g.status == crate::goal::GoalStatus::Active || g.status == crate::goal::GoalStatus::Paused
228    });
229    match target {
230        Some(g) => {
231            match store
232                .transition(&g.id, crate::goal::GoalStatus::Cleared, g.updated_at)
233                .await
234            {
235                Ok(_) => {
236                    let _ = accounting.refresh().await;
237                    Ok(format!("Goal [{}] cleared.", &g.id[..8]))
238                }
239                Err(e) => Ok(format!("Clear failed: {e}")),
240            }
241        }
242        None => Ok("No active or paused goal to clear.".to_owned()),
243    }
244}
245
246async fn goal_list(store: &GoalStore) -> Result<String, CommandError> {
247    let goals = store.list(20).await.unwrap_or_default();
248    if goals.is_empty() {
249        return Ok("No goals recorded.".to_owned());
250    }
251    let mut out = String::from("Goals:\n");
252    for g in goals {
253        let _ = std::fmt::Write::write_fmt(
254            &mut out,
255            format_args!(
256                "  {} [{}] {} — {} turns\n",
257                g.status.badge_symbol(),
258                &g.id[..8],
259                g.text,
260                g.turns_used
261            ),
262        );
263    }
264    Ok(out.trim_end().to_owned())
265}
266
267fn parse_goal_create_args(args: &str) -> (&str, Option<u64>) {
268    if let Some(pos) = args.find("--budget") {
269        let text = args[..pos].trim();
270        let rest = args[pos + "--budget".len()..].trim();
271        let budget = rest
272            .split_whitespace()
273            .next()
274            .and_then(|s| s.parse::<u64>().ok());
275        (text, budget)
276    } else {
277        (args, None)
278    }
279}
280
281/// Parse `--auto` and `--turns N` flags from the remainder of a `/goal create` argument string.
282///
283/// Returns `(text_without_auto_flags, is_auto, explicit_turns)`.
284fn parse_auto_flags(args: &str) -> (String, bool, Option<u32>) {
285    let mut is_auto = false;
286    let mut turns: Option<u32> = None;
287    let mut text_words: Vec<&str> = Vec::new();
288    let mut words = args.split_whitespace();
289
290    while let Some(w) = words.next() {
291        if w == "--auto" {
292            is_auto = true;
293        } else if w == "--turns" {
294            turns = words.next().and_then(|n| n.parse::<u32>().ok());
295        } else {
296            text_words.push(w);
297        }
298    }
299
300    (text_words.join(" "), is_auto, turns)
301}
302
303impl<C: Channel + Send + 'static> TrackingAccess for Agent<C> {
304    // ----- /trajectory -----
305
306    fn handle_trajectory(&mut self, args: &str) -> String {
307        self.handle_trajectory_command_as_string(args)
308    }
309
310    // ----- /scope -----
311
312    fn handle_scope(&self, args: &str) -> String {
313        self.handle_scope_command_as_string(args)
314    }
315
316    // ----- /goal -----
317
318    fn handle_goal<'a>(
319        &'a mut self,
320        args: &'a str,
321    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
322        // Extract all non-Send data synchronously before entering the async block.
323        if self.services.goal_accounting.is_none() {
324            if !self.runtime.config.goals.enabled {
325                return Box::pin(async {
326                    Ok("Goals are disabled. Set `[goals] enabled = true` in config.".to_owned())
327                });
328            }
329            let pool = match self.services.memory.persistence.memory.as_ref() {
330                Some(m) => std::sync::Arc::new(m.sqlite().pool().clone()),
331                None => {
332                    return Box::pin(async {
333                        Ok("Goals require a database backend (memory not configured).".to_owned())
334                    });
335                }
336            };
337            let store = std::sync::Arc::new(crate::goal::GoalStore::new(pool));
338            let accounting = std::sync::Arc::new(crate::goal::GoalAccounting::new(store));
339            self.services.goal_accounting = Some(accounting);
340        }
341
342        let accounting =
343            self.services.goal_accounting.clone().expect(
344                "invariant: goal_accounting is always Some at this point (initialized above)",
345            );
346        let max_chars = self.runtime.config.goals.max_text_chars;
347        let default_budget = self.runtime.config.goals.default_token_budget;
348        let autonomous_enabled = self.runtime.config.goals.autonomous_enabled;
349        let autonomous_max_turns = self.runtime.config.goals.autonomous_max_turns;
350        let args_owned = args.to_owned();
351
352        // S1: `goal_create` may need to arm `AutonomousDriver` with a new session.
353        // We capture a clone of the pending_start Arc that lives on the driver.
354        // The async block fills it; the main agent loop (which has `&mut self`) drains it
355        // via `AutonomousDriver::flush_pending_start()` after each command handler returns.
356        let pending_start_arc = std::sync::Arc::clone(&self.services.autonomous.pending_start_arc);
357
358        Box::pin(async move {
359            let _ = accounting.refresh().await;
360            let store = accounting.get_store();
361            let args = args_owned.as_str();
362
363            match args {
364                "" | "status" => goal_status(&accounting).await,
365                "pause" => goal_pause(&accounting, &store).await,
366                "resume" => goal_resume(&accounting, &store).await,
367                "complete" => goal_complete(&accounting, &store).await,
368                "clear" => goal_clear(&accounting, &store).await,
369                "list" => goal_list(&store).await,
370                _ if args.starts_with("create") => {
371                    let (msg, auto_req) = goal_create(
372                        args,
373                        &accounting,
374                        &store,
375                        max_chars,
376                        default_budget,
377                        autonomous_enabled,
378                        autonomous_max_turns,
379                    )
380                    .await?;
381                    if let Some(req) = auto_req {
382                        *pending_start_arc.lock() = Some(req);
383                    }
384                    Ok(msg)
385                }
386                _ => Ok(
387                    "Unknown /goal subcommand. Try: create, pause, resume, complete, clear, status, list."
388                        .to_owned(),
389                ),
390            }
391        })
392    }
393
394    fn active_goal_snapshot(&self) -> Option<zeph_commands::GoalSnapshot> {
395        let accounting = self.services.goal_accounting.as_ref()?;
396        let snap = accounting.snapshot()?;
397        Some(zeph_commands::GoalSnapshot {
398            id: snap.id,
399            text: snap.text,
400            status: match snap.status {
401                crate::goal::GoalStatus::Active => zeph_commands::GoalStatusView::Active,
402                crate::goal::GoalStatus::Paused => zeph_commands::GoalStatusView::Paused,
403                crate::goal::GoalStatus::Completed => zeph_commands::GoalStatusView::Completed,
404                crate::goal::GoalStatus::Cleared => zeph_commands::GoalStatusView::Cleared,
405            },
406            turns_used: snap.turns_used,
407            tokens_used: snap.tokens_used,
408            token_budget: snap.token_budget,
409        })
410    }
411}