1#![forbid(unsafe_code)]
12
13use std::{collections::BTreeMap, fmt};
14
15use dekopon_capability::{EffectKind, Idempotency, Permission};
16pub use dekopon_core::AgentStatus;
17use dekopon_core::{CapabilityId, ProviderId, RiskLevel};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21#[derive(
23 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
24)]
25pub enum ApiVersion {
26 #[serde(rename = "dekopon.dev/v1alpha1")]
28 V1Alpha1,
29}
30
31impl fmt::Display for ApiVersion {
32 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::V1Alpha1 => formatter.write_str("dekopon.dev/v1alpha1"),
35 }
36 }
37}
38
39#[derive(
41 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
42)]
43#[serde(rename_all = "PascalCase")]
44pub enum Kind {
45 Agent,
47 Capability,
49 Provider,
51 AgentList,
53 CapabilityList,
55 ProviderList,
57}
58
59impl fmt::Display for Kind {
60 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61 write!(formatter, "{self:?}")
62 }
63}
64
65#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
67#[serde(deny_unknown_fields, rename_all = "camelCase")]
68pub struct ObjectMeta {
69 pub name: String,
71 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73 pub labels: BTreeMap<String, String>,
74}
75
76impl ObjectMeta {
77 #[must_use]
79 pub fn named(name: impl Into<String>) -> Self {
80 Self {
81 name: name.into(),
82 labels: BTreeMap::new(),
83 }
84 }
85}
86
87#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
89#[serde(deny_unknown_fields, rename_all = "camelCase")]
90pub struct AgentSpec {
91 pub description: String,
93 #[serde(default = "default_enabled")]
95 pub enabled: bool,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub instructions: Option<String>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub capabilities: Vec<CapabilityId>,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub providers: Vec<ProviderId>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub model_class: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub policy_profile: Option<String>,
116}
117
118const fn default_enabled() -> bool {
119 true
120}
121
122#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
124#[serde(deny_unknown_fields, rename_all = "camelCase")]
125pub struct Agent {
126 pub api_version: ApiVersion,
128 pub kind: Kind,
130 pub metadata: ObjectMeta,
132 pub spec: AgentSpec,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub status: Option<AgentStatus>,
137}
138
139#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
141#[serde(deny_unknown_fields, rename_all = "camelCase")]
142pub struct CapabilitySpec {
143 pub description: String,
145 pub provider: ProviderId,
147 pub effect: EffectKind,
149 pub risk: RiskLevel,
151 pub idempotency: Idempotency,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub permissions: Vec<Permission>,
156}
157
158#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
160#[serde(rename_all = "PascalCase")]
161pub enum CapabilityStatus {
162 Available,
164 Unavailable,
166 Unknown,
168}
169
170impl fmt::Display for CapabilityStatus {
171 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172 write!(formatter, "{self:?}")
173 }
174}
175
176#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
178#[serde(deny_unknown_fields, rename_all = "camelCase")]
179pub struct Capability {
180 pub api_version: ApiVersion,
182 pub kind: Kind,
184 pub metadata: ObjectMeta,
186 pub spec: CapabilitySpec,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub status: Option<CapabilityStatus>,
191}
192
193#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
195#[serde(deny_unknown_fields, rename_all = "camelCase")]
196pub struct ProviderSpec {
197 pub description: String,
199 #[serde(rename = "type")]
201 pub provider_type: String,
202 pub credential_ref: String,
204}
205
206#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
208#[serde(rename_all = "PascalCase")]
209pub enum ProviderStatus {
210 Ready,
212 Unavailable,
214 Unknown,
216}
217
218impl fmt::Display for ProviderStatus {
219 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
220 write!(formatter, "{self:?}")
221 }
222}
223
224#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
226#[serde(deny_unknown_fields, rename_all = "camelCase")]
227pub struct Provider {
228 pub api_version: ApiVersion,
230 pub kind: Kind,
232 pub metadata: ObjectMeta,
234 pub spec: ProviderSpec,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub status: Option<ProviderStatus>,
239}
240
241#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
243#[serde(deny_unknown_fields, rename_all = "camelCase")]
244pub struct AgentList {
245 pub api_version: ApiVersion,
247 pub kind: Kind,
249 pub items: Vec<Agent>,
251}
252
253impl AgentList {
254 #[must_use]
256 pub const fn new(items: Vec<Agent>) -> Self {
257 Self {
258 api_version: ApiVersion::V1Alpha1,
259 kind: Kind::AgentList,
260 items,
261 }
262 }
263}
264
265#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
267#[serde(deny_unknown_fields, rename_all = "camelCase")]
268pub struct CapabilityList {
269 pub api_version: ApiVersion,
271 pub kind: Kind,
273 pub items: Vec<Capability>,
275}
276
277impl CapabilityList {
278 #[must_use]
280 pub const fn new(items: Vec<Capability>) -> Self {
281 Self {
282 api_version: ApiVersion::V1Alpha1,
283 kind: Kind::CapabilityList,
284 items,
285 }
286 }
287}
288
289#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
291#[serde(deny_unknown_fields, rename_all = "camelCase")]
292pub struct ProviderList {
293 pub api_version: ApiVersion,
295 pub kind: Kind,
297 pub items: Vec<Provider>,
299}
300
301impl ProviderList {
302 #[must_use]
304 pub const fn new(items: Vec<Provider>) -> Self {
305 Self {
306 api_version: ApiVersion::V1Alpha1,
307 kind: Kind::ProviderList,
308 items,
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use std::collections::BTreeMap;
316
317 use dekopon_capability::{EffectKind, Idempotency};
318 use dekopon_core::{AgentStatus, ProviderId, RiskLevel};
319 use schemars::schema_for;
320
321 use super::{Agent, AgentSpec, ApiVersion, Capability, CapabilitySpec, Kind, ObjectMeta};
322
323 fn agent() -> Agent {
324 Agent {
325 api_version: ApiVersion::V1Alpha1,
326 kind: Kind::Agent,
327 metadata: ObjectMeta {
328 name: "reviewer".to_owned(),
329 labels: BTreeMap::from([("team".to_owned(), "platform".to_owned())]),
330 },
331 spec: AgentSpec {
332 description: "Reviews pull requests".to_owned(),
333 enabled: true,
334 instructions: Some("Review the diff and comment; never approve.".to_owned()),
335 capabilities: vec![
336 "github.pull-request.read"
337 .parse()
338 .expect("valid capability fixture"),
339 ],
340 providers: vec!["github".parse().expect("valid provider fixture")],
341 model_class: Some("reasoning".to_owned()),
342 policy_profile: Some("review-read-only".to_owned()),
343 },
344 status: Some(AgentStatus::Ready),
345 }
346 }
347
348 #[test]
349 fn agent_round_trips_through_json_and_yaml() {
350 let original = agent();
351
352 let json = serde_json::to_string(&original).expect("agent serializes as JSON");
353 let from_json = serde_json::from_str::<Agent>(&json).expect("agent parses as JSON");
354 assert_eq!(from_json, original);
355
356 let yaml = serde_yaml::to_string(&original).expect("agent serializes as YAML");
357 let from_yaml = serde_yaml::from_str::<Agent>(&yaml).expect("agent parses as YAML");
358 assert_eq!(from_yaml, original);
359 assert!(yaml.contains("apiVersion: dekopon.dev/v1alpha1"));
360 assert!(yaml.contains("instructions:"));
361 }
362
363 #[test]
368 fn absent_instructions_stay_absent_through_a_round_trip() {
369 let mut original = agent();
370 original.spec.instructions = None;
371
372 let value = serde_json::to_value(&original).expect("agent serializes");
373 assert!(value["spec"].get("instructions").is_none(), "{value}");
374
375 let yaml = serde_yaml::to_string(&original).expect("agent serializes as YAML");
376 assert!(!yaml.contains("instructions"), "{yaml}");
377 let decoded = serde_yaml::from_str::<Agent>(&yaml).expect("agent parses as YAML");
378 assert_eq!(decoded, original);
379 assert!(decoded.spec.instructions.is_none());
380 }
381
382 #[test]
383 fn rejects_unknown_authored_fields() {
384 let input = r#"
385apiVersion: dekopon.dev/v1alpha1
386kind: Capability
387metadata:
388 name: github.pull-request.read
389spec:
390 description: Reads pull requests
391 provider: github
392 effect: read-only
393 risk: Low
394 idempotency: idempotent
395 permisssions: []
396"#;
397 let error = serde_yaml::from_str::<Capability>(input)
398 .expect_err("misspelled permissions must not be ignored");
399 assert!(error.to_string().contains("unknown field `permisssions`"));
400 }
401
402 #[test]
403 fn generates_json_schema() {
404 let schema = schema_for!(Agent);
405 let encoded = serde_json::to_value(schema).expect("schema serializes");
406 assert_eq!(encoded["title"], "Agent");
407 }
408
409 #[test]
410 fn capability_wire_values_are_explicit() {
411 let capability = Capability {
412 api_version: ApiVersion::V1Alpha1,
413 kind: Kind::Capability,
414 metadata: ObjectMeta::named("github.pull-request.read"),
415 spec: CapabilitySpec {
416 description: "Reads pull requests".to_owned(),
417 provider: "github".parse::<ProviderId>().expect("valid fixture"),
418 effect: EffectKind::ReadOnly,
419 risk: RiskLevel::Low,
420 idempotency: Idempotency::Idempotent,
421 permissions: Vec::new(),
422 },
423 status: None,
424 };
425 let value = serde_json::to_value(capability).expect("capability serializes");
426
427 assert_eq!(value["spec"]["effect"], "read-only");
428 assert_eq!(value["spec"]["risk"], "Low");
429 }
430}