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//!             session_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 workflow_validate_schema: bool,
105    /// Whether the backend can attempt to continue a supplied session.
106    /// Backends may still reject an expired session at runtime.
107    #[serde(default)]
108    pub session_resume: bool,
109    /// Known model ids; empty = unknown/any.
110    pub models: Vec<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct AgentTask {
115    pub agent_id: AgentId,
116    pub phase_id: PhaseId,
117    pub prompt: String,
118    pub model: Option<String>,
119    #[serde(default)]
120    pub description: Option<String>,
121    #[serde(default)]
122    pub role: Option<String>,
123    #[serde(default)]
124    pub name: Option<String>,
125    #[serde(default)]
126    pub agent_seq: u32,
127    pub allowlist: Option<ToolPolicy>,
128    pub workdir: PathBuf,
129    /// Data-plane injection point (Luft MCP endpoint).
130    pub mcp_endpoint: Option<McpEndpoint>,
131    /// Idle timeout: maximum silence (no ACP notifications) before the backend
132    /// kills the session. `None` = backend default (5 min).
133    pub timeout: Option<Duration>,
134    /// Optional JSON Schema (M4) for validating agent output.
135    /// When set, the runtime validates the agent's output against this schema
136    /// and may retry or reject if validation fails.
137    pub output_schema: Option<serde_json::Value>,
138
139    /// Per-agent working directory override from Lua `working_folder` opt.
140    #[serde(default)]
141    pub workdir_override: Option<PathBuf>,
142
143    /// Thread ID for cross-process conversation resume (Loom SqliteSaver).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub session_id: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct AgentResult {
150    pub agent_id: AgentId,
151    pub status: AgentStatus,
152    /// Structured output: prefers aggregated MCP findings, falls back to parsed
153    /// final message.
154    pub output: serde_json::Value,
155    #[serde(default)]
156    pub findings: Vec<Finding>,
157    pub tokens_used: TokenUsage,
158    #[serde(default)]
159    pub artifacts: Vec<Artifact>,
160    pub logs: LogRef,
161
162    /// Thread ID used during execution, echoed back for checkpoint linking.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub session_id: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168pub enum AgentStatus {
169    Ok,
170    Error,
171    Cancelled,
172    TimedOut,
173}
174
175impl AgentStatus {
176    pub fn as_str(&self) -> &'static str {
177        match self {
178            AgentStatus::Ok => "ok",
179            AgentStatus::Error => "error",
180            AgentStatus::Cancelled => "cancelled",
181            AgentStatus::TimedOut => "timed_out",
182        }
183    }
184}
185
186/// Per-agent runtime context: cancellation + event sink + run association.
187#[derive(Clone)]
188pub struct RunContext {
189    pub run_id: RunId,
190    pub cancel: CancellationToken,
191    pub events: EventSender,
192}
193
194/// Tool permission policy. v0.1 translates to a backend's acceptEdits + command
195/// allowlist.
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197pub struct ToolPolicy {
198    pub accept_edits: bool,
199    pub allow_commands: Vec<String>,
200    pub allow_mcp: Vec<String>,
201    /// Explicit denies (highest precedence).
202    pub deny: Vec<String>,
203}
204
205/// MCP data-plane endpoint injected into an agent for structured reporting.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct McpEndpoint {
208    /// Server name injected into the agent, e.g. "luft".
209    pub name: String,
210    pub url: String,
211    pub run_id: RunId,
212    pub agent_id: AgentId,
213    pub auth_token: Option<String>,
214}
215
216#[derive(thiserror::Error, Debug)]
217pub enum BackendError {
218    #[error("spawn failed: {0}")]
219    Spawn(String),
220    #[error("protocol error: {0}")]
221    Protocol(String),
222    #[error("connection error: {0}")]
223    Connection(String),
224    #[error("backend timed out")]
225    Timeout,
226    #[error("cancelled")]
227    Cancelled,
228    #[error("configuration error: {0}")]
229    Config(String),
230    #[error("IO error: {0}")]
231    Io(String),
232    #[error("parse error: {0}")]
233    Parse(String),
234    #[error("execution error: {0}")]
235    Execution(String),
236    #[error(transparent)]
237    Other(#[from] anyhow::Error),
238}
239
240impl BackendError {
241    /// Distinguish retryable (transient/timeout) from non-retryable (protocol/logic).
242    pub fn is_retryable(&self) -> bool {
243        matches!(self, BackendError::Timeout | BackendError::Spawn(_))
244    }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct Artifact {
249    pub key: String,
250    pub path: Option<PathBuf>,
251    pub inline: Option<serde_json::Value>,
252}
253
254#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
255pub struct LogRef {
256    pub path: PathBuf,
257}
258
259// ── Process-global current backend identity ────────────────────────────
260
261/// Identity of the backend currently connected to this process, captured
262/// at the ACP `initialize` handshake.
263///
264/// Stored process-globally so any code path — not only the adapter itself —
265/// can answer "which backend am I talking to?". The agent primitive resolves
266/// its default backend (when the script omits `backend`) from this store via
267/// the [`id`](Self::id) field, rather than each caller threading an explicit
268/// `backend` argument through every call.
269///
270/// **Semantics**: written by the ACP adapter on every handshake. In the
271/// current single-backend model the value is constant for a run's lifetime.
272/// With multiple backends it becomes "most recently handshaken", so readers
273/// must tolerate concurrent writers; the scheduler falls back to the
274/// registry's designated default when the captured id is stale or missing.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct CurrentBackend {
277    /// Registry key of this backend (i.e. [`AgentBackend::id`], e.g.
278    /// `"opencode"`, `"codex"`). This is the value the scheduler uses to route
279    /// a default (`backend`-omitted) agent, and it is set from the adapter's
280    /// own `config.id` — so it is guaranteed to match a registry key, unlike
281    /// the ACP-reported `name`.
282    pub id: String,
283    /// Backend implementation name as reported by the ACP peer, e.g.
284    /// "opencode", "codex". Display/metadata only — not used for routing.
285    pub name: String,
286    /// Backend implementation version.
287    pub version: String,
288    /// Optional human-readable title.
289    pub title: Option<String>,
290    /// Client identity sent by Luft during the ACP `initialize` handshake.
291    pub client: ClientIdentity,
292}
293
294/// Luft's own identity as sent in the `client_info` field of the ACP
295/// `InitializeRequest`. Stored so downstream code can log or surface
296/// which Luft version initiated the session.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct ClientIdentity {
299    pub name: String,
300    pub version: String,
301    pub title: Option<String>,
302}
303
304static CURRENT_BACKEND: std::sync::RwLock<Option<CurrentBackend>> =
305    std::sync::RwLock::new(None);
306
307/// Record the backend identity captured during a handshake. Called by the
308/// ACP adapter after `initialize`. Overwrites any prior value.
309///
310/// # Panics
311///
312/// Only if the internal lock is poisoned (a writer panicked while holding
313/// it), which indicates a logic bug rather than a runtime condition.
314pub fn set_current_backend(b: CurrentBackend) {
315    let mut g = CURRENT_BACKEND
316        .write()
317        .expect("CURRENT_BACKEND lock poisoned");
318    *g = Some(b);
319}
320
321/// Read the current backend identity, if a handshake has completed yet.
322///
323/// Returns `None` before any ACP connection has been established (e.g. a
324/// `luft run` using a non-ACP backend, or before the first agent task).
325pub fn current_backend() -> Option<CurrentBackend> {
326    CURRENT_BACKEND
327        .read()
328        .expect("CURRENT_BACKEND lock poisoned")
329        .clone()
330}
331
332/// Clear the recorded current backend identity. Primarily for tests, so a
333/// `current_backend`-dependent assertion does not leak into the next test.
334pub fn clear_current_backend() {
335    *CURRENT_BACKEND
336        .write()
337        .expect("CURRENT_BACKEND lock poisoned") = None;
338}
339
340#[cfg(test)]
341mod tests {
342    //! Tests for the `AgentStatus::as_str()` contract introduced by F5.
343    //!
344    //! The persisted `AgentResultCache.status` string is part of the on-disk
345    //! checkpoint contract. Before F5 it was derived from `Debug` formatting,
346    //! which silently broke when a variant was renamed (`TimedOut` → `"TimedOut"`
347    //! → `"timedout"`). The explicit `as_str()` mapping pins the strings so that
348    //! future renames cannot regress existing checkpoints.
349
350    use super::*;
351
352    #[test]
353    fn as_str_ok_returns_ok() {
354        assert_eq!(AgentStatus::Ok.as_str(), "ok");
355    }
356
357    #[test]
358    fn as_str_error_returns_error() {
359        assert_eq!(AgentStatus::Error.as_str(), "error");
360    }
361
362    #[test]
363    fn as_str_cancelled_returns_cancelled() {
364        assert_eq!(AgentStatus::Cancelled.as_str(), "cancelled");
365    }
366
367    #[test]
368    fn as_str_timed_out_returns_snake_case_timed_out() {
369        // The KEY F5 invariant: `TimedOut` Debug is "TimedOut" (lowercased
370        // "timedout"), but the persisted string MUST be "timed_out" with an
371        // underscore so it matches the surrounding snake_case contract.
372        assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
373    }
374
375    #[test]
376    fn as_str_timed_out_differs_from_debug_lowercased() {
377        // Regression guard: the bug being fixed. If this ever flips to
378        // `format!("{:?}", status).to_lowercase()`, `TimedOut` would yield
379        // "timedout" (no underscore) and silently corrupt existing checkpoints.
380        let debug_lower = format!("{:?}", AgentStatus::TimedOut).to_lowercase();
381        assert_ne!(AgentStatus::TimedOut.as_str(), debug_lower);
382        assert_eq!(debug_lower, "timedout");
383        assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
384    }
385
386    #[test]
387    fn as_str_values_are_unique() {
388        let variants = [
389            AgentStatus::Ok.as_str(),
390            AgentStatus::Error.as_str(),
391            AgentStatus::Cancelled.as_str(),
392            AgentStatus::TimedOut.as_str(),
393        ];
394        for i in 0..variants.len() {
395            for j in (i + 1)..variants.len() {
396                assert_ne!(
397                    variants[i], variants[j],
398                    "AgentStatus::as_str() must produce distinct strings for each variant \
399                     (collision between {:?} and {:?})",
400                    variants[i], variants[j]
401                );
402            }
403        }
404    }
405
406    #[test]
407    fn as_str_values_are_non_empty_and_ascii() {
408        for variant in [
409            AgentStatus::Ok,
410            AgentStatus::Error,
411            AgentStatus::Cancelled,
412            AgentStatus::TimedOut,
413        ] {
414            let s = variant.as_str();
415            assert!(!s.is_empty(), "as_str() must not return empty strings");
416            assert!(
417                s.is_ascii(),
418                "as_str() must return ASCII-only strings (got: {:?})",
419                s
420            );
421        }
422    }
423
424    #[test]
425    fn as_str_values_are_snake_case_or_lowercase() {
426        // Each returned string must be either pure lowercase ASCII or
427        // snake_case (lowercase ASCII letters separated by single underscores).
428        // This matches the convention used elsewhere in the codebase
429        // (CheckpointStatus via `rename_all = "lowercase"`, RunStatus, etc.).
430        for variant in [
431            AgentStatus::Ok,
432            AgentStatus::Error,
433            AgentStatus::Cancelled,
434            AgentStatus::TimedOut,
435        ] {
436            let s = variant.as_str();
437            for c in s.chars() {
438                let ok = c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit();
439                assert!(
440                    ok,
441                    "as_str() must return snake_case / lowercase (got {:?} in {:?})",
442                    c, s
443                );
444            }
445            assert!(
446                !s.contains("__"),
447                "as_str() must not produce consecutive underscores (got {:?})",
448                s
449            );
450            assert!(
451                !s.starts_with('_') && !s.ends_with('_'),
452                "as_str() must not start or end with underscore (got {:?})",
453                s
454            );
455        }
456    }
457
458    #[test]
459    fn as_str_return_type_is_static_str() {
460        // Compile-time check: the signature must return &'static str so the
461        // string outlives any AgentStatus instance and the literal lives in
462        // the binary's read-only data.
463        fn returns_static(s: AgentStatus) -> &'static str {
464            s.as_str()
465        }
466        let _: &'static str = returns_static(AgentStatus::Ok);
467    }
468
469    #[test]
470    fn as_str_matches_storage_writer_canonical_mapping() {
471        // The storage layer (`storage/writer.rs::agent_status_str`) already
472        // canonicalises AgentStatus into the snake_case strings that the
473        // on-disk SQLite tables consume. The F5 contract requires that
474        // `AgentStatus::as_str()` agrees with this canonical mapping so
475        // checkpoint persistence and storage persistence do not drift apart.
476        const STORAGE_CANONICAL: &[(&str, AgentStatus)] = &[
477            ("ok", AgentStatus::Ok),
478            ("error", AgentStatus::Error),
479            ("cancelled", AgentStatus::Cancelled),
480            ("timed_out", AgentStatus::TimedOut),
481        ];
482        for (expected, variant) in STORAGE_CANONICAL {
483            assert_eq!(
484                variant.as_str(),
485                *expected,
486                "AgentStatus::{:?}::as_str() must equal {:?} (storage canonical)",
487                variant,
488                expected
489            );
490        }
491    }
492}