devflow_core/agents/
hermes.rs1use super::AgentDriver;
12use crate::phase_id::PhaseId;
13use std::path::PathBuf;
14
15pub 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 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
71pub 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
82pub 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
96pub 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}