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