1use 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 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 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
46const 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
70async 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 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
281fn 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 fn handle_trajectory(&mut self, args: &str) -> String {
307 self.handle_trajectory_command_as_string(args)
308 }
309
310 fn handle_scope(&self, args: &str) -> String {
313 self.handle_scope_command_as_string(args)
314 }
315
316 fn handle_goal<'a>(
319 &'a mut self,
320 args: &'a str,
321 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
322 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 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}