Skip to main content

luft_core/contract/
backend.rs

1//! # AgentBackend Contract
2//!
3//! The [`AgentBackend`] trait is the **control-plane boundary** between Luft's
4//! orchestration runtime and the agent execution environment. Prompt goes in,
5//! structured [`AgentResult`] comes out.
6//!
7//! ## Implementing a Backend
8//!
9//! ```no_run
10//! use luft_core::contract::backend::*;
11//! use async_trait::async_trait;
12//!
13//! struct MyBackend;
14//!
15//! impl MyBackend {
16//!     fn new() -> Self { Self }
17//! }
18//!
19//! #[async_trait]
20//! impl AgentBackend for MyBackend {
21//!     fn id(&self) -> &'static str { "my-backend" }
22//!
23//!     fn capabilities(&self) -> AgentCapabilities {
24//!         AgentCapabilities {
25//!             streaming: true,
26//!             ..Default::default()
27//!         }
28//!     }
29//!
30//!     async fn run(&self, task: AgentTask, ctx: RunContext)
31//!         -> Result<AgentResult, BackendError>
32//!     {
33//!         // 1. Observe cancellation
34//!         if ctx.cancel.is_cancelled() {
35//!             return Err(BackendError::Cancelled);
36//!         }
37//!
38//!         // 2. Execute the agent task (your custom logic)
39//!         let output = serde_json::json!({ "text": "hello" });
40//!
41//!         // 3. Return structured result
42//!         Ok(AgentResult {
43//!             agent_id: task.agent_id,
44//!             status: AgentStatus::Ok,
45//!             output,
46//!             findings: vec![],
47//!             tokens_used: Default::default(),
48//!             artifacts: vec![],
49//!             logs: LogRef::default(),
50//!         })
51//!     }
52//!
53//!     fn as_any(&self) -> &dyn std::any::Any { self }
54//! }
55//! ```
56use crate::contract::event::EventSender;
57use crate::contract::finding::Finding;
58use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
59use async_trait::async_trait;
60use serde::{Deserialize, Serialize};
61use std::path::PathBuf;
62use std::time::Duration;
63use tokio_util::sync::CancellationToken;
64
65/// A pluggable agent backend (e.g. OpenCode via ACP). Prompt in, structured
66/// result out.
67///
68/// # Contract
69///
70/// - **Cancellation**: implementations **must** observe `ctx.cancel` and return
71///   promptly with [`BackendError::Cancelled`] when the token fires.
72/// - **Id stability**: [`id()`](Self::id) must return a stable string for the
73///   backend's lifetime — it is used as the registry key.
74/// - **Thread safety**: the trait requires `Send + Sync`; backends are typically
75///   wrapped in `Arc<dyn AgentBackend>` and shared across tasks.
76/// - **Downcasting**: implement [`as_any()`](Self::as_any) by returning `self`
77///   to allow callers to downcast to the concrete backend type.
78///
79/// See the [module docs](self) for a complete implementation example.
80#[async_trait]
81pub trait AgentBackend: Send + Sync {
82    /// Stable backend id, e.g. "opencode".
83    fn id(&self) -> &'static str;
84
85    /// Capability declaration (v0.1: recorded/validated only; routing in v0.2).
86    fn capabilities(&self) -> AgentCapabilities;
87
88    /// Run one agent task to completion.
89    async fn run(&self, task: AgentTask, ctx: RunContext) -> Result<AgentResult, BackendError>;
90
91    /// Upcast hook for downcasting `&dyn AgentBackend` back to a concrete
92    /// backend type. Standard Rust trait-object downcast pattern: each impl
93    /// returns `self`, which `Any::downcast_ref` then narrows to `&Concrete`.
94    /// No default impl is provided — `Self` is unsized on a trait object, so a
95    /// default body `self` would not compile.
96    fn as_any(&self) -> &dyn std::any::Any;
97}
98
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct AgentCapabilities {
101    pub streaming: bool,
102    pub mcp_injection: bool,
103    pub structured_output: bool,
104    /// Known model ids; empty = unknown/any.
105    pub models: Vec<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct AgentTask {
110    pub agent_id: AgentId,
111    pub phase_id: PhaseId,
112    pub prompt: String,
113    pub model: Option<String>,
114    #[serde(default)]
115    pub description: Option<String>,
116    #[serde(default)]
117    pub role: Option<String>,
118    #[serde(default)]
119    pub name: Option<String>,
120    #[serde(default)]
121    pub agent_seq: u32,
122    pub allowlist: Option<ToolPolicy>,
123    pub workdir: PathBuf,
124    /// Data-plane injection point (Luft MCP endpoint).
125    pub mcp_endpoint: Option<McpEndpoint>,
126    /// Idle timeout: maximum silence (no ACP notifications) before the backend
127    /// kills the session. `None` = backend default (5 min).
128    pub timeout: Option<Duration>,
129    /// Optional JSON Schema (M4) for validating agent output.
130    /// When set, the runtime validates the agent's output against this schema
131    /// and may retry or reject if validation fails.
132    pub output_schema: Option<serde_json::Value>,
133
134    /// Per-agent working directory override from Lua `working_folder` opt.
135    #[serde(default)]
136    pub workdir_override: Option<PathBuf>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct AgentResult {
141    pub agent_id: AgentId,
142    pub status: AgentStatus,
143    /// Structured output: prefers aggregated MCP findings, falls back to parsed
144    /// final message.
145    pub output: serde_json::Value,
146    #[serde(default)]
147    pub findings: Vec<Finding>,
148    pub tokens_used: TokenUsage,
149    #[serde(default)]
150    pub artifacts: Vec<Artifact>,
151    pub logs: LogRef,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub enum AgentStatus {
156    Ok,
157    Error,
158    Cancelled,
159    TimedOut,
160}
161
162impl AgentStatus {
163    pub fn as_str(&self) -> &'static str {
164        match self {
165            AgentStatus::Ok => "ok",
166            AgentStatus::Error => "error",
167            AgentStatus::Cancelled => "cancelled",
168            AgentStatus::TimedOut => "timed_out",
169        }
170    }
171}
172
173/// Per-agent runtime context: cancellation + event sink + run association.
174#[derive(Clone)]
175pub struct RunContext {
176    pub run_id: RunId,
177    pub cancel: CancellationToken,
178    pub events: EventSender,
179}
180
181/// Tool permission policy. v0.1 translates to a backend's acceptEdits + command
182/// allowlist.
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184pub struct ToolPolicy {
185    pub accept_edits: bool,
186    pub allow_commands: Vec<String>,
187    pub allow_mcp: Vec<String>,
188    /// Explicit denies (highest precedence).
189    pub deny: Vec<String>,
190}
191
192/// MCP data-plane endpoint injected into an agent for structured reporting.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct McpEndpoint {
195    /// Server name injected into the agent, e.g. "luft".
196    pub name: String,
197    pub url: String,
198    pub run_id: RunId,
199    pub agent_id: AgentId,
200    pub auth_token: Option<String>,
201}
202
203#[derive(thiserror::Error, Debug)]
204pub enum BackendError {
205    #[error("spawn failed: {0}")]
206    Spawn(String),
207    #[error("protocol error: {0}")]
208    Protocol(String),
209    #[error("connection error: {0}")]
210    Connection(String),
211    #[error("backend timed out")]
212    Timeout,
213    #[error("cancelled")]
214    Cancelled,
215    #[error("configuration error: {0}")]
216    Config(String),
217    #[error("IO error: {0}")]
218    Io(String),
219    #[error("parse error: {0}")]
220    Parse(String),
221    #[error("execution error: {0}")]
222    Execution(String),
223    #[error(transparent)]
224    Other(#[from] anyhow::Error),
225}
226
227impl BackendError {
228    /// Distinguish retryable (transient/timeout) from non-retryable (protocol/logic).
229    pub fn is_retryable(&self) -> bool {
230        matches!(self, BackendError::Timeout | BackendError::Spawn(_))
231    }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Artifact {
236    pub key: String,
237    pub path: Option<PathBuf>,
238    pub inline: Option<serde_json::Value>,
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
242pub struct LogRef {
243    pub path: PathBuf,
244}
245
246#[cfg(test)]
247mod tests {
248    //! Tests for the `AgentStatus::as_str()` contract introduced by F5.
249    //!
250    //! The persisted `AgentResultCache.status` string is part of the on-disk
251    //! checkpoint contract. Before F5 it was derived from `Debug` formatting,
252    //! which silently broke when a variant was renamed (`TimedOut` → `"TimedOut"`
253    //! → `"timedout"`). The explicit `as_str()` mapping pins the strings so that
254    //! future renames cannot regress existing checkpoints.
255
256    use super::*;
257
258    #[test]
259    fn as_str_ok_returns_ok() {
260        assert_eq!(AgentStatus::Ok.as_str(), "ok");
261    }
262
263    #[test]
264    fn as_str_error_returns_error() {
265        assert_eq!(AgentStatus::Error.as_str(), "error");
266    }
267
268    #[test]
269    fn as_str_cancelled_returns_cancelled() {
270        assert_eq!(AgentStatus::Cancelled.as_str(), "cancelled");
271    }
272
273    #[test]
274    fn as_str_timed_out_returns_snake_case_timed_out() {
275        // The KEY F5 invariant: `TimedOut` Debug is "TimedOut" (lowercased
276        // "timedout"), but the persisted string MUST be "timed_out" with an
277        // underscore so it matches the surrounding snake_case contract.
278        assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
279    }
280
281    #[test]
282    fn as_str_timed_out_differs_from_debug_lowercased() {
283        // Regression guard: the bug being fixed. If this ever flips to
284        // `format!("{:?}", status).to_lowercase()`, `TimedOut` would yield
285        // "timedout" (no underscore) and silently corrupt existing checkpoints.
286        let debug_lower = format!("{:?}", AgentStatus::TimedOut).to_lowercase();
287        assert_ne!(AgentStatus::TimedOut.as_str(), debug_lower);
288        assert_eq!(debug_lower, "timedout");
289        assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
290    }
291
292    #[test]
293    fn as_str_values_are_unique() {
294        let variants = [
295            AgentStatus::Ok.as_str(),
296            AgentStatus::Error.as_str(),
297            AgentStatus::Cancelled.as_str(),
298            AgentStatus::TimedOut.as_str(),
299        ];
300        for i in 0..variants.len() {
301            for j in (i + 1)..variants.len() {
302                assert_ne!(
303                    variants[i], variants[j],
304                    "AgentStatus::as_str() must produce distinct strings for each variant \
305                     (collision between {:?} and {:?})",
306                    variants[i], variants[j]
307                );
308            }
309        }
310    }
311
312    #[test]
313    fn as_str_values_are_non_empty_and_ascii() {
314        for variant in [
315            AgentStatus::Ok,
316            AgentStatus::Error,
317            AgentStatus::Cancelled,
318            AgentStatus::TimedOut,
319        ] {
320            let s = variant.as_str();
321            assert!(!s.is_empty(), "as_str() must not return empty strings");
322            assert!(
323                s.is_ascii(),
324                "as_str() must return ASCII-only strings (got: {:?})",
325                s
326            );
327        }
328    }
329
330    #[test]
331    fn as_str_values_are_snake_case_or_lowercase() {
332        // Each returned string must be either pure lowercase ASCII or
333        // snake_case (lowercase ASCII letters separated by single underscores).
334        // This matches the convention used elsewhere in the codebase
335        // (CheckpointStatus via `rename_all = "lowercase"`, RunStatus, etc.).
336        for variant in [
337            AgentStatus::Ok,
338            AgentStatus::Error,
339            AgentStatus::Cancelled,
340            AgentStatus::TimedOut,
341        ] {
342            let s = variant.as_str();
343            for c in s.chars() {
344                let ok = c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit();
345                assert!(
346                    ok,
347                    "as_str() must return snake_case / lowercase (got {:?} in {:?})",
348                    c, s
349                );
350            }
351            assert!(
352                !s.contains("__"),
353                "as_str() must not produce consecutive underscores (got {:?})",
354                s
355            );
356            assert!(
357                !s.starts_with('_') && !s.ends_with('_'),
358                "as_str() must not start or end with underscore (got {:?})",
359                s
360            );
361        }
362    }
363
364    #[test]
365    fn as_str_return_type_is_static_str() {
366        // Compile-time check: the signature must return &'static str so the
367        // string outlives any AgentStatus instance and the literal lives in
368        // the binary's read-only data.
369        fn returns_static(s: AgentStatus) -> &'static str {
370            s.as_str()
371        }
372        let _: &'static str = returns_static(AgentStatus::Ok);
373    }
374
375    #[test]
376    fn as_str_matches_storage_writer_canonical_mapping() {
377        // The storage layer (`storage/writer.rs::agent_status_str`) already
378        // canonicalises AgentStatus into the snake_case strings that the
379        // on-disk SQLite tables consume. The F5 contract requires that
380        // `AgentStatus::as_str()` agrees with this canonical mapping so
381        // checkpoint persistence and storage persistence do not drift apart.
382        const STORAGE_CANONICAL: &[(&str, AgentStatus)] = &[
383            ("ok", AgentStatus::Ok),
384            ("error", AgentStatus::Error),
385            ("cancelled", AgentStatus::Cancelled),
386            ("timed_out", AgentStatus::TimedOut),
387        ];
388        for (expected, variant) in STORAGE_CANONICAL {
389            assert_eq!(
390                variant.as_str(),
391                *expected,
392                "AgentStatus::{:?}::as_str() must equal {:?} (storage canonical)",
393                variant,
394                expected
395            );
396        }
397    }
398}