Skip to main content

agent_orchestrator/resource/
agent.rs

1use crate::cli_types::{
2    AgentMetadataSpec, AgentSelectionSpec, AgentSpec, HealthPolicySpec, OrchestratorResource,
3    ResourceKind, ResourceSpec,
4};
5use crate::config::{
6    AgentConfig, AgentMetadata, AgentSelectionConfig, HealthPolicyConfig, OrchestratorConfig,
7    PromptDelivery,
8};
9use anyhow::{Result, anyhow};
10
11use super::{ApplyResult, RegisteredResource, Resource, ResourceMetadata};
12
13#[derive(Debug, Clone)]
14/// Builtin manifest adapter for `Agent` resources.
15pub struct AgentResource {
16    /// Resource metadata from the manifest.
17    pub metadata: ResourceMetadata,
18    /// Manifest spec payload for the agent.
19    pub spec: AgentSpec,
20}
21
22impl Resource for AgentResource {
23    fn kind(&self) -> ResourceKind {
24        ResourceKind::Agent
25    }
26
27    fn name(&self) -> &str {
28        &self.metadata.name
29    }
30
31    fn validate(&self) -> Result<()> {
32        super::validate_resource_name(self.name())?;
33        if self.spec.command.trim().is_empty() {
34            return Err(anyhow!("agent.spec.command cannot be empty"));
35        }
36        Ok(())
37    }
38
39    fn apply(&self, config: &mut OrchestratorConfig) -> Result<ApplyResult> {
40        let mut metadata = self.metadata.clone();
41        metadata.project = Some(
42            config
43                .effective_project_id(metadata.project.as_deref())
44                .to_string(),
45        );
46        Ok(super::apply_to_store(
47            config,
48            "Agent",
49            self.name(),
50            &metadata,
51            serde_json::to_value(&self.spec)?,
52        ))
53    }
54
55    fn to_yaml(&self) -> Result<String> {
56        super::manifest_yaml(
57            ResourceKind::Agent,
58            &self.metadata,
59            ResourceSpec::Agent(Box::new(self.spec.clone())),
60        )
61    }
62
63    fn get_from_project(
64        config: &OrchestratorConfig,
65        name: &str,
66        project_id: Option<&str>,
67    ) -> Option<Self> {
68        config
69            .project(project_id)?
70            .agents
71            .get(name)
72            .map(|agent| Self {
73                metadata: super::metadata_from_store(config, "Agent", name, project_id),
74                spec: agent_config_to_spec(agent),
75            })
76    }
77
78    fn delete_from_project(
79        config: &mut OrchestratorConfig,
80        name: &str,
81        project_id: Option<&str>,
82    ) -> bool {
83        super::helpers::delete_from_store_project(config, "Agent", name, project_id)
84    }
85}
86
87/// Builds a typed `AgentResource` from a generic manifest wrapper.
88pub(super) fn build_agent(resource: OrchestratorResource) -> Result<RegisteredResource> {
89    let OrchestratorResource {
90        kind,
91        metadata,
92        spec,
93        ..
94    } = resource;
95    if kind != ResourceKind::Agent {
96        return Err(anyhow!("resource kind/spec mismatch for Agent"));
97    }
98    match spec {
99        ResourceSpec::Agent(spec) => Ok(RegisteredResource::Agent(Box::new(AgentResource {
100            metadata,
101            spec: *spec,
102        }))),
103        _ => Err(anyhow!("resource kind/spec mismatch for Agent")),
104    }
105}
106
107/// Converts an `AgentSpec` manifest payload into runtime config.
108pub(crate) fn agent_spec_to_config(spec: &AgentSpec) -> AgentConfig {
109    let capabilities = spec.capabilities.clone().unwrap_or_default();
110
111    AgentConfig {
112        metadata: AgentMetadata {
113            name: String::new(),
114            description: spec.metadata.as_ref().and_then(|m| m.description.clone()),
115            version: None,
116            cost: spec.metadata.as_ref().and_then(|m| m.cost),
117        },
118        enabled: spec.enabled.unwrap_or(true),
119        capabilities,
120        command: spec.command.clone(),
121        command_rules: spec.command_rules.clone(),
122        selection: spec
123            .selection
124            .as_ref()
125            .map(|selection| AgentSelectionConfig {
126                strategy: selection.strategy,
127                weights: selection.weights.clone(),
128            })
129            .unwrap_or_default(),
130        env: spec.env.clone(),
131        prompt_delivery: spec.prompt_delivery.unwrap_or_default(),
132        health_policy: spec
133            .health_policy
134            .as_ref()
135            .map(|hp| HealthPolicyConfig {
136                disease_duration_hours: hp
137                    .disease_duration_hours
138                    .unwrap_or_else(|| HealthPolicyConfig::default().disease_duration_hours),
139                disease_threshold: hp
140                    .disease_threshold
141                    .unwrap_or_else(|| HealthPolicyConfig::default().disease_threshold),
142                capability_success_threshold: hp
143                    .capability_success_threshold
144                    .unwrap_or_else(|| HealthPolicyConfig::default().capability_success_threshold),
145            })
146            .unwrap_or_default(),
147    }
148}
149
150/// Converts runtime agent config into its manifest spec representation.
151pub(crate) fn agent_config_to_spec(config: &AgentConfig) -> AgentSpec {
152    AgentSpec {
153        command: config.command.clone(),
154        command_rules: config.command_rules.clone(),
155        enabled: if config.enabled { None } else { Some(false) },
156        capabilities: if config.capabilities.is_empty() {
157            None
158        } else {
159            Some(config.capabilities.clone())
160        },
161        metadata: if config.metadata.description.is_none() && config.metadata.cost.is_none() {
162            None
163        } else {
164            Some(AgentMetadataSpec {
165                cost: config.metadata.cost,
166                description: config.metadata.description.clone(),
167            })
168        },
169        selection: Some(AgentSelectionSpec {
170            strategy: config.selection.strategy,
171            weights: config.selection.weights.clone(),
172        }),
173        env: config.env.clone(),
174        prompt_delivery: if config.prompt_delivery == PromptDelivery::Arg {
175            None
176        } else {
177            Some(config.prompt_delivery)
178        },
179        health_policy: if config.health_policy.is_default() {
180            None
181        } else {
182            Some(HealthPolicySpec {
183                disease_duration_hours: Some(config.health_policy.disease_duration_hours),
184                disease_threshold: Some(config.health_policy.disease_threshold),
185                capability_success_threshold: Some(
186                    config.health_policy.capability_success_threshold,
187                ),
188            })
189        },
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::cli_types::{ResourceMetadata, ResourceSpec};
197    use crate::resource::{API_VERSION, dispatch_resource};
198
199    use super::super::test_fixtures::{agent_manifest, make_config};
200
201    #[test]
202    fn agent_resource_apply() {
203        let mut config = make_config();
204
205        let resource =
206            dispatch_resource(agent_manifest("agent-roundtrip", "glmcode -p \"{prompt}\""))
207                .expect("agent dispatch should succeed");
208        assert_eq!(
209            resource.apply(&mut config).expect("apply"),
210            ApplyResult::Created
211        );
212
213        let loaded = AgentResource::get_from(&config, "agent-roundtrip")
214            .expect("agent should be present in config");
215        assert!(loaded.spec.command.contains("{prompt}"));
216        assert_eq!(loaded.kind(), ResourceKind::Agent);
217    }
218
219    #[test]
220    fn agent_validate_rejects_empty_command() {
221        let agent = AgentResource {
222            metadata: super::super::metadata_with_name("ag-empty-cmd"),
223            spec: AgentSpec {
224                enabled: None,
225                command: "  ".to_string(),
226                capabilities: None,
227                metadata: None,
228                selection: None,
229                env: None,
230                prompt_delivery: None,
231                health_policy: None,
232                command_rules: vec![],
233            },
234        };
235        let err = agent.validate().expect_err("operation should fail");
236        assert!(err.to_string().contains("command cannot be empty"));
237    }
238
239    #[test]
240    fn agent_validate_accepts_valid_command() {
241        let agent = AgentResource {
242            metadata: super::super::metadata_with_name("ag-valid"),
243            spec: AgentSpec {
244                enabled: None,
245                command: "glmcode -p \"{prompt}\"".to_string(),
246                capabilities: Some(vec!["plan".to_string()]),
247                metadata: None,
248                selection: None,
249                env: None,
250                prompt_delivery: None,
251                health_policy: None,
252                command_rules: vec![],
253            },
254        };
255        assert!(agent.validate().is_ok());
256    }
257
258    #[test]
259    fn agent_get_from_without_stored_metadata() {
260        let mut config = make_config();
261        config.ensure_project(None).agents.insert(
262            "bare-ag".to_string(),
263            AgentConfig {
264                enabled: true,
265                metadata: AgentMetadata::default(),
266                capabilities: vec!["qa".to_string()],
267                command: "glmcode -p \"{prompt}\"".to_string(),
268                selection: AgentSelectionConfig::default(),
269                env: None,
270                prompt_delivery: PromptDelivery::default(),
271                health_policy: Default::default(),
272                command_rules: Vec::new(),
273            },
274        );
275        let loaded =
276            AgentResource::get_from(&config, "bare-ag").expect("bare agent should be returned");
277        assert_eq!(loaded.metadata.name, "bare-ag");
278        assert!(loaded.metadata.labels.is_none());
279    }
280
281    #[test]
282    fn agent_get_from_returns_none_for_missing() {
283        let config = make_config();
284        assert!(AgentResource::get_from(&config, "nonexistent-ag").is_none());
285    }
286
287    #[test]
288    fn agent_delete_cleans_up_metadata() {
289        let mut config = make_config();
290        let ag = dispatch_resource(agent_manifest("meta-ag", "glmcode -p \"{prompt}\""))
291            .expect("dispatch agent resource");
292        ag.apply(&mut config).expect("apply");
293        assert!(
294            config
295                .resource_store
296                .get_namespaced("Agent", crate::config::DEFAULT_PROJECT_ID, "meta-ag")
297                .is_some()
298        );
299
300        AgentResource::delete_from(&mut config, "meta-ag");
301        assert!(
302            config
303                .resource_store
304                .get_namespaced("Agent", crate::config::DEFAULT_PROJECT_ID, "meta-ag")
305                .is_none()
306        );
307    }
308
309    #[test]
310    fn agent_to_yaml_includes_command() {
311        let agent = AgentResource {
312            metadata: ResourceMetadata {
313                name: "full-agent".to_string(),
314                project: None,
315                labels: None,
316                annotations: None,
317            },
318            spec: AgentSpec {
319                enabled: None,
320                command: "glmcode -p \"{prompt}\" --verbose".to_string(),
321                capabilities: Some(vec!["plan".to_string(), "implement".to_string()]),
322                metadata: None,
323                selection: None,
324                env: None,
325                prompt_delivery: None,
326                health_policy: None,
327                command_rules: vec![],
328            },
329        };
330        let yaml = agent.to_yaml().expect("should serialize");
331        assert!(yaml.contains("full-agent"));
332        assert!(yaml.contains("glmcode"));
333        assert!(yaml.contains("{prompt}"));
334    }
335
336    #[test]
337    fn agent_spec_config_roundtrip() {
338        let spec = AgentSpec {
339            enabled: None,
340            command: "glmcode -p \"{prompt}\" --verbose".to_string(),
341            capabilities: Some(vec!["plan".to_string(), "implement".to_string()]),
342            metadata: Some(AgentMetadataSpec {
343                cost: Some(2),
344                description: Some("A test agent".to_string()),
345            }),
346            selection: Some(AgentSelectionSpec {
347                strategy: Default::default(),
348                weights: None,
349            }),
350            env: None,
351            prompt_delivery: None,
352            health_policy: None,
353            command_rules: vec![],
354        };
355
356        let config = agent_spec_to_config(&spec);
357        assert_eq!(config.command, "glmcode -p \"{prompt}\" --verbose");
358        assert!(config.capabilities.contains(&"plan".to_string()));
359        assert!(config.capabilities.contains(&"implement".to_string()));
360
361        let roundtripped = agent_config_to_spec(&config);
362        assert_eq!(roundtripped.command, spec.command);
363        assert!(roundtripped.capabilities.is_some());
364        let rt_meta = roundtripped.metadata.expect("metadata should be preserved");
365        assert_eq!(rt_meta.cost, Some(2));
366        assert_eq!(rt_meta.description, Some("A test agent".to_string()));
367    }
368
369    #[test]
370    fn agent_config_to_spec_empty_capabilities_becomes_none() {
371        let config = AgentConfig {
372            enabled: true,
373            metadata: AgentMetadata::default(),
374            capabilities: vec![],
375            command: "echo".to_string(),
376            selection: AgentSelectionConfig::default(),
377            env: None,
378            prompt_delivery: PromptDelivery::default(),
379            health_policy: Default::default(),
380            command_rules: Vec::new(),
381        };
382        let spec = agent_config_to_spec(&config);
383        assert!(spec.capabilities.is_none());
384    }
385
386    #[test]
387    fn agent_config_to_spec_no_metadata_becomes_none() {
388        let config = AgentConfig {
389            enabled: true,
390            metadata: AgentMetadata {
391                name: String::new(),
392                description: None,
393                version: None,
394                cost: None,
395            },
396            capabilities: vec![],
397            command: "echo".to_string(),
398            selection: AgentSelectionConfig::default(),
399            env: None,
400            prompt_delivery: PromptDelivery::default(),
401            health_policy: Default::default(),
402            command_rules: Vec::new(),
403        };
404        let spec = agent_config_to_spec(&config);
405        assert!(spec.metadata.is_none());
406    }
407
408    #[test]
409    fn agent_apply_stores_resource_metadata() {
410        let mut config = make_config();
411        let resource = OrchestratorResource {
412            api_version: API_VERSION.to_string(),
413            kind: ResourceKind::Agent,
414            metadata: ResourceMetadata {
415                name: "store-meta-ag".to_string(),
416                project: None,
417                labels: Some([("tier".to_string(), "primary".to_string())].into()),
418                annotations: None,
419            },
420            spec: ResourceSpec::Agent(Box::new(AgentSpec {
421                enabled: None,
422                command: "glmcode -p \"{prompt}\"".to_string(),
423                capabilities: Some(vec!["qa".to_string()]),
424                metadata: None,
425                selection: None,
426                env: None,
427                prompt_delivery: None,
428                health_policy: None,
429                command_rules: vec![],
430            })),
431        };
432        let rr = dispatch_resource(resource).expect("dispatch agent resource");
433        rr.apply(&mut config).expect("apply");
434
435        let cr = config
436            .resource_store
437            .get_namespaced("Agent", crate::config::DEFAULT_PROJECT_ID, "store-meta-ag")
438            .expect("stored agent CR should exist");
439        assert_eq!(
440            cr.metadata
441                .labels
442                .as_ref()
443                .expect("labels should exist")
444                .get("tier")
445                .expect("tier label should exist"),
446            "primary"
447        );
448    }
449}