1use std::collections::BTreeMap;
4
5use lean_ctx_protocol::CapabilityManifestV1;
6use serde::{Deserialize, Serialize};
7
8use super::{OclaError, OclaResult};
9
10#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
13pub struct AgentTaskRequest {
14 pub id: String,
15 pub prompt: String,
16 pub working_dir: std::path::PathBuf,
17 pub timeout_ms: u64,
18 pub model: Option<String>,
19 pub max_turns: Option<u32>,
20 #[serde(default)]
22 pub profile_name: Option<String>,
23 pub profile_hash: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub delivery_profile: Option<Box<crate::core::work_graph_delegation::ChildDeliveryProfileV1>>,
27}
28
29pub const CAPABILITY_OBSERVATION_SCHEMA_VERSION: u32 = 1;
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum CapabilityInput {
36 ContextRequest {
37 paths: Vec<String>,
38 mode: String,
39 budget_tokens: Option<u64>,
40 },
41 ShellCommand {
42 command: String,
43 workdir: Option<String>,
44 },
45 ModelRequest {
46 prompt: String,
47 model: Option<String>,
48 },
49 AgentTask(AgentTaskRequest),
50}
51
52impl CapabilityInput {
53 #[must_use]
55 pub fn payload(&self) -> &str {
56 match self {
57 Self::ContextRequest { paths, .. } => paths.first().map(String::as_str).unwrap_or(""),
58 Self::ShellCommand { command, .. } => command,
59 Self::ModelRequest { prompt, .. } => prompt,
60 Self::AgentTask(request) => &request.prompt,
61 }
62 }
63}
64
65#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
67#[serde(default)]
68pub struct PolicyConstraints {
69 pub max_input_tokens: Option<u64>,
70 pub max_output_tokens: Option<u64>,
71 pub max_latency_ms: Option<u64>,
72 pub allowed_models: Vec<String>,
73 pub allowed_paths: Vec<String>,
74 pub allowed_data_classifications: Vec<String>,
75 pub allow_remote: bool,
76 pub require_deterministic: bool,
77 pub require_reversible: bool,
78}
79
80impl PolicyConstraints {
81 pub fn validate(&self) -> OclaResult<()> {
83 if self
84 .allowed_models
85 .iter()
86 .chain(self.allowed_paths.iter())
87 .chain(self.allowed_data_classifications.iter())
88 .any(|value| value.trim().is_empty())
89 {
90 return Err(OclaError::InvalidRequest(
91 "policy constraint lists must not contain empty values".into(),
92 ));
93 }
94 Ok(())
95 }
96
97 pub fn check_input(&self, input: &CapabilityInput) -> OclaResult<()> {
99 self.validate()?;
100 match input {
101 CapabilityInput::ContextRequest { paths, .. } => {
102 if !self.allowed_paths.is_empty()
103 && paths
104 .iter()
105 .any(|path| !self.allowed_paths.iter().any(|allowed| path == allowed))
106 {
107 return Err(OclaError::InvalidRequest(
108 "context path is outside policy allowlist".into(),
109 ));
110 }
111 }
112 CapabilityInput::ModelRequest { model, .. }
113 | CapabilityInput::AgentTask(AgentTaskRequest { model, .. }) => {
114 if !self.allowed_models.is_empty()
115 && model.as_ref().is_none_or(|model| {
116 !self.allowed_models.iter().any(|allowed| allowed == model)
117 })
118 {
119 return Err(OclaError::InvalidRequest(
120 "model is outside policy allowlist".into(),
121 ));
122 }
123 }
124 CapabilityInput::ShellCommand { .. } => {}
125 }
126 if let CapabilityInput::AgentTask(request) = input
127 && !self.allowed_paths.is_empty()
128 && !self
129 .allowed_paths
130 .iter()
131 .any(|path| std::path::Path::new(path) == request.working_dir)
132 {
133 return Err(OclaError::InvalidRequest(
134 "agent working directory is outside policy allowlist".into(),
135 ));
136 }
137 Ok(())
138 }
139}
140
141#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct CapabilityInvocation {
145 pub task_id: String,
146 pub capability_id: String,
147 pub capability_version: String,
148 pub input: CapabilityInput,
149 pub policy_constraints: PolicyConstraints,
150 pub timeout_ms: u64,
151}
152
153impl CapabilityInvocation {
154 pub fn validate(&self) -> OclaResult<()> {
156 for (label, value) in [
157 ("task_id", &self.task_id),
158 ("capability_id", &self.capability_id),
159 ("capability_version", &self.capability_version),
160 ] {
161 if value.trim().is_empty() {
162 return Err(OclaError::InvalidRequest(format!("{label} is required")));
163 }
164 }
165 self.policy_constraints.check_input(&self.input)
166 }
167}
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum CapabilityFailureMode {
173 Timeout,
174 Unavailable,
175 RejectedByPolicy,
176 InvalidOutput,
177 Partial,
178 FallbackToNative,
179 Internal,
180}
181
182#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct CapabilityObservationV1 {
186 pub schema_version: u32,
187 pub task_id: String,
188 pub capability_id: String,
189 pub capability_version: String,
190 pub success: bool,
191 pub input_tokens: u64,
192 pub output_tokens: u64,
193 pub latency_ms: u64,
194 pub failure_mode: Option<CapabilityFailureMode>,
195 pub output_ref: Option<String>,
196 pub metrics: BTreeMap<String, u64>,
197}
198
199impl CapabilityObservationV1 {
200 #[must_use]
202 pub fn success(
203 invocation: &CapabilityInvocation,
204 input_tokens: u64,
205 output_tokens: u64,
206 latency_ms: u64,
207 output_ref: Option<String>,
208 ) -> Self {
209 Self {
210 schema_version: CAPABILITY_OBSERVATION_SCHEMA_VERSION,
211 task_id: invocation.task_id.clone(),
212 capability_id: invocation.capability_id.clone(),
213 capability_version: invocation.capability_version.clone(),
214 success: true,
215 input_tokens,
216 output_tokens,
217 latency_ms,
218 failure_mode: None,
219 output_ref,
220 metrics: BTreeMap::new(),
221 }
222 }
223}
224
225#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct CapabilityResult {
229 pub success: bool,
230 pub output_tokens: u64,
231 pub latency_ms: u64,
232 pub observation: CapabilityObservationV1,
233 pub evidence_ref: Option<String>,
234}
235
236pub trait CapabilityAdapter: Send + Sync {
238 fn manifest(&self) -> &CapabilityManifestV1;
239 fn invoke(&self, invocation: CapabilityInvocation) -> OclaResult<CapabilityResult>;
240 fn health_check(&self) -> OclaResult<bool>;
241}
242
243#[must_use]
245pub(crate) fn evidence_ref(content: &str) -> String {
246 format!("blake3:{}", blake3::hash(content.as_bytes()).to_hex())
247}
248
249pub(crate) fn check_timeout(start: std::time::Instant, timeout_ms: u64) -> OclaResult<u64> {
251 let elapsed_ms = start.elapsed().as_millis() as u64;
252 if timeout_ms != 0 && elapsed_ms > timeout_ms {
253 return Err(OclaError::InvalidRequest(format!(
254 "capability invocation exceeded timeout ({elapsed_ms}ms > {timeout_ms}ms)"
255 )));
256 }
257 Ok(elapsed_ms)
258}