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/// Agent configuration
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct AgentConfig {
98    #[serde(default)]
99    /// Metadata that describes the agent.
100    pub metadata: AgentMetadata,
101    /// Whether this agent is enabled for scheduling.
102    /// Disabled agents are skipped during task dispatch.
103    #[serde(default = "default_true")]
104    pub enabled: bool,
105    #[serde(default)]
106    /// Capabilities advertised by the agent.
107    pub capabilities: Vec<String>,
108    /// Command to execute (must contain {prompt} placeholder)
109    #[serde(default)]
110    pub command: String,
111    #[serde(default)]
112    /// Agent-selection policy and weights.
113    pub selection: AgentSelectionConfig,
114    /// Environment variable entries (direct values and store references).
115    /// Resolution happens at runtime via `resolve_agent_env()`.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub env: Option<Vec<AgentEnvEntry>>,
118    /// How the rendered prompt is delivered to the agent process.
119    #[serde(default, skip_serializing_if = "PromptDelivery::is_default")]
120    pub prompt_delivery: PromptDelivery,
121    /// Health/disease policy overrides for this agent.
122    #[serde(default, skip_serializing_if = "HealthPolicyConfig::is_default")]
123    pub health_policy: HealthPolicyConfig,
124}
125
126fn default_true() -> bool {
127    true
128}
129
130impl AgentConfig {
131    /// Creates an empty enabled agent configuration with defaults.
132    pub fn new() -> Self {
133        Self {
134            metadata: AgentMetadata::default(),
135            enabled: true,
136            capabilities: Vec::new(),
137            command: String::new(),
138            selection: AgentSelectionConfig::default(),
139            env: None,
140            prompt_delivery: PromptDelivery::default(),
141            health_policy: HealthPolicyConfig::default(),
142        }
143    }
144
145    /// Returns `true` when the agent advertises the requested capability.
146    pub fn supports_capability(&self, capability: &str) -> bool {
147        self.capabilities.contains(&capability.to_string())
148    }
149}
150
151impl Default for AgentConfig {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157/// Agent selection configuration.
158#[derive(Debug, Clone, Serialize, Deserialize, Default)]
159pub struct AgentSelectionConfig {
160    /// Candidate-selection strategy.
161    #[serde(default = "default_selection_strategy")]
162    pub strategy: SelectionStrategy,
163    /// Optional scoring weights for adaptive selection.
164    #[serde(default)]
165    pub weights: Option<SelectionWeights>,
166}
167
168fn default_selection_strategy() -> SelectionStrategy {
169    SelectionStrategy::CapabilityAware
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_agent_config_default_and_new() {
178        let cfg = AgentConfig::default();
179        assert!(cfg.capabilities.is_empty());
180        assert!(cfg.command.is_empty());
181        assert_eq!(cfg.metadata.name, "");
182        assert!(cfg.metadata.description.is_none());
183        assert!(cfg.metadata.version.is_none());
184        assert!(cfg.metadata.cost.is_none());
185        assert_eq!(cfg.prompt_delivery, PromptDelivery::Arg);
186
187        let cfg2 = AgentConfig::new();
188        assert!(cfg2.capabilities.is_empty());
189        assert_eq!(cfg2.prompt_delivery, PromptDelivery::Arg);
190    }
191
192    #[test]
193    fn prompt_delivery_default_is_arg() {
194        assert_eq!(PromptDelivery::default(), PromptDelivery::Arg);
195        assert!(PromptDelivery::Arg.is_default());
196        assert!(!PromptDelivery::Stdin.is_default());
197        assert!(!PromptDelivery::File.is_default());
198        assert!(!PromptDelivery::Env.is_default());
199    }
200
201    #[test]
202    fn prompt_delivery_serde_roundtrip() {
203        for (variant, expected_str) in [
204            (PromptDelivery::Stdin, "\"stdin\""),
205            (PromptDelivery::File, "\"file\""),
206            (PromptDelivery::Env, "\"env\""),
207            (PromptDelivery::Arg, "\"arg\""),
208        ] {
209            let json = serde_json::to_string(&variant).unwrap();
210            assert_eq!(json, expected_str);
211            let deserialized: PromptDelivery = serde_json::from_str(&json).unwrap();
212            assert_eq!(deserialized, variant);
213        }
214    }
215
216    #[test]
217    fn prompt_delivery_skip_serializing_default() {
218        let cfg = AgentConfig::new();
219        let json = serde_json::to_string(&cfg).unwrap();
220        assert!(
221            !json.contains("prompt_delivery"),
222            "default Arg should be omitted"
223        );
224
225        let mut cfg2 = AgentConfig::new();
226        cfg2.prompt_delivery = PromptDelivery::Stdin;
227        let json2 = serde_json::to_string(&cfg2).unwrap();
228        assert!(
229            json2.contains("prompt_delivery"),
230            "non-default should be present"
231        );
232    }
233
234    #[test]
235    fn test_agent_supports_capability() {
236        let mut agent = AgentConfig::new();
237        agent.capabilities = vec!["plan".to_string(), "qa".to_string()];
238        assert!(agent.supports_capability("plan"));
239        assert!(agent.supports_capability("qa"));
240        assert!(!agent.supports_capability("fix"));
241    }
242
243    #[test]
244    fn test_agent_command_field() {
245        let mut agent = AgentConfig::new();
246        agent.command = "glmcode -p \"{prompt}\"".to_string();
247        assert!(agent.command.contains("{prompt}"));
248    }
249
250    #[test]
251    fn test_agent_selection_config_default() {
252        let cfg = AgentSelectionConfig::default();
253        assert!(cfg.weights.is_none());
254    }
255}