Skip to main content

lean_ctx/core/ocla/
invocation.rs

1//! Common invocation and measurement envelopes for OCLA capability adapters.
2
3use std::collections::BTreeMap;
4
5use lean_ctx_protocol::CapabilityManifestV1;
6use serde::{Deserialize, Serialize};
7
8use super::{OclaError, OclaResult};
9
10/// Agent task description carried by `CapabilityInput::AgentTask`. LeanCTX
11/// does not launch agents; the host executes the task under these limits.
12#[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    /// Profile selected for this isolated agent invocation.
21    #[serde(default)]
22    pub profile_name: Option<String>,
23    pub profile_hash: Option<String>,
24    /// Explicit host-provided child identity; never inherited from the parent.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub delivery_profile: Option<Box<crate::core::work_graph_delegation::ChildDeliveryProfileV1>>,
27}
28
29/// Version of the public observation shape emitted by native adapters.
30pub const CAPABILITY_OBSERVATION_SCHEMA_VERSION: u32 = 1;
31
32/// Input accepted by a capability adapter.
33#[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    /// Returns the payload measured by the common token envelope.
54    #[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/// Policy limits applied uniformly before and after adapter execution.
66#[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    /// Validate internal policy invariants.
82    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    /// Apply the model/path portions of policy to an invocation.
98    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/// A single capability invocation with task and policy lineage attached.
142#[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    /// Validate identity and policy fields before dispatch.
155    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/// Failure taxonomy shared by all adapters.
170#[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/// Comparable, payload-free measurement emitted for one invocation.
183#[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    /// Build the successful observation shared by native adapters.
201    #[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/// Result returned by every adapter through the common invocation path.
226#[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
236/// Adapter boundary shared by native, reference, and enterprise capabilities.
237pub 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/// Create a stable content-addressed evidence reference without exposing data.
244#[must_use]
245pub(crate) fn evidence_ref(content: &str) -> String {
246    format!("blake3:{}", blake3::hash(content.as_bytes()).to_hex())
247}
248
249/// Enforce the invocation timeout after a synchronous adapter operation.
250pub(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}