Skip to main content

agent_runtime_core/
schema.rs

1//! Versioned provider contract schema.
2//!
3//! This module defines the machine-readable contract shared between provider
4//! adapters and `agentctl`:
5//! - adapter operation payloads (`capabilities`, `healthcheck`, `execute`,
6//!   `limits`, `auth-state`)
7//! - normalized success/error envelopes
8//! - stable error categorization and retry semantics
9
10use serde::{Deserialize, Serialize};
11
12/// Stable contract identifier for the v1 provider adapter schema.
13pub const CONTRACT_VERSION_V1: &str = "provider-adapter.v1";
14
15/// Supported provider contract versions.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17pub enum ContractVersion {
18    /// Current stable contract.
19    #[default]
20    #[serde(rename = "provider-adapter.v1")]
21    V1,
22}
23
24impl ContractVersion {
25    /// Return the stable wire value for this contract version.
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::V1 => CONTRACT_VERSION_V1,
29        }
30    }
31}
32
33/// Provider maturity used for rollout and UX messaging.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
35#[serde(rename_all = "kebab-case")]
36pub enum ProviderMaturity {
37    /// Stable and fully supported adapter.
38    #[default]
39    Stable,
40    /// Compile-only/onboarding adapter stub.
41    Stub,
42}
43
44impl ProviderMaturity {
45    pub const fn as_str(self) -> &'static str {
46        match self {
47            Self::Stable => "stable",
48            Self::Stub => "stub",
49        }
50    }
51}
52
53/// Provider identity included in each envelope for routing and diagnostics.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ProviderRef {
56    pub id: String,
57}
58
59impl ProviderRef {
60    pub fn new(id: impl Into<String>) -> Self {
61        Self { id: id.into() }
62    }
63}
64
65/// Adapter metadata used by the provider trait.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct ProviderMetadata {
68    pub id: String,
69    #[serde(default)]
70    pub contract_version: ContractVersion,
71    #[serde(default)]
72    pub maturity: ProviderMaturity,
73}
74
75impl ProviderMetadata {
76    pub fn new(id: impl Into<String>) -> Self {
77        Self {
78            id: id.into(),
79            contract_version: ContractVersion::V1,
80            maturity: ProviderMaturity::Stable,
81        }
82    }
83
84    pub fn with_maturity(mut self, maturity: ProviderMaturity) -> Self {
85        self.maturity = maturity;
86        self
87    }
88
89    pub fn provider_ref(&self) -> ProviderRef {
90        ProviderRef::new(self.id.clone())
91    }
92}
93
94/// Standardized adapter operations.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum ProviderOperation {
98    Capabilities,
99    Healthcheck,
100    Execute,
101    Limits,
102    AuthState,
103}
104
105/// Normalized provider result type used by contract APIs.
106///
107/// `ProviderError` is boxed to keep trait return types small and avoid
108/// large-Err penalties in strict clippy configurations.
109pub type ProviderResult<T> = std::result::Result<T, Box<ProviderError>>;
110
111/// Normalized envelope that `agentctl` can consume uniformly across providers.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct ProviderEnvelope<T> {
114    #[serde(default)]
115    pub contract_version: ContractVersion,
116    pub provider: ProviderRef,
117    pub operation: ProviderOperation,
118    #[serde(flatten)]
119    pub outcome: ProviderOutcome<T>,
120}
121
122impl<T> ProviderEnvelope<T> {
123    pub fn from_result(
124        provider: ProviderRef,
125        operation: ProviderOperation,
126        result: ProviderResult<T>,
127    ) -> Self {
128        match result {
129            Ok(payload) => Self::ok(provider, operation, payload),
130            Err(error) => Self::error(provider, operation, *error),
131        }
132    }
133
134    pub fn ok(provider: ProviderRef, operation: ProviderOperation, result: T) -> Self {
135        Self {
136            contract_version: ContractVersion::V1,
137            provider,
138            operation,
139            outcome: ProviderOutcome::Ok { result },
140        }
141    }
142
143    pub fn error(
144        provider: ProviderRef,
145        operation: ProviderOperation,
146        error: ProviderError,
147    ) -> Self {
148        Self {
149            contract_version: ContractVersion::V1,
150            provider,
151            operation,
152            outcome: ProviderOutcome::Error { error },
153        }
154    }
155
156    pub fn into_result(self) -> ProviderResult<T> {
157        match self.outcome {
158            ProviderOutcome::Ok { result } => Ok(result),
159            ProviderOutcome::Error { error } => Err(Box::new(error)),
160        }
161    }
162}
163
164/// Tagged envelope outcome to keep success/error shape stable over time.
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "status", rename_all = "kebab-case")]
167pub enum ProviderOutcome<T> {
168    Ok { result: T },
169    Error { error: ProviderError },
170}
171
172/// Stable error categorization for cross-provider policy handling.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "kebab-case")]
175pub enum ProviderErrorCategory {
176    Auth,
177    RateLimit,
178    Network,
179    Timeout,
180    Validation,
181    Dependency,
182    Unavailable,
183    Internal,
184    Unknown,
185}
186
187impl ProviderErrorCategory {
188    /// Default retry policy by category.
189    pub const fn is_retryable(self) -> bool {
190        matches!(
191            self,
192            Self::RateLimit | Self::Network | Self::Timeout | Self::Unavailable
193        )
194    }
195}
196
197/// Normalized provider error payload.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct ProviderError {
200    pub category: ProviderErrorCategory,
201    pub code: String,
202    pub message: String,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub retryable: Option<bool>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub details: Option<serde_json::Value>,
207}
208
209impl ProviderError {
210    pub fn new(
211        category: ProviderErrorCategory,
212        code: impl Into<String>,
213        message: impl Into<String>,
214    ) -> Self {
215        Self {
216            category,
217            code: code.into(),
218            message: message.into(),
219            retryable: None,
220            details: None,
221        }
222    }
223
224    pub fn with_retryable(mut self, retryable: bool) -> Self {
225        self.retryable = Some(retryable);
226        self
227    }
228
229    pub fn with_details(mut self, details: serde_json::Value) -> Self {
230        self.details = Some(details);
231        self
232    }
233
234    pub fn is_retryable(&self) -> bool {
235        self.retryable
236            .unwrap_or_else(|| self.category.is_retryable())
237    }
238}
239
240/// Input payload for `capabilities`.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
242pub struct CapabilitiesRequest {
243    #[serde(default)]
244    pub include_experimental: bool,
245}
246
247/// Capability inventory.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
249pub struct CapabilitiesResponse {
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub capabilities: Vec<Capability>,
252}
253
254/// Single declared capability.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct Capability {
257    pub name: String,
258    #[serde(default = "default_true")]
259    pub available: bool,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub description: Option<String>,
262}
263
264impl Capability {
265    pub fn available(name: impl Into<String>) -> Self {
266        Self {
267            name: name.into(),
268            available: true,
269            description: None,
270        }
271    }
272}
273
274fn default_true() -> bool {
275    true
276}
277
278/// Input payload for `healthcheck`.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
280pub struct HealthcheckRequest {
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub timeout_ms: Option<u64>,
283}
284
285/// Provider health states.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
287#[serde(rename_all = "kebab-case")]
288pub enum HealthStatus {
289    Healthy,
290    Degraded,
291    Unhealthy,
292    #[default]
293    Unknown,
294}
295
296/// Output payload for `healthcheck`.
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
298pub struct HealthcheckResponse {
299    #[serde(default)]
300    pub status: HealthStatus,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub summary: Option<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub details: Option<serde_json::Value>,
305}
306
307/// Input payload for `execute`.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct ExecuteRequest {
310    pub task: String,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub input: Option<String>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub timeout_ms: Option<u64>,
315}
316
317impl ExecuteRequest {
318    pub fn new(task: impl Into<String>) -> Self {
319        Self {
320            task: task.into(),
321            input: None,
322            timeout_ms: None,
323        }
324    }
325}
326
327/// Output payload for `execute`.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
329pub struct ExecuteResponse {
330    #[serde(default)]
331    pub exit_code: i32,
332    #[serde(default)]
333    pub stdout: String,
334    #[serde(default)]
335    pub stderr: String,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub duration_ms: Option<u64>,
338}
339
340/// Input payload for `limits`.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
342pub struct LimitsRequest {}
343
344/// Output payload for `limits`.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
346pub struct LimitsResponse {
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub max_concurrency: Option<u32>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub max_timeout_ms: Option<u64>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub max_input_bytes: Option<u64>,
353}
354
355/// Input payload for `auth-state`.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
357pub struct AuthStateRequest {}
358
359/// Authentication state categories.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
361#[serde(rename_all = "kebab-case")]
362pub enum AuthStateStatus {
363    Authenticated,
364    Unauthenticated,
365    Expired,
366    #[default]
367    Unknown,
368}
369
370/// Output payload for `auth-state`.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
372pub struct AuthStateResponse {
373    #[serde(default)]
374    pub state: AuthStateStatus,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub subject: Option<String>,
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub scopes: Vec<String>,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub expires_at: Option<String>,
381}