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
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct AgentResult {
137 pub agent_id: AgentId,
138 pub status: AgentStatus,
139 /// Structured output: prefers aggregated MCP findings, falls back to parsed
140 /// final message.
141 pub output: serde_json::Value,
142 #[serde(default)]
143 pub findings: Vec<Finding>,
144 pub tokens_used: TokenUsage,
145 #[serde(default)]
146 pub artifacts: Vec<Artifact>,
147 pub logs: LogRef,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151pub enum AgentStatus {
152 Ok,
153 Error,
154 Cancelled,
155 TimedOut,
156}
157
158impl AgentStatus {
159 pub fn as_str(&self) -> &'static str {
160 match self {
161 AgentStatus::Ok => "ok",
162 AgentStatus::Error => "error",
163 AgentStatus::Cancelled => "cancelled",
164 AgentStatus::TimedOut => "timed_out",
165 }
166 }
167}
168
169/// Per-agent runtime context: cancellation + event sink + run association.
170#[derive(Clone)]
171pub struct RunContext {
172 pub run_id: RunId,
173 pub cancel: CancellationToken,
174 pub events: EventSender,
175}
176
177/// Tool permission policy. v0.1 translates to a backend's acceptEdits + command
178/// allowlist.
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180pub struct ToolPolicy {
181 pub accept_edits: bool,
182 pub allow_commands: Vec<String>,
183 pub allow_mcp: Vec<String>,
184 /// Explicit denies (highest precedence).
185 pub deny: Vec<String>,
186}
187
188/// MCP data-plane endpoint injected into an agent for structured reporting.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct McpEndpoint {
191 /// Server name injected into the agent, e.g. "luft".
192 pub name: String,
193 pub url: String,
194 pub run_id: RunId,
195 pub agent_id: AgentId,
196 pub auth_token: Option<String>,
197}
198
199#[derive(thiserror::Error, Debug)]
200pub enum BackendError {
201 #[error("spawn failed: {0}")]
202 Spawn(String),
203 #[error("protocol error: {0}")]
204 Protocol(String),
205 #[error("connection error: {0}")]
206 Connection(String),
207 #[error("backend timed out")]
208 Timeout,
209 #[error("cancelled")]
210 Cancelled,
211 #[error("configuration error: {0}")]
212 Config(String),
213 #[error("IO error: {0}")]
214 Io(String),
215 #[error("parse error: {0}")]
216 Parse(String),
217 #[error("execution error: {0}")]
218 Execution(String),
219 #[error(transparent)]
220 Other(#[from] anyhow::Error),
221}
222
223impl BackendError {
224 /// Distinguish retryable (transient/timeout) from non-retryable (protocol/logic).
225 pub fn is_retryable(&self) -> bool {
226 matches!(self, BackendError::Timeout | BackendError::Spawn(_))
227 }
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Artifact {
232 pub key: String,
233 pub path: Option<PathBuf>,
234 pub inline: Option<serde_json::Value>,
235}
236
237#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
238pub struct LogRef {
239 pub path: PathBuf,
240}
241
242#[cfg(test)]
243mod tests {
244 //! Tests for the `AgentStatus::as_str()` contract introduced by F5.
245 //!
246 //! The persisted `AgentResultCache.status` string is part of the on-disk
247 //! checkpoint contract. Before F5 it was derived from `Debug` formatting,
248 //! which silently broke when a variant was renamed (`TimedOut` → `"TimedOut"`
249 //! → `"timedout"`). The explicit `as_str()` mapping pins the strings so that
250 //! future renames cannot regress existing checkpoints.
251
252 use super::*;
253
254 #[test]
255 fn as_str_ok_returns_ok() {
256 assert_eq!(AgentStatus::Ok.as_str(), "ok");
257 }
258
259 #[test]
260 fn as_str_error_returns_error() {
261 assert_eq!(AgentStatus::Error.as_str(), "error");
262 }
263
264 #[test]
265 fn as_str_cancelled_returns_cancelled() {
266 assert_eq!(AgentStatus::Cancelled.as_str(), "cancelled");
267 }
268
269 #[test]
270 fn as_str_timed_out_returns_snake_case_timed_out() {
271 // The KEY F5 invariant: `TimedOut` Debug is "TimedOut" (lowercased
272 // "timedout"), but the persisted string MUST be "timed_out" with an
273 // underscore so it matches the surrounding snake_case contract.
274 assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
275 }
276
277 #[test]
278 fn as_str_timed_out_differs_from_debug_lowercased() {
279 // Regression guard: the bug being fixed. If this ever flips to
280 // `format!("{:?}", status).to_lowercase()`, `TimedOut` would yield
281 // "timedout" (no underscore) and silently corrupt existing checkpoints.
282 let debug_lower = format!("{:?}", AgentStatus::TimedOut).to_lowercase();
283 assert_ne!(AgentStatus::TimedOut.as_str(), debug_lower);
284 assert_eq!(debug_lower, "timedout");
285 assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
286 }
287
288 #[test]
289 fn as_str_values_are_unique() {
290 let variants = [
291 AgentStatus::Ok.as_str(),
292 AgentStatus::Error.as_str(),
293 AgentStatus::Cancelled.as_str(),
294 AgentStatus::TimedOut.as_str(),
295 ];
296 for i in 0..variants.len() {
297 for j in (i + 1)..variants.len() {
298 assert_ne!(
299 variants[i], variants[j],
300 "AgentStatus::as_str() must produce distinct strings for each variant \
301 (collision between {:?} and {:?})",
302 variants[i], variants[j]
303 );
304 }
305 }
306 }
307
308 #[test]
309 fn as_str_values_are_non_empty_and_ascii() {
310 for variant in [
311 AgentStatus::Ok,
312 AgentStatus::Error,
313 AgentStatus::Cancelled,
314 AgentStatus::TimedOut,
315 ] {
316 let s = variant.as_str();
317 assert!(!s.is_empty(), "as_str() must not return empty strings");
318 assert!(
319 s.is_ascii(),
320 "as_str() must return ASCII-only strings (got: {:?})",
321 s
322 );
323 }
324 }
325
326 #[test]
327 fn as_str_values_are_snake_case_or_lowercase() {
328 // Each returned string must be either pure lowercase ASCII or
329 // snake_case (lowercase ASCII letters separated by single underscores).
330 // This matches the convention used elsewhere in the codebase
331 // (CheckpointStatus via `rename_all = "lowercase"`, RunStatus, etc.).
332 for variant in [
333 AgentStatus::Ok,
334 AgentStatus::Error,
335 AgentStatus::Cancelled,
336 AgentStatus::TimedOut,
337 ] {
338 let s = variant.as_str();
339 for c in s.chars() {
340 let ok = c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit();
341 assert!(
342 ok,
343 "as_str() must return snake_case / lowercase (got {:?} in {:?})",
344 c, s
345 );
346 }
347 assert!(
348 !s.contains("__"),
349 "as_str() must not produce consecutive underscores (got {:?})",
350 s
351 );
352 assert!(
353 !s.starts_with('_') && !s.ends_with('_'),
354 "as_str() must not start or end with underscore (got {:?})",
355 s
356 );
357 }
358 }
359
360 #[test]
361 fn as_str_return_type_is_static_str() {
362 // Compile-time check: the signature must return &'static str so the
363 // string outlives any AgentStatus instance and the literal lives in
364 // the binary's read-only data.
365 fn returns_static(s: AgentStatus) -> &'static str {
366 s.as_str()
367 }
368 let _: &'static str = returns_static(AgentStatus::Ok);
369 }
370
371 #[test]
372 fn as_str_matches_storage_writer_canonical_mapping() {
373 // The storage layer (`storage/writer.rs::agent_status_str`) already
374 // canonicalises AgentStatus into the snake_case strings that the
375 // on-disk SQLite tables consume. The F5 contract requires that
376 // `AgentStatus::as_str()` agrees with this canonical mapping so
377 // checkpoint persistence and storage persistence do not drift apart.
378 const STORAGE_CANONICAL: &[(&str, AgentStatus)] = &[
379 ("ok", AgentStatus::Ok),
380 ("error", AgentStatus::Error),
381 ("cancelled", AgentStatus::Cancelled),
382 ("timed_out", AgentStatus::TimedOut),
383 ];
384 for (expected, variant) in STORAGE_CANONICAL {
385 assert_eq!(
386 variant.as_str(),
387 *expected,
388 "AgentStatus::{:?}::as_str() must equal {:?} (storage canonical)",
389 variant,
390 expected
391 );
392 }
393 }
394}