Skip to main content

orchestrator_config/config/
agent.rs

1use crate::cli_types::AgentEnvEntry;
2use crate::selection::{SelectionStrategy, SelectionWeights};
3use serde::{Deserialize, Serialize};
4
5/// Configurable health/disease policy for an agent.
6///
7/// Controls how aggressively the scheduler marks agents as "diseased"
8/// (temporarily unhealthy) after consecutive infrastructure failures.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct HealthPolicyConfig {
11    /// Hours to keep an agent in "diseased" state after threshold is hit.
12    /// Set to 0 to disable disease entirely (agent always stays healthy).
13    #[serde(default = "default_disease_duration_hours")]
14    pub disease_duration_hours: u64,
15
16    /// Number of consecutive infrastructure failures before marking diseased.
17    #[serde(default = "default_disease_threshold")]
18    pub disease_threshold: u32,
19
20    /// Minimum per-capability success rate to remain schedulable while diseased.
21    #[serde(default = "default_capability_success_threshold")]
22    pub capability_success_threshold: f64,
23}
24
25fn default_disease_duration_hours() -> u64 {
26    5
27}
28
29fn default_disease_threshold() -> u32 {
30    2
31}
32
33fn default_capability_success_threshold() -> f64 {
34    0.5
35}
36
37impl Default for HealthPolicyConfig {
38    fn default() -> Self {
39        Self {
40            disease_duration_hours: default_disease_duration_hours(),
41            disease_threshold: default_disease_threshold(),
42            capability_success_threshold: default_capability_success_threshold(),
43        }
44    }
45}
46
47impl HealthPolicyConfig {
48    /// Returns `true` when all fields match the global defaults.
49    pub fn is_default(&self) -> bool {
50        *self == Self::default()
51    }
52}
53
54/// How the rendered prompt reaches the agent process.
55///
56/// - `Stdin`: prompt written to child stdin fd (zero shell risk)
57/// - `File`: prompt written to temp file, `{prompt_file}` placeholder in command (near-zero risk)
58/// - `Env`: prompt passed as `ORCH_PROMPT` env var (low risk)
59/// - `Arg`: `{prompt}` substitution in shell command (requires shell_escape, default)
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
61#[serde(rename_all = "snake_case")]
62pub enum PromptDelivery {
63    /// Write the rendered prompt to stdin.
64    Stdin,
65    /// Write the rendered prompt to a temporary file.
66    File,
67    /// Pass the rendered prompt via environment variable.
68    Env,
69    /// Substitute the rendered prompt into the command arguments.
70    #[default]
71    Arg,
72}
73
74impl PromptDelivery {
75    /// Returns `true` when this is the default prompt-delivery mode.
76    pub fn is_default(&self) -> bool {
77        *self == Self::Arg
78    }
79}
80
81/// Agent metadata
82#[derive(Debug, Clone, Serialize, Deserialize, Default)]
83pub struct AgentMetadata {
84    /// Stable agent name.
85    pub name: String,
86    /// Optional human-readable description.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub description: Option<String>,
89    /// Optional agent version string.
90    pub version: Option<String>,
91    /// Optional static cost hint.
92    pub cost: Option<u8>,
93}
94
95/// A conditional command rule evaluated via CEL at step execution time.
96///
97/// When an agent has `command_rules`, each rule is evaluated in order before
98/// falling back to the default `command`. The first rule whose `when` expression
99/// evaluates to `true` provides the command template for that step invocation.
100#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
101pub struct AgentCommandRule {
102    /// CEL expression that must evaluate to `true` for this rule to match.
103    pub when: String,
104    /// Command template to use when the rule matches.
105    pub command: String,
106}
107
108/// Agent configuration
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct AgentConfig {
111    #[serde(default)]
112    /// Metadata that describes the agent.
113    pub metadata: AgentMetadata,
114    /// Whether this agent is enabled for scheduling.
115    /// Disabled agents are skipped during task dispatch.
116    #[serde(default = "default_true")]
117    pub enabled: bool,
118    #[serde(default)]
119    /// Capabilities advertised by the agent.
120    pub capabilities: Vec<String>,
121    /// Command to execute (must contain {prompt} placeholder)
122    #[serde(default)]
123    pub command: String,
124    /// Conditional command rules evaluated in order via CEL.
125    /// First matching rule's command is used; falls back to `command` if none match.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub command_rules: Vec<AgentCommandRule>,
128    #[serde(default)]
129    /// Agent-selection policy and weights.
130    pub selection: AgentSelectionConfig,
131    /// Environment variable entries (direct values and store references).
132    /// Resolution happens at runtime via `resolve_agent_env()`.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub env: Option<Vec<AgentEnvEntry>>,
135    /// How the rendered prompt is delivered to the agent process.
136    #[serde(default, skip_serializing_if = "PromptDelivery::is_default")]
137    pub prompt_delivery: PromptDelivery,
138    /// Health/disease policy overrides for this agent.
139    #[serde(default, skip_serializing_if = "HealthPolicyConfig::is_default")]
140    pub health_policy: HealthPolicyConfig,
141}
142
143fn default_true() -> bool {
144    true
145}
146
147impl AgentConfig {
148    /// Creates an empty enabled agent configuration with defaults.
149    pub fn new() -> Self {
150        Self {
151            metadata: AgentMetadata::default(),
152            enabled: true,
153            capabilities: Vec::new(),
154            command: String::new(),
155            command_rules: Vec::new(),
156            selection: AgentSelectionConfig::default(),
157            env: None,
158            prompt_delivery: PromptDelivery::default(),
159            health_policy: HealthPolicyConfig::default(),
160        }
161    }
162
163    /// Returns `true` when the agent advertises the requested capability.
164    pub fn supports_capability(&self, capability: &str) -> bool {
165        self.capabilities.contains(&capability.to_string())
166    }
167}
168
169impl Default for AgentConfig {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175/// Agent selection configuration.
176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
177pub struct AgentSelectionConfig {
178    /// Candidate-selection strategy.
179    #[serde(default = "default_selection_strategy")]
180    pub strategy: SelectionStrategy,
181    /// Optional scoring weights for adaptive selection.
182    #[serde(default)]
183    pub weights: Option<SelectionWeights>,
184}
185
186fn default_selection_strategy() -> SelectionStrategy {
187    SelectionStrategy::CapabilityAware
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_agent_config_default_and_new() {
196        let cfg = AgentConfig::default();
197        assert!(cfg.capabilities.is_empty());
198        assert!(cfg.command.is_empty());
199        assert_eq!(cfg.metadata.name, "");
200        assert!(cfg.metadata.description.is_none());
201        assert!(cfg.metadata.version.is_none());
202        assert!(cfg.metadata.cost.is_none());
203        assert_eq!(cfg.prompt_delivery, PromptDelivery::Arg);
204
205        let cfg2 = AgentConfig::new();
206        assert!(cfg2.capabilities.is_empty());
207        assert_eq!(cfg2.prompt_delivery, PromptDelivery::Arg);
208    }
209
210    #[test]
211    fn prompt_delivery_default_is_arg() {
212        assert_eq!(PromptDelivery::default(), PromptDelivery::Arg);
213        assert!(PromptDelivery::Arg.is_default());
214        assert!(!PromptDelivery::Stdin.is_default());
215        assert!(!PromptDelivery::File.is_default());
216        assert!(!PromptDelivery::Env.is_default());
217    }
218
219    #[test]
220    fn prompt_delivery_serde_roundtrip() {
221        for (variant, expected_str) in [
222            (PromptDelivery::Stdin, "\"stdin\""),
223            (PromptDelivery::File, "\"file\""),
224            (PromptDelivery::Env, "\"env\""),
225            (PromptDelivery::Arg, "\"arg\""),
226        ] {
227            let json = serde_json::to_string(&variant).unwrap();
228            assert_eq!(json, expected_str);
229            let deserialized: PromptDelivery = serde_json::from_str(&json).unwrap();
230            assert_eq!(deserialized, variant);
231        }
232    }
233
234    #[test]
235    fn prompt_delivery_skip_serializing_default() {
236        let cfg = AgentConfig::new();
237        let json = serde_json::to_string(&cfg).unwrap();
238        assert!(
239            !json.contains("prompt_delivery"),
240            "default Arg should be omitted"
241        );
242
243        let mut cfg2 = AgentConfig::new();
244        cfg2.prompt_delivery = PromptDelivery::Stdin;
245        let json2 = serde_json::to_string(&cfg2).unwrap();
246        assert!(
247            json2.contains("prompt_delivery"),
248            "non-default should be present"
249        );
250    }
251
252    #[test]
253    fn test_agent_supports_capability() {
254        let mut agent = AgentConfig::new();
255        agent.capabilities = vec!["plan".to_string(), "qa".to_string()];
256        assert!(agent.supports_capability("plan"));
257        assert!(agent.supports_capability("qa"));
258        assert!(!agent.supports_capability("fix"));
259    }
260
261    #[test]
262    fn test_agent_command_field() {
263        let mut agent = AgentConfig::new();
264        agent.command = "glmcode -p \"{prompt}\"".to_string();
265        assert!(agent.command.contains("{prompt}"));
266    }
267
268    #[test]
269    fn test_agent_selection_config_default() {
270        let cfg = AgentSelectionConfig::default();
271        assert!(cfg.weights.is_none());
272    }
273
274    #[test]
275    fn command_rules_default_empty() {
276        let cfg = AgentConfig::new();
277        assert!(cfg.command_rules.is_empty());
278    }
279
280    #[test]
281    fn command_rules_serde_roundtrip() {
282        let mut cfg = AgentConfig::new();
283        cfg.command_rules = vec![AgentCommandRule {
284            when: "vars.loop_session_id != \"\"".to_string(),
285            command: "claude --resume {loop_session_id} -p \"{prompt}\"".to_string(),
286        }];
287        let json = serde_json::to_string(&cfg).unwrap();
288        assert!(json.contains("command_rules"));
289        assert!(json.contains("loop_session_id"));
290
291        let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
292        assert_eq!(deserialized.command_rules.len(), 1);
293        assert_eq!(
294            deserialized.command_rules[0].when,
295            cfg.command_rules[0].when
296        );
297        assert_eq!(
298            deserialized.command_rules[0].command,
299            cfg.command_rules[0].command
300        );
301    }
302
303    #[test]
304    fn command_rules_omitted_when_empty() {
305        let cfg = AgentConfig::new();
306        let json = serde_json::to_string(&cfg).unwrap();
307        assert!(
308            !json.contains("command_rules"),
309            "empty command_rules should be omitted"
310        );
311    }
312}