Skip to main content

devflow_core/agents/
hermes.rs

1//! Hermes coding-agent adapter (Phase 42).
2//!
3//! Launches `hermes -z "<prompt>" --yolo --accept-hooks` in headless-safe oneshot mode.
4//! The prompt is passed via `-z`. Environment variable `HERMES_ACCEPT_HOOKS=1` is injected
5//! to avoid interactive prompts on shell hooks.
6//!
7//! Slash commands (`/gsd-*`) are rendered via standard claude-style prompt rendering.
8//! Subagent dispatch capability is dynamically probed via `hermes tools list` checking for
9//! the enabled `delegation` toolset.
10
11use super::AgentDriver;
12use crate::phase_id::PhaseId;
13use std::path::PathBuf;
14
15/// The modular driver for Hermes (Phase 42): headless `-z` oneshot launch,
16/// `HERMES_ACCEPT_HOOKS=1` environment, standard claude-style prompt rendering,
17/// and dynamic delegation subagent dispatch probing.
18pub struct HermesDriver;
19
20impl AgentDriver for HermesDriver {
21    fn name(&self) -> &'static str {
22        "Hermes"
23    }
24
25    fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String {
26        crate::prompt::render_claude_style(intent)
27    }
28
29    fn capabilities(&self) -> super::DriverCapabilities {
30        super::DriverCapabilities {
31            subagent_dispatch: hermes_subagent_dispatch_available(),
32        }
33    }
34
35    fn build_command(
36        &self,
37        _phase: PhaseId,
38        prompt: &str,
39        _extra_writable_roots: &[PathBuf],
40    ) -> (&'static str, Vec<String>) {
41        (
42            "hermes",
43            vec![
44                "-z".into(),
45                prompt.to_string(),
46                "--yolo".into(),
47                "--accept-hooks".into(),
48            ],
49        )
50    }
51
52    fn environment(&self) -> Vec<(String, String)> {
53        vec![("HERMES_ACCEPT_HOOKS".into(), "1".into())]
54    }
55
56    fn health(&self, _state: &crate::state::State) -> Result<(), String> {
57        // Presence-only probe of the `hermes` binary.
58        let output = std::process::Command::new("hermes")
59            .arg("--version")
60            .output()
61            .map_err(|e| format!("could not run `hermes --version`: {e}"))?;
62        if output.status.success() {
63            Ok(())
64        } else {
65            let detail = String::from_utf8_lossy(&output.stderr);
66            Err(format!("`hermes --version` failed: {}", detail.trim()))
67        }
68    }
69}
70
71/// Dynamically probe whether Hermes has the `delegation` toolset enabled.
72///
73/// Runs `hermes tools list` and checks for both `enabled` and `delegation` in the output.
74pub fn hermes_subagent_dispatch_available() -> bool {
75    hermes_subagent_dispatch_available_with(|| {
76        std::process::Command::new("hermes")
77            .args(["tools", "list"])
78            .output()
79    })
80}
81
82/// Inner helper parameterized on output function for unit testing without invoking real CLI.
83pub fn hermes_subagent_dispatch_available_with(
84    output_fn: impl FnOnce() -> Result<std::process::Output, std::io::Error>,
85) -> bool {
86    let Ok(output) = output_fn() else {
87        return false;
88    };
89    if !output.status.success() {
90        return false;
91    }
92    let stdout = String::from_utf8_lossy(&output.stdout);
93    parse_hermes_tools_list_for_delegation(&stdout)
94}
95
96/// Parse `hermes tools list` stdout to check if delegation toolset is enabled.
97pub fn parse_hermes_tools_list_for_delegation(stdout: &str) -> bool {
98    for line in stdout.lines() {
99        let lower = line.to_ascii_lowercase();
100        if lower.contains("delegation")
101            && lower.contains("enabled")
102            && !lower.contains("disabled")
103            && !lower.contains("not enabled")
104        {
105            return true;
106        }
107    }
108    false
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::phase_id::PhaseId;
115    use crate::stage::Stage;
116    use std::os::unix::process::ExitStatusExt;
117
118    #[test]
119    fn hermes_driver_name() {
120        let driver = HermesDriver;
121        assert_eq!(driver.name(), "Hermes");
122    }
123
124    #[test]
125    fn hermes_driver_build_command() {
126        let driver = HermesDriver;
127        let (prog, args) = driver.build_command(PhaseId::new(42), "test prompt", &[]);
128        assert_eq!(prog, "hermes");
129        assert_eq!(
130            args,
131            vec![
132                "-z".to_string(),
133                "test prompt".to_string(),
134                "--yolo".to_string(),
135                "--accept-hooks".to_string(),
136            ]
137        );
138    }
139
140    #[test]
141    fn hermes_driver_environment() {
142        let driver = HermesDriver;
143        let envs = driver.environment();
144        assert_eq!(envs, vec![("HERMES_ACCEPT_HOOKS".into(), "1".into())]);
145    }
146
147    #[test]
148    fn hermes_driver_render_prompt() {
149        let driver = HermesDriver;
150        let intent = crate::prompt::StageIntent::for_stage(Stage::Plan, PhaseId::new(42));
151        let rendered = driver.render_prompt(&intent);
152        assert!(rendered.contains("DEVFLOW_RESULT"));
153        assert!(rendered.contains("/gsd-plan-phase 42"));
154    }
155
156    #[test]
157    fn parse_hermes_tools_list_delegation_enabled() {
158        let sample = "\
159Available Toolsets:
160  ✓ enabled delegation 👥 Task Delegation
161  ✓ enabled terminal   💻 Terminal Execution
162  ✗ disabled web       🌐 Web Search
163";
164        assert!(parse_hermes_tools_list_for_delegation(sample));
165    }
166
167    #[test]
168    fn parse_hermes_tools_list_delegation_disabled() {
169        let sample = "\
170Available Toolsets:
171  ✗ disabled delegation 👥 Task Delegation
172  ✓ enabled terminal   💻 Terminal Execution
173";
174        assert!(!parse_hermes_tools_list_for_delegation(sample));
175    }
176
177    #[test]
178    fn parse_hermes_tools_list_missing_delegation() {
179        let sample = "\
180Available Toolsets:
181  ✓ enabled terminal   💻 Terminal Execution
182";
183        assert!(!parse_hermes_tools_list_for_delegation(sample));
184    }
185
186    #[test]
187    fn parse_hermes_tools_list_disabled_delegation_with_enabled_word() {
188        let sample = "\
189Available Toolsets:
190  ✗ disabled delegation 👥 Task Delegation (can be enabled in config)
191";
192        assert!(!parse_hermes_tools_list_for_delegation(sample));
193    }
194
195    #[test]
196    fn hermes_subagent_dispatch_with_mock() {
197        let success_output = || {
198            Ok(std::process::Output {
199                status: std::process::ExitStatus::from_raw(0),
200                stdout: b"  \xe2\x9c\x93 enabled delegation \xf0\x9f\x91\xa5 Task Delegation\n"
201                    .to_vec(),
202                stderr: Vec::new(),
203            })
204        };
205        assert!(hermes_subagent_dispatch_available_with(success_output));
206
207        let failure_output = || {
208            Ok(std::process::Output {
209                status: std::process::ExitStatus::from_raw(1 << 8),
210                stdout: b"error\n".to_vec(),
211                stderr: Vec::new(),
212            })
213        };
214        assert!(!hermes_subagent_dispatch_available_with(failure_output));
215
216        let io_error = || {
217            Err(std::io::Error::new(
218                std::io::ErrorKind::NotFound,
219                "not found",
220            ))
221        };
222        assert!(!hermes_subagent_dispatch_available_with(io_error));
223    }
224}