use crate::contract::event::EventSender;
use crate::contract::finding::Finding;
use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
#[async_trait]
pub trait AgentBackend: Send + Sync {
fn id(&self) -> &'static str;
fn capabilities(&self) -> AgentCapabilities;
async fn run(&self, task: AgentTask, ctx: RunContext) -> Result<AgentResult, BackendError>;
fn as_any(&self) -> &dyn std::any::Any;
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentCapabilities {
pub streaming: bool,
pub mcp_injection: bool,
pub structured_output: bool,
pub models: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTask {
pub agent_id: AgentId,
pub phase_id: PhaseId,
pub prompt: String,
pub model: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub agent_seq: u32,
pub allowlist: Option<ToolPolicy>,
pub workdir: PathBuf,
pub mcp_endpoint: Option<McpEndpoint>,
pub timeout: Option<Duration>,
pub output_schema: Option<serde_json::Value>,
#[serde(default)]
pub workdir_override: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentResult {
pub agent_id: AgentId,
pub status: AgentStatus,
pub output: serde_json::Value,
#[serde(default)]
pub findings: Vec<Finding>,
pub tokens_used: TokenUsage,
#[serde(default)]
pub artifacts: Vec<Artifact>,
pub logs: LogRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AgentStatus {
Ok,
Error,
Cancelled,
TimedOut,
}
impl AgentStatus {
pub fn as_str(&self) -> &'static str {
match self {
AgentStatus::Ok => "ok",
AgentStatus::Error => "error",
AgentStatus::Cancelled => "cancelled",
AgentStatus::TimedOut => "timed_out",
}
}
}
#[derive(Clone)]
pub struct RunContext {
pub run_id: RunId,
pub cancel: CancellationToken,
pub events: EventSender,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolPolicy {
pub accept_edits: bool,
pub allow_commands: Vec<String>,
pub allow_mcp: Vec<String>,
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpEndpoint {
pub name: String,
pub url: String,
pub run_id: RunId,
pub agent_id: AgentId,
pub auth_token: Option<String>,
}
#[derive(thiserror::Error, Debug)]
pub enum BackendError {
#[error("spawn failed: {0}")]
Spawn(String),
#[error("protocol error: {0}")]
Protocol(String),
#[error("connection error: {0}")]
Connection(String),
#[error("backend timed out")]
Timeout,
#[error("cancelled")]
Cancelled,
#[error("configuration error: {0}")]
Config(String),
#[error("IO error: {0}")]
Io(String),
#[error("parse error: {0}")]
Parse(String),
#[error("execution error: {0}")]
Execution(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl BackendError {
pub fn is_retryable(&self) -> bool {
matches!(self, BackendError::Timeout | BackendError::Spawn(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
pub key: String,
pub path: Option<PathBuf>,
pub inline: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LogRef {
pub path: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn as_str_ok_returns_ok() {
assert_eq!(AgentStatus::Ok.as_str(), "ok");
}
#[test]
fn as_str_error_returns_error() {
assert_eq!(AgentStatus::Error.as_str(), "error");
}
#[test]
fn as_str_cancelled_returns_cancelled() {
assert_eq!(AgentStatus::Cancelled.as_str(), "cancelled");
}
#[test]
fn as_str_timed_out_returns_snake_case_timed_out() {
assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
}
#[test]
fn as_str_timed_out_differs_from_debug_lowercased() {
let debug_lower = format!("{:?}", AgentStatus::TimedOut).to_lowercase();
assert_ne!(AgentStatus::TimedOut.as_str(), debug_lower);
assert_eq!(debug_lower, "timedout");
assert_eq!(AgentStatus::TimedOut.as_str(), "timed_out");
}
#[test]
fn as_str_values_are_unique() {
let variants = [
AgentStatus::Ok.as_str(),
AgentStatus::Error.as_str(),
AgentStatus::Cancelled.as_str(),
AgentStatus::TimedOut.as_str(),
];
for i in 0..variants.len() {
for j in (i + 1)..variants.len() {
assert_ne!(
variants[i], variants[j],
"AgentStatus::as_str() must produce distinct strings for each variant \
(collision between {:?} and {:?})",
variants[i], variants[j]
);
}
}
}
#[test]
fn as_str_values_are_non_empty_and_ascii() {
for variant in [
AgentStatus::Ok,
AgentStatus::Error,
AgentStatus::Cancelled,
AgentStatus::TimedOut,
] {
let s = variant.as_str();
assert!(!s.is_empty(), "as_str() must not return empty strings");
assert!(
s.is_ascii(),
"as_str() must return ASCII-only strings (got: {:?})",
s
);
}
}
#[test]
fn as_str_values_are_snake_case_or_lowercase() {
for variant in [
AgentStatus::Ok,
AgentStatus::Error,
AgentStatus::Cancelled,
AgentStatus::TimedOut,
] {
let s = variant.as_str();
for c in s.chars() {
let ok = c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit();
assert!(
ok,
"as_str() must return snake_case / lowercase (got {:?} in {:?})",
c, s
);
}
assert!(
!s.contains("__"),
"as_str() must not produce consecutive underscores (got {:?})",
s
);
assert!(
!s.starts_with('_') && !s.ends_with('_'),
"as_str() must not start or end with underscore (got {:?})",
s
);
}
}
#[test]
fn as_str_return_type_is_static_str() {
fn returns_static(s: AgentStatus) -> &'static str {
s.as_str()
}
let _: &'static str = returns_static(AgentStatus::Ok);
}
#[test]
fn as_str_matches_storage_writer_canonical_mapping() {
const STORAGE_CANONICAL: &[(&str, AgentStatus)] = &[
("ok", AgentStatus::Ok),
("error", AgentStatus::Error),
("cancelled", AgentStatus::Cancelled),
("timed_out", AgentStatus::TimedOut),
];
for (expected, variant) in STORAGE_CANONICAL {
assert_eq!(
variant.as_str(),
*expected,
"AgentStatus::{:?}::as_str() must equal {:?} (storage canonical)",
variant,
expected
);
}
}
}