gate4agent_types/
capability.rs1use crate::{OperationId, SessionGeneration, WORKING_DIRECTORY_MAX_BYTES};
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5pub const CAPABILITY_MODELS_MAX: usize = 512;
6pub const CAPABILITY_MODEL_ID_MAX_BYTES: usize = 512;
7pub const CAPABILITY_MODEL_LABEL_MAX_BYTES: usize = 1_024;
8
9#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
10pub struct CapabilityProbeRequest {
11 pub working_directory: String,
12}
13
14impl CapabilityProbeRequest {
15 pub fn validate(&self) -> Result<(), CapabilityValidationError> {
16 if self.working_directory.is_empty()
17 || self.working_directory.len() > WORKING_DIRECTORY_MAX_BYTES
18 || self.working_directory.contains('\0')
19 {
20 return Err(CapabilityValidationError::InvalidWorkingDirectory);
21 }
22 Ok(())
23 }
24}
25
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27pub struct CapabilityModelSummary {
28 pub id: String,
29 pub label: String,
30}
31
32impl CapabilityModelSummary {
33 pub fn validate(&self) -> Result<(), CapabilityValidationError> {
34 if self.id.is_empty()
35 || self.id.len() > CAPABILITY_MODEL_ID_MAX_BYTES
36 || self.id.chars().any(char::is_whitespace)
37 || self.id.chars().any(char::is_control)
38 {
39 return Err(CapabilityValidationError::InvalidModelId);
40 }
41 if self.label.trim().is_empty()
42 || self.label.len() > CAPABILITY_MODEL_LABEL_MAX_BYTES
43 || self.label.chars().any(char::is_control)
44 {
45 return Err(CapabilityValidationError::InvalidModelLabel);
46 }
47 Ok(())
48 }
49}
50
51pub fn validate_capability_models(
52 models: &[CapabilityModelSummary],
53) -> Result<(), CapabilityValidationError> {
54 if models.len() > CAPABILITY_MODELS_MAX {
55 return Err(CapabilityValidationError::TooManyModels);
56 }
57 let mut seen = std::collections::BTreeSet::new();
58 for model in models {
59 model.validate()?;
60 if !seen.insert(model.id.as_str()) {
61 return Err(CapabilityValidationError::DuplicateModelId);
62 }
63 }
64 Ok(())
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
68#[serde(tag = "kind", rename_all = "kebab-case")]
69pub enum CapabilityProbeFailure {
70 ExecutorUnavailable,
71 SpawnUnavailable,
72 TimedOut,
73 OutputLimitExceeded,
74 NonZeroExit { exit_code: Option<i32> },
75 AuthorityRejected,
76}
77
78#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
79pub struct PendingCapabilityProbe {
80 pub operation_id: OperationId,
81 pub generation: SessionGeneration,
82 pub request: CapabilityProbeRequest,
83}
84
85#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
86pub struct CapabilitySnapshot {
87 pub pending: Option<PendingCapabilityProbe>,
88 pub settled: bool,
89 pub session_option_models: Vec<CapabilityModelSummary>,
90 pub last_failure: Option<CapabilityProbeFailure>,
91}
92
93#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
94pub enum CapabilityValidationError {
95 #[error("capability probe working directory is empty, too large, or contains a NUL byte")]
96 InvalidWorkingDirectory,
97 #[error("capability model ID is empty, too large, or contains whitespace or controls")]
98 InvalidModelId,
99 #[error("capability model label is empty, too large, or contains controls")]
100 InvalidModelLabel,
101 #[error("capability model result exceeds its count bound")]
102 TooManyModels,
103 #[error("capability model result contains duplicate IDs")]
104 DuplicateModelId,
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn capability_models_are_bounded_and_unique() {
113 let valid = vec![CapabilityModelSummary {
114 id: "gpt-5.3-codex".to_owned(),
115 label: "GPT-5.3 Codex".to_owned(),
116 }];
117 assert!(validate_capability_models(&valid).is_ok());
118
119 let duplicate = vec![valid[0].clone(), valid[0].clone()];
120 assert_eq!(
121 validate_capability_models(&duplicate),
122 Err(CapabilityValidationError::DuplicateModelId)
123 );
124 }
125}