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)]
14pub struct AgentResource {
16 pub metadata: ResourceMetadata,
18 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
87pub(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
107pub(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
150pub(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}