use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
group = "polychrome.dev",
version = "v1alpha1",
kind = "Agent",
namespaced,
status = "AgentStatus",
shortname = "ag",
category = "polychrome",
derive = "PartialEq",
printcolumn = r#"{"name":"Model","type":"string","jsonPath":".spec.model"}"#,
printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct AgentSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools_enabled: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub core_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub builtin_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub can_handoff_to: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub can_delegate_to: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegate_step_budget: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegate_share_in: Option<DelegateShareIn>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegate_max_fanout: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegate_turn_budget: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<ApprovalPolicy>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DelegateShareIn {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_files: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ApprovalPolicy {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub needs_approval: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentStatus {
#[serde(default)]
pub ready: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use kube::CustomResourceExt;
use serde_json::{Value, json};
#[test]
fn crd_identity_is_polychrome_agent() {
let crd = Agent::crd();
assert_eq!(crd.spec.group, "polychrome.dev");
assert_eq!(crd.spec.names.kind, "Agent");
assert_eq!(crd.spec.names.plural, "agents");
}
#[test]
fn spec_round_trips_camel_case() {
let spec = AgentSpec {
description: Some("route service ops".to_owned()),
instructions: Some("You are the ops agent.".to_owned()),
model: Some("vertex/some-model".to_owned()),
tools_enabled: vec!["toolservice:polychrome/scaffold".to_owned()],
core_tools: vec!["grep".to_owned(), "scaffold__plan".to_owned()],
builtin_tools: Some(vec![]),
can_handoff_to: vec!["agent:default/coding".to_owned()],
can_delegate_to: vec!["agent:default/researcher".to_owned()],
delegate_step_budget: Some(4),
delegate_share_in: Some(DelegateShareIn {
allow: vec!["src/**".to_owned()],
max_files: Some(8),
max_bytes: Some(4096),
}),
delegate_max_fanout: Some(6),
delegate_turn_budget: Some(10),
sandbox_mode: Some("read-only".to_owned()),
approval_policy: Some(ApprovalPolicy {
needs_approval: vec!["service_delete".to_owned()],
}),
};
let v = serde_json::to_value(&spec).unwrap();
assert!(
v.get("builtinTools").is_some(),
"builtin_tools → builtinTools"
);
assert!(
v.get("canHandoffTo").is_some(),
"can_handoff_to → canHandoffTo"
);
assert!(
v.get("canDelegateTo").is_some(),
"can_delegate_to → canDelegateTo"
);
assert!(
v.get("delegateStepBudget").is_some(),
"delegate_step_budget → delegateStepBudget"
);
assert!(
v.get("delegateMaxFanout").is_some(),
"delegate_max_fanout → delegateMaxFanout"
);
assert!(
v.get("delegateTurnBudget").is_some(),
"delegate_turn_budget → delegateTurnBudget"
);
assert!(v.get("sandboxMode").is_some(), "sandbox_mode → sandboxMode");
assert_eq!(
v["delegateShareIn"]["allow"][0], "src/**",
"delegate_share_in → delegateShareIn"
);
assert_eq!(v["delegateShareIn"]["maxFiles"], 8, "max_files → maxFiles");
assert_eq!(
v["delegateShareIn"]["maxBytes"], 4096,
"max_bytes → maxBytes"
);
assert_eq!(v["coreTools"][0], "grep", "core_tools → coreTools");
assert_eq!(v["approvalPolicy"]["needsApproval"][0], "service_delete");
assert_eq!(serde_json::from_value::<AgentSpec>(v).unwrap(), spec);
}
#[test]
fn core_tools_defaults_empty_and_round_trips() {
let absent: AgentSpec = serde_json::from_value(json!({})).unwrap();
assert!(absent.core_tools.is_empty());
assert!(
serde_json::to_value(&absent)
.unwrap()
.get("coreTools")
.is_none(),
"empty core omitted from the wire"
);
let present: AgentSpec = serde_json::from_value(json!({
"coreTools": ["grep", "vcs__repo_list"]
}))
.unwrap();
assert_eq!(present.core_tools, vec!["grep", "vcs__repo_list"]);
let round_tripped: AgentSpec =
serde_json::from_value(serde_json::to_value(&present).unwrap()).unwrap();
assert_eq!(round_tripped, present);
}
#[test]
fn builtin_tools_distinguishes_unset_from_empty() {
let unset: AgentSpec = serde_json::from_value(json!({})).unwrap();
assert_eq!(unset.builtin_tools, None);
let empty: AgentSpec = serde_json::from_value(json!({ "builtinTools": [] })).unwrap();
assert_eq!(empty.builtin_tools, Some(vec![]));
let out = serde_json::to_value(&unset).unwrap();
assert!(out.get("builtinTools").is_none());
}
#[test]
fn minimal_agent_spec_is_valid() {
let spec: AgentSpec = serde_json::from_value(json!({})).unwrap();
assert!(
spec.model.is_none() && spec.tools_enabled.is_empty() && spec.can_handoff_to.is_empty()
);
let _: Value = serde_json::to_value(spec).unwrap();
}
}