1use std::path::PathBuf;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CredentialRef {
15 pub key: String,
17 pub env: Option<String>,
19}
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct AgentContext {
26 pub segments: Vec<(String, String)>,
29 pub tokens_used: u64,
31}
32
33impl AgentContext {
34 pub fn new() -> Self {
35 Self::default()
36 }
37
38 pub fn push(mut self, key: impl Into<String>, content: impl Into<String>) -> Self {
39 self.segments.push((key.into(), content.into()));
40 self
41 }
42
43 pub fn render(&self) -> String {
46 if self.segments.is_empty() {
47 return String::new();
48 }
49 let mut out = String::new();
50 for (key, content) in &self.segments {
51 out.push_str("### ");
52 out.push_str(key);
53 out.push('\n');
54 out.push_str(content.trim());
55 out.push_str("\n\n");
56 }
57 out.trim_end().to_string()
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AgentTask {
64 pub id: String,
65 pub description: String,
66 pub context: AgentContext,
67 pub workdir: Option<PathBuf>,
69 #[serde(default)]
71 pub credentials: Vec<CredentialRef>,
72 pub timeout_secs: u64,
75}
76
77impl AgentTask {
78 pub fn new(description: impl Into<String>) -> Self {
79 Self {
80 id: uuid::Uuid::new_v4().to_string(),
81 description: description.into(),
82 context: AgentContext::default(),
83 workdir: None,
84 credentials: Vec::new(),
85 timeout_secs: 300,
86 }
87 }
88
89 pub fn with_context(mut self, ctx: AgentContext) -> Self {
90 self.context = ctx;
91 self
92 }
93
94 pub fn with_workdir(mut self, workdir: impl Into<PathBuf>) -> Self {
95 self.workdir = Some(workdir.into());
96 self
97 }
98
99 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
100 self.timeout_secs = secs;
101 self
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct Artifact {
108 pub kind: String,
110 pub reference: String,
112 #[serde(default)]
114 pub summary: Option<String>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum AgentTaskStatus {
121 Running,
122 Succeeded,
123 Failed,
124 Cancelled,
125 TimedOut,
126}
127
128impl AgentTaskStatus {
129 pub fn is_terminal(self) -> bool {
130 !matches!(self, AgentTaskStatus::Running)
131 }
132 pub fn is_success(self) -> bool {
133 matches!(self, AgentTaskStatus::Succeeded)
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct AgentResult {
140 pub task_id: String,
141 pub status: AgentTaskStatus,
142 pub summary: String,
144 #[serde(default)]
146 pub artifacts: Vec<Artifact>,
147 #[serde(default)]
149 pub stdout: String,
150 #[serde(default)]
152 pub stderr: String,
153 pub exit_code: Option<i32>,
155 pub started_at: DateTime<Utc>,
156 pub completed_at: DateTime<Utc>,
157}
158
159impl AgentResult {
160 pub fn duration_ms(&self) -> i64 {
161 (self.completed_at - self.started_at)
162 .num_milliseconds()
163 .max(0)
164 }
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct AgentCapabilities {
171 #[serde(default)]
173 pub tags: Vec<String>,
174 #[serde(default)]
176 pub languages: Vec<String>,
177 #[serde(default = "default_concurrency")]
179 pub max_concurrency: u32,
180 #[serde(default)]
182 pub needs_network: bool,
183}
184
185fn default_concurrency() -> u32 {
186 1
187}
188
189#[derive(Debug, Error)]
190pub enum AgentError {
191 #[error("delegate '{0}' not found in registry")]
192 NotFound(String),
193 #[error("delegate failed to launch: {0}")]
194 Launch(String),
195 #[error("delegate timed out after {secs}s: {task_id}")]
196 Timeout { task_id: String, secs: u64 },
197 #[error("delegate returned non-zero exit {code}: {stderr}")]
198 NonZeroExit { code: i32, stderr: String },
199 #[error("delegate output unparseable: {0}")]
200 Parse(String),
201 #[error("delegate cancelled: {0}")]
202 Cancelled(String),
203 #[error("IO error: {0}")]
204 Io(String),
205 #[error("other: {0}")]
206 Other(String),
207}
208
209#[async_trait]
211pub trait AgentDelegate: Send + Sync {
212 fn name(&self) -> &str;
214
215 fn capabilities(&self) -> AgentCapabilities {
217 AgentCapabilities::default()
218 }
219
220 async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError>;
223
224 async fn cancel(&self, _task_id: &str) -> Result<(), AgentError> {
226 Ok(())
227 }
228
229 async fn status(&self, _task_id: &str) -> Result<AgentTaskStatus, AgentError> {
232 Ok(AgentTaskStatus::Running)
233 }
234
235 async fn health_check(&self) -> bool {
239 true
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn context_render_round_trips() {
249 let ctx = AgentContext::new()
250 .push("memory", "fact A\nfact B")
251 .push("conventions", "use tabs");
252 let rendered = ctx.render();
253 assert!(rendered.contains("### memory"));
254 assert!(rendered.contains("fact A"));
255 assert!(rendered.contains("### conventions"));
256 assert!(rendered.contains("use tabs"));
257 }
258
259 #[test]
260 fn context_render_empty_returns_empty() {
261 assert!(AgentContext::new().render().is_empty());
262 }
263
264 #[test]
265 fn agent_task_new_generates_uuid() {
266 let a = AgentTask::new("a");
267 let b = AgentTask::new("b");
268 assert_ne!(a.id, b.id);
269 assert_eq!(a.timeout_secs, 300);
270 }
271
272 #[test]
273 fn task_status_terminality() {
274 assert!(!AgentTaskStatus::Running.is_terminal());
275 assert!(AgentTaskStatus::Succeeded.is_terminal());
276 assert!(AgentTaskStatus::Failed.is_terminal());
277 assert!(AgentTaskStatus::TimedOut.is_terminal());
278 assert!(AgentTaskStatus::Cancelled.is_terminal());
279 assert!(AgentTaskStatus::Succeeded.is_success());
280 assert!(!AgentTaskStatus::Failed.is_success());
281 }
282}