use std::path::PathBuf;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentContext {
pub segments: Vec<(String, String)>,
pub tokens_used: u64,
}
impl AgentContext {
pub fn new() -> Self {
Self::default()
}
pub fn push(mut self, key: impl Into<String>, content: impl Into<String>) -> Self {
self.segments.push((key.into(), content.into()));
self
}
pub fn render(&self) -> String {
if self.segments.is_empty() {
return String::new();
}
let mut out = String::new();
for (key, content) in &self.segments {
out.push_str("### ");
out.push_str(key);
out.push('\n');
out.push_str(content.trim());
out.push_str("\n\n");
}
out.trim_end().to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTask {
pub id: String,
pub description: String,
pub context: AgentContext,
pub workdir: Option<PathBuf>,
pub timeout_secs: u64,
}
impl AgentTask {
pub fn new(description: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
description: description.into(),
context: AgentContext::default(),
workdir: None,
timeout_secs: 300,
}
}
pub fn with_context(mut self, ctx: AgentContext) -> Self {
self.context = ctx;
self
}
pub fn with_workdir(mut self, workdir: impl Into<PathBuf>) -> Self {
self.workdir = Some(workdir.into());
self
}
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
pub kind: String,
pub reference: String,
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentTaskStatus {
Running,
Succeeded,
Failed,
Cancelled,
TimedOut,
}
impl AgentTaskStatus {
pub fn is_terminal(self) -> bool {
!matches!(self, AgentTaskStatus::Running)
}
pub fn is_success(self) -> bool {
matches!(self, AgentTaskStatus::Succeeded)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentResult {
pub task_id: String,
pub status: AgentTaskStatus,
pub summary: String,
#[serde(default)]
pub artifacts: Vec<Artifact>,
#[serde(default)]
pub stdout: String,
#[serde(default)]
pub stderr: String,
pub exit_code: Option<i32>,
pub started_at: DateTime<Utc>,
pub completed_at: DateTime<Utc>,
}
impl AgentResult {
pub fn duration_ms(&self) -> i64 {
(self.completed_at - self.started_at)
.num_milliseconds()
.max(0)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentCapabilities {
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub languages: Vec<String>,
#[serde(default = "default_concurrency")]
pub max_concurrency: u32,
#[serde(default)]
pub needs_network: bool,
}
fn default_concurrency() -> u32 {
1
}
#[derive(Debug, Error)]
pub enum AgentError {
#[error("delegate '{0}' not found in registry")]
NotFound(String),
#[error("delegate failed to launch: {0}")]
Launch(String),
#[error("delegate timed out after {secs}s: {task_id}")]
Timeout { task_id: String, secs: u64 },
#[error("delegate returned non-zero exit {code}: {stderr}")]
NonZeroExit { code: i32, stderr: String },
#[error("delegate output unparseable: {0}")]
Parse(String),
#[error("delegate cancelled: {0}")]
Cancelled(String),
#[error("IO error: {0}")]
Io(String),
#[error("other: {0}")]
Other(String),
}
#[async_trait]
pub trait AgentDelegate: Send + Sync {
fn name(&self) -> &str;
fn capabilities(&self) -> AgentCapabilities {
AgentCapabilities::default()
}
async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError>;
async fn cancel(&self, _task_id: &str) -> Result<(), AgentError> {
Ok(())
}
async fn status(&self, _task_id: &str) -> Result<AgentTaskStatus, AgentError> {
Ok(AgentTaskStatus::Running)
}
async fn health_check(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_render_round_trips() {
let ctx = AgentContext::new()
.push("memory", "fact A\nfact B")
.push("conventions", "use tabs");
let rendered = ctx.render();
assert!(rendered.contains("### memory"));
assert!(rendered.contains("fact A"));
assert!(rendered.contains("### conventions"));
assert!(rendered.contains("use tabs"));
}
#[test]
fn context_render_empty_returns_empty() {
assert!(AgentContext::new().render().is_empty());
}
#[test]
fn agent_task_new_generates_uuid() {
let a = AgentTask::new("a");
let b = AgentTask::new("b");
assert_ne!(a.id, b.id);
assert_eq!(a.timeout_secs, 300);
}
#[test]
fn task_status_terminality() {
assert!(!AgentTaskStatus::Running.is_terminal());
assert!(AgentTaskStatus::Succeeded.is_terminal());
assert!(AgentTaskStatus::Failed.is_terminal());
assert!(AgentTaskStatus::TimedOut.is_terminal());
assert!(AgentTaskStatus::Cancelled.is_terminal());
assert!(AgentTaskStatus::Succeeded.is_success());
assert!(!AgentTaskStatus::Failed.is_success());
}
}