Skip to main content

apollo/agent/
mode.rs

1//! Agent execution modes — coding mode, swarm mode, and plan approval.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, Instant};
7
8use async_trait::async_trait;
9use tokio::sync::mpsc;
10
11use crate::channels::{Channel, IncomingMessage, OutgoingMessage};
12
13/// Agent execution mode.
14#[derive(Debug, Clone, Default)]
15pub enum AgentMode {
16    /// Auto: heuristic-based classification (default).
17    ///
18    /// Short conversational messages skip planning. Longer or tool-requiring
19    /// messages go through the Planning → Executing → Summarizing state machine.
20    #[default]
21    Auto,
22
23    /// Bypass all permission checks and approval steps — agent runs fully autonomously.
24    ///
25    /// Equivalent to Claude Code's `--dangerously-skip-permissions`. The agent:
26    /// - Never pauses for plan approval
27    /// - Executes all tool calls without confirmation
28    /// - Defaults to the heavy model (Opus) for maximum capability
29    /// - Always plans before executing
30    ///
31    /// Only enable this in trusted, sandboxed environments or when you explicitly
32    /// want autonomous unattended execution.
33    BypassPermissions,
34
35    /// Coding mode: optimized for software development.
36    ///
37    /// - Always plans before executing
38    /// - Prefers the heavy model (Opus) for execution
39    /// - Injects coding-specific rules into the system prompt
40    /// - When `plan_approval` is enabled, previews the plan to the user
41    ///   and waits for explicit confirmation before executing
42    Coding {
43        plan_approval: bool,
44        project_path: Option<PathBuf>,
45    },
46
47    /// Swarm mode: deploy parallel coding agents.
48    ///
49    /// The planner decomposes the task, then spawns up to `parallelism`
50    /// concurrent AgentRunner workers. Results are merged into a summary.
51    Swarm { parallelism: usize },
52}
53
54/// Maps stored `agent.permission_profile` to the initial runtime mode.
55pub fn agent_mode_from_permission_profile(profile: &str) -> AgentMode {
56    let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
57    match p.as_str() {
58        "full" => AgentMode::BypassPermissions,
59        "prompt" => AgentMode::Coding {
60            plan_approval: true,
61            project_path: None,
62        },
63        _ => AgentMode::Auto,
64    }
65}
66
67impl AgentMode {
68    pub fn is_coding(&self) -> bool {
69        matches!(self, Self::Coding { .. })
70    }
71
72    pub fn is_swarm(&self) -> bool {
73        matches!(self, Self::Swarm { .. })
74    }
75
76    /// Whether all permission checks and approval steps should be skipped.
77    /// True for `BypassPermissions` and `Coding { plan_approval: false }`.
78    pub fn bypass_permissions(&self) -> bool {
79        matches!(self, Self::BypassPermissions)
80    }
81
82    /// Whether to show a plan preview and wait for user approval before executing.
83    /// Always false in `BypassPermissions` mode even if `plan_approval` was set.
84    pub fn plan_approval(&self) -> bool {
85        if self.bypass_permissions() {
86            return false;
87        }
88        matches!(
89            self,
90            Self::Coding {
91                plan_approval: true,
92                ..
93            }
94        )
95    }
96
97    pub fn swarm_parallelism(&self) -> usize {
98        match self {
99            Self::Swarm { parallelism } => *parallelism,
100            _ => 1,
101        }
102    }
103
104    /// System prompt section to inject when this mode is active.
105    pub fn system_prompt_injection(&self) -> Option<&'static str> {
106        match self {
107            Self::BypassPermissions => Some(BYPASS_PERMISSIONS_PROMPT),
108            Self::Coding { .. } => Some(CODING_MODE_PROMPT),
109            Self::Swarm { .. } => Some(SWARM_MODE_PROMPT),
110            Self::Auto => None,
111        }
112    }
113
114    /// Whether to prefer the heavy model for execution.
115    pub fn prefer_heavy_model(&self) -> bool {
116        matches!(
117            self,
118            Self::BypassPermissions | Self::Coding { .. } | Self::Swarm { .. }
119        )
120    }
121
122    /// Whether to always plan (skip the conversational shortcut).
123    pub fn always_plan(&self) -> bool {
124        matches!(
125            self,
126            Self::BypassPermissions | Self::Coding { .. } | Self::Swarm { .. }
127        )
128    }
129}
130
131/// A plan waiting for user approval before execution.
132#[derive(Debug, Clone)]
133pub struct PendingPlan {
134    pub plan: String,
135    pub original_message: String,
136    pub preferred_model: String,
137    pub created_at: Instant,
138}
139
140impl PendingPlan {
141    const TTL: Duration = Duration::from_secs(30 * 60); // 30 minutes
142
143    pub fn is_expired(&self) -> bool {
144        self.created_at.elapsed() > Self::TTL
145    }
146}
147
148pub type PendingPlans = Arc<Mutex<HashMap<String, PendingPlan>>>;
149
150/// Returns true if `text` is an explicit approval ("go", "yes", "ok", etc.)
151pub fn is_approval(text: &str) -> bool {
152    let lower = text.trim().to_lowercase();
153    matches!(
154        lower.as_str(),
155        "yes"
156            | "y"
157            | "go"
158            | "ok"
159            | "okay"
160            | "proceed"
161            | "approve"
162            | "do it"
163            | "looks good"
164            | "lgtm"
165            | "go ahead"
166            | "continue"
167            | "run it"
168            | "execute"
169    ) || lower.starts_with("yes,")
170        || lower.starts_with("go ahead")
171        || lower.starts_with("looks good")
172        || lower.starts_with("lgtm")
173}
174
175/// Returns true if `text` is an explicit rejection ("no", "cancel", "abort", etc.)
176pub fn is_rejection(text: &str) -> bool {
177    let lower = text.trim().to_lowercase();
178    matches!(
179        lower.as_str(),
180        "no" | "n" | "nope" | "cancel" | "abort" | "stop" | "nevermind" | "never mind"
181    ) || lower.starts_with("no,")
182        || lower.starts_with("cancel")
183        || lower.starts_with("stop ")
184}
185
186/// No-op channel for headless swarm worker agents.
187/// All operations are discarded — only the return value of `handle_message` is used.
188pub struct NullChannel {
189    name: String,
190}
191
192impl NullChannel {
193    pub fn new(name: impl Into<String>) -> Self {
194        Self { name: name.into() }
195    }
196}
197
198#[async_trait]
199impl Channel for NullChannel {
200    fn name(&self) -> &str {
201        &self.name
202    }
203
204    async fn start(&mut self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>> {
205        let (_tx, rx) = mpsc::channel(1);
206        Ok(rx) // tx dropped immediately — receiver never yields messages
207    }
208
209    async fn send(&self, _message: OutgoingMessage) -> anyhow::Result<Option<String>> {
210        Ok(None)
211    }
212
213    async fn stop(&mut self) -> anyhow::Result<()> {
214        Ok(())
215    }
216}
217
218const BYPASS_PERMISSIONS_PROMPT: &str = "\
219## Autonomous Mode (Bypass Permissions)
220
221You are running with all permission checks disabled. You have full autonomy to:
222- Execute shell commands without confirmation
223- Read and write any files in the workspace
224- Make and push commits autonomously
225- Install dependencies and run builds
226
227Operate decisively. Do not ask for permission before taking actions. \
228Complete the task end-to-end and report what was done.";
229
230const CODING_MODE_PROMPT: &str = "\
231## Coding Mode Active
232
233You are in coding mode. Follow these rules:
234- Read files before editing them
235- Make surgical, minimal changes — don't reformat code you don't touch
236- After edits, verify correctness (cargo build, npm test, or equivalent)
237- Prefer targeted `edit` operations over full file rewrites
238- For multi-file changes: enumerate all affected files first, then execute in order
239- Diagnose root causes before retrying failures
240- Commit at natural checkpoints with clear, descriptive messages
241- Never break working code — test incrementally";
242
243const SWARM_MODE_PROMPT: &str = "\
244## Swarm Deployment Mode Active
245
246You are coordinating parallel coding agents. Follow these rules:
247- Decompose the goal into independent, non-overlapping subtasks
248- Assign each agent a clear, bounded scope (a file, module, or feature)
249- Avoid shared-state conflicts between agents
250- Validate and integrate each agent's output before merging
251- Reassign or handle failed tasks yourself
252- Report the outcome of each agent with status and a brief summary";
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_agent_mode_from_permission_profile() {
260        assert!(matches!(
261            agent_mode_from_permission_profile("full"),
262            AgentMode::BypassPermissions
263        ));
264        assert!(matches!(
265            agent_mode_from_permission_profile("  FULL  "),
266            AgentMode::BypassPermissions
267        ));
268        assert!(matches!(
269            agent_mode_from_permission_profile("prompt"),
270            AgentMode::Coding {
271                plan_approval: true,
272                project_path: None
273            }
274        ));
275        assert!(matches!(
276            agent_mode_from_permission_profile(" ProMpT\n"),
277            AgentMode::Coding {
278                plan_approval: true,
279                project_path: None
280            }
281        ));
282        assert!(matches!(
283            agent_mode_from_permission_profile(""),
284            AgentMode::Auto
285        ));
286        assert!(matches!(
287            agent_mode_from_permission_profile("unknown-profile"),
288            AgentMode::Auto
289        ));
290    }
291
292    #[test]
293    fn test_is_approval() {
294        // Exact matches
295        assert!(is_approval("yes"));
296        assert!(is_approval("y"));
297        assert!(is_approval("go"));
298        assert!(is_approval("ok"));
299        assert!(is_approval("okay"));
300        assert!(is_approval("proceed"));
301        assert!(is_approval("approve"));
302        assert!(is_approval("do it"));
303        assert!(is_approval("looks good"));
304        assert!(is_approval("lgtm"));
305        assert!(is_approval("go ahead"));
306        assert!(is_approval("continue"));
307        assert!(is_approval("run it"));
308        assert!(is_approval("execute"));
309
310        // Case insensitivity
311        assert!(is_approval("YES"));
312        assert!(is_approval("OkAy"));
313        assert!(is_approval("PROCEED"));
314        assert!(is_approval("LGTM"));
315
316        // Leading/trailing whitespace
317        assert!(is_approval("  yes  "));
318        assert!(is_approval("\tgo ahead\n"));
319
320        // Prefix matches
321        assert!(is_approval("yes, do it"));
322        assert!(is_approval("go ahead and run"));
323        assert!(is_approval("looks good to me"));
324        assert!(is_approval("lgtm, go for it"));
325
326        // Negative cases
327        assert!(!is_approval("no"));
328        assert!(!is_approval("yep"));
329        assert!(!is_approval("sure"));
330        assert!(!is_approval(""));
331        assert!(!is_approval("   "));
332        assert!(!is_approval("go ahea")); // not an exact match or prefix
333    }
334
335    #[test]
336    fn test_is_rejection() {
337        // Exact matches
338        assert!(is_rejection("no"));
339        assert!(is_rejection("n"));
340        assert!(is_rejection("nope"));
341        assert!(is_rejection("cancel"));
342        assert!(is_rejection("abort"));
343        assert!(is_rejection("stop"));
344        assert!(is_rejection("nevermind"));
345        assert!(is_rejection("never mind"));
346
347        // Case insensitivity
348        assert!(is_rejection("NO"));
349        assert!(is_rejection("Cancel"));
350        assert!(is_rejection("ABORT"));
351        assert!(is_rejection("STOP"));
352
353        // Leading/trailing whitespace
354        assert!(is_rejection("  no  "));
355        assert!(is_rejection("\tabort\n"));
356
357        // Prefix matches
358        assert!(is_rejection("no, don't do that"));
359        assert!(is_rejection("cancel the operation"));
360        assert!(is_rejection("stop executing"));
361
362        // Negative cases
363        assert!(!is_rejection("yes"));
364        assert!(!is_rejection("not really"));
365        assert!(!is_rejection(""));
366        assert!(!is_rejection("   "));
367        assert!(!is_rejection("stopit")); // starts_with("stop ") has a space
368    }
369}