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 workflow_validate_schema: 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// ── Process-global current backend identity ────────────────────────────
256
257/// Identity of the backend currently connected to this process, captured
258/// at the ACP `initialize` handshake.
259///
260/// Stored process-globally so any code path — not only the adapter itself —
261/// can answer "which backend am I talking to?". The agent primitive resolves
262/// its default backend (when the script omits `backend`) from this store via
263/// the [`id`](Self::id) field, rather than each caller threading an explicit
264/// `backend` argument through every call.
265///
266/// **Semantics**: written by the ACP adapter on every handshake. In the
267/// current single-backend model the value is constant for a run's lifetime.
268/// With multiple backends it becomes "most recently handshaken", so readers
269/// must tolerate concurrent writers; the scheduler falls back to the
270/// registry's designated default when the captured id is stale or missing.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct CurrentBackend {
273 /// Registry key of this backend (i.e. [`AgentBackend::id`], e.g.
274 /// `"opencode"`, `"codex"`). This is the value the scheduler uses to route
275 /// a default (`backend`-omitted) agent, and it is set from the adapter's
276 /// own `config.id` — so it is guaranteed to match a registry key, unlike
277 /// the ACP-reported `name`.
278 pub id: String,
279 /// Backend implementation name as reported by the ACP peer, e.g.
280 /// "opencode", "codex". Display/metadata only — not used for routing.
281 pub name: String,
282 /// Backend implementation version.
283 pub version: String,
284 /// Optional human-readable title.
285 pub title: Option<String>,
286 /// Client identity sent by Luft during the ACP `initialize` handshake.
287 pub client: ClientIdentity,
288}
289
290/// Luft's own identity as sent in the `client_info` field of the ACP
291/// `InitializeRequest`. Stored so downstream code can log or surface
292/// which Luft version initiated the session.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct ClientIdentity {
295 pub name: String,
296 pub version: String,
297 pub title: Option<String>,
298}
299
300static CURRENT_BACKEND: std::sync::RwLock<Option<CurrentBackend>> =
301 std::sync::RwLock::new(None);
302
303/// Record the backend identity captured during a handshake. Called by the
304/// ACP adapter after `initialize`. Overwrites any prior value.
305///
306/// # Panics
307///
308/// Only if the internal lock is poisoned (a writer panicked while holding
309/// it), which indicates a logic bug rather than a runtime condition.
310pub fn set_current_backend(b: CurrentBackend) {
311 let mut g = CURRENT_BACKEND
312 .write()
313 .expect("CURRENT_BACKEND lock poisoned");
314 *g = Some(b);
315}
316
317/// Read the current backend identity, if a handshake has completed yet.
318///
319/// Returns `None` before any ACP connection has been established (e.g. a
320/// `luft run` using a non-ACP backend, or before the first agent task).
321pub fn current_backend() -> Option<CurrentBackend> {
322 CURRENT_BACKEND
323 .read()
324 .expect("CURRENT_BACKEND lock poisoned")
325 .clone()
326}
327
328/// Clear the recorded current backend identity. Primarily for tests, so a
329/// `current_backend`-dependent assertion does not leak into the next test.
330pub fn clear_current_backend() {
331 *CURRENT_BACKEND
332 .write()
333 .expect("CURRENT_BACKEND lock poisoned") = None;
334}
335
336#[cfg(test)]
337mod tests {
338 //! Tests for the `AgentStatus::as_str()` contract introduced by F5.
339 //!
340 //! The persisted `AgentResultCache.status` string is part of the on-disk
341 //! checkpoint contract. Before F5 it was derived from `Debug` formatting,
342 //! which silently broke when a variant was renamed (`TimedOut` → `"TimedOut"`
343 //! → `"timedout"`). The explicit `as_str()` mapping pins the strings so that
344 //! future renames cannot regress existing checkpoints.
345
346 use super::*;
347
348 #[test]
349 fn as_str_ok_returns_ok() {
350 assert_eq!(AgentStatus::Ok.as_str(), "ok");
351 }
352
353 #[test]
354 fn as_str_error_returns_error() {
355 assert_eq!(AgentStatus::Error.as_str(), "error");
356 }
357
358 #[test]
359 fn as_str_cancelled_returns_cancelled() {
360 assert_eq!(AgentStatus::Cancelled.as_str(), "cancelled");
361 }
362
363 #[test]
364 fn as_str_timed_out_returns_snake_case_timed_out() {
365 // The KEY F5 invariant: `TimedOut` Debug is "TimedOut" (lowercased
366 // "timedout"), but the persisted string MUST be "timed_out" with an
367 // underscore so it matches the surrounding snake_case contract.
368 assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
369 }
370
371 #[test]
372 fn as_str_timed_out_differs_from_debug_lowercased() {
373 // Regression guard: the bug being fixed. If this ever flips to
374 // `format!("{:?}", status).to_lowercase()`, `TimedOut` would yield
375 // "timedout" (no underscore) and silently corrupt existing checkpoints.
376 let debug_lower = format!("{:?}", AgentStatus::TimedOut).to_lowercase();
377 assert_ne!(AgentStatus::TimedOut.as_str(), debug_lower);
378 assert_eq!(debug_lower, "timedout");
379 assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
380 }
381
382 #[test]
383 fn as_str_values_are_unique() {
384 let variants = [
385 AgentStatus::Ok.as_str(),
386 AgentStatus::Error.as_str(),
387 AgentStatus::Cancelled.as_str(),
388 AgentStatus::TimedOut.as_str(),
389 ];
390 for i in 0..variants.len() {
391 for j in (i + 1)..variants.len() {
392 assert_ne!(
393 variants[i], variants[j],
394 "AgentStatus::as_str() must produce distinct strings for each variant \
395 (collision between {:?} and {:?})",
396 variants[i], variants[j]
397 );
398 }
399 }
400 }
401
402 #[test]
403 fn as_str_values_are_non_empty_and_ascii() {
404 for variant in [
405 AgentStatus::Ok,
406 AgentStatus::Error,
407 AgentStatus::Cancelled,
408 AgentStatus::TimedOut,
409 ] {
410 let s = variant.as_str();
411 assert!(!s.is_empty(), "as_str() must not return empty strings");
412 assert!(
413 s.is_ascii(),
414 "as_str() must return ASCII-only strings (got: {:?})",
415 s
416 );
417 }
418 }
419
420 #[test]
421 fn as_str_values_are_snake_case_or_lowercase() {
422 // Each returned string must be either pure lowercase ASCII or
423 // snake_case (lowercase ASCII letters separated by single underscores).
424 // This matches the convention used elsewhere in the codebase
425 // (CheckpointStatus via `rename_all = "lowercase"`, RunStatus, etc.).
426 for variant in [
427 AgentStatus::Ok,
428 AgentStatus::Error,
429 AgentStatus::Cancelled,
430 AgentStatus::TimedOut,
431 ] {
432 let s = variant.as_str();
433 for c in s.chars() {
434 let ok = c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit();
435 assert!(
436 ok,
437 "as_str() must return snake_case / lowercase (got {:?} in {:?})",
438 c, s
439 );
440 }
441 assert!(
442 !s.contains("__"),
443 "as_str() must not produce consecutive underscores (got {:?})",
444 s
445 );
446 assert!(
447 !s.starts_with('_') && !s.ends_with('_'),
448 "as_str() must not start or end with underscore (got {:?})",
449 s
450 );
451 }
452 }
453
454 #[test]
455 fn as_str_return_type_is_static_str() {
456 // Compile-time check: the signature must return &'static str so the
457 // string outlives any AgentStatus instance and the literal lives in
458 // the binary's read-only data.
459 fn returns_static(s: AgentStatus) -> &'static str {
460 s.as_str()
461 }
462 let _: &'static str = returns_static(AgentStatus::Ok);
463 }
464
465 #[test]
466 fn as_str_matches_storage_writer_canonical_mapping() {
467 // The storage layer (`storage/writer.rs::agent_status_str`) already
468 // canonicalises AgentStatus into the snake_case strings that the
469 // on-disk SQLite tables consume. The F5 contract requires that
470 // `AgentStatus::as_str()` agrees with this canonical mapping so
471 // checkpoint persistence and storage persistence do not drift apart.
472 const STORAGE_CANONICAL: &[(&str, AgentStatus)] = &[
473 ("ok", AgentStatus::Ok),
474 ("error", AgentStatus::Error),
475 ("cancelled", AgentStatus::Cancelled),
476 ("timed_out", AgentStatus::TimedOut),
477 ];
478 for (expected, variant) in STORAGE_CANONICAL {
479 assert_eq!(
480 variant.as_str(),
481 *expected,
482 "AgentStatus::{:?}::as_str() must equal {:?} (storage canonical)",
483 variant,
484 expected
485 );
486 }
487 }
488}