Skip to main content

brainos_delegate/
traits.rs

1//! Core trait + envelope types for agent delegation.
2
3use std::path::PathBuf;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// Token-budgeted context passed to the delegate alongside the task spec.
11/// Free-form fields so different agents can lift what they need — keys
12/// like `"memory_facts"`, `"recent_episodes"`, `"project_conventions"`.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct AgentContext {
15    /// Human-readable context segments (key → content), already truncated
16    /// by the caller to fit the agent's context window.
17    pub segments: Vec<(String, String)>,
18    /// Approximate token count used by the caller's budget tracker.
19    pub tokens_used: u64,
20}
21
22impl AgentContext {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    pub fn push(mut self, key: impl Into<String>, content: impl Into<String>) -> Self {
28        self.segments.push((key.into(), content.into()));
29        self
30    }
31
32    /// Render as a single prompt-friendly string — each segment becomes a
33    /// `### {key}` block. Keeps the layout predictable across delegates.
34    pub fn render(&self) -> String {
35        if self.segments.is_empty() {
36            return String::new();
37        }
38        let mut out = String::new();
39        for (key, content) in &self.segments {
40            out.push_str("### ");
41            out.push_str(key);
42            out.push('\n');
43            out.push_str(content.trim());
44            out.push_str("\n\n");
45        }
46        out.trim_end().to_string()
47    }
48}
49
50/// One unit of work handed to a delegate.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AgentTask {
53    pub id: String,
54    pub description: String,
55    pub context: AgentContext,
56    /// Working directory; the delegate should cd here before executing.
57    pub workdir: Option<PathBuf>,
58    /// Hard ceiling in seconds. The delegate kills the underlying
59    /// process/request at or before this mark.
60    pub timeout_secs: u64,
61}
62
63impl AgentTask {
64    pub fn new(description: impl Into<String>) -> Self {
65        Self {
66            id: uuid::Uuid::new_v4().to_string(),
67            description: description.into(),
68            context: AgentContext::default(),
69            workdir: None,
70            timeout_secs: 300,
71        }
72    }
73
74    pub fn with_context(mut self, ctx: AgentContext) -> Self {
75        self.context = ctx;
76        self
77    }
78
79    pub fn with_workdir(mut self, workdir: impl Into<PathBuf>) -> Self {
80        self.workdir = Some(workdir.into());
81        self
82    }
83
84    pub fn with_timeout_secs(mut self, secs: u64) -> Self {
85        self.timeout_secs = secs;
86        self
87    }
88}
89
90/// An artifact the delegate produced — a file, a test result, a PR URL.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Artifact {
93    /// Classifier like `"file"`, `"test"`, `"url"`.
94    pub kind: String,
95    /// Opaque reference — path, URL, or JSON blob — interpreted by kind.
96    pub reference: String,
97    /// Optional summary (one line).
98    #[serde(default)]
99    pub summary: Option<String>,
100}
101
102/// Terminal status of a delegated task.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum AgentTaskStatus {
106    Running,
107    Succeeded,
108    Failed,
109    Cancelled,
110    TimedOut,
111}
112
113impl AgentTaskStatus {
114    pub fn is_terminal(self) -> bool {
115        !matches!(self, AgentTaskStatus::Running)
116    }
117    pub fn is_success(self) -> bool {
118        matches!(self, AgentTaskStatus::Succeeded)
119    }
120}
121
122/// What the delegate came back with.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct AgentResult {
125    pub task_id: String,
126    pub status: AgentTaskStatus,
127    /// Agent's own summary of what happened (first-person, human-readable).
128    pub summary: String,
129    /// Structured artifacts (files changed, test runs, links).
130    #[serde(default)]
131    pub artifacts: Vec<Artifact>,
132    /// Raw stdout, truncated by the delegate to avoid blowing context.
133    #[serde(default)]
134    pub stdout: String,
135    /// Raw stderr, likewise truncated.
136    #[serde(default)]
137    pub stderr: String,
138    /// Exit code for subprocess-backed delegates; `None` for HTTP agents.
139    pub exit_code: Option<i32>,
140    pub started_at: DateTime<Utc>,
141    pub completed_at: DateTime<Utc>,
142}
143
144impl AgentResult {
145    pub fn duration_ms(&self) -> i64 {
146        (self.completed_at - self.started_at)
147            .num_milliseconds()
148            .max(0)
149    }
150}
151
152/// Declarative statement of what an agent can do — the orchestrator uses
153/// this to pick a delegate, and the registry uses it for search.
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155pub struct AgentCapabilities {
156    /// Tags like `"code-edit"`, `"plan"`, `"research"`.
157    #[serde(default)]
158    pub tags: Vec<String>,
159    /// Preferred languages/frameworks (`"rust"`, `"typescript"`).
160    #[serde(default)]
161    pub languages: Vec<String>,
162    /// Maximum concurrent delegations (conservative default = 1).
163    #[serde(default = "default_concurrency")]
164    pub max_concurrency: u32,
165    /// Whether this delegate needs network — informs sandbox policy.
166    #[serde(default)]
167    pub needs_network: bool,
168}
169
170fn default_concurrency() -> u32 {
171    1
172}
173
174#[derive(Debug, Error)]
175pub enum AgentError {
176    #[error("delegate '{0}' not found in registry")]
177    NotFound(String),
178    #[error("delegate failed to launch: {0}")]
179    Launch(String),
180    #[error("delegate timed out after {secs}s: {task_id}")]
181    Timeout { task_id: String, secs: u64 },
182    #[error("delegate returned non-zero exit {code}: {stderr}")]
183    NonZeroExit { code: i32, stderr: String },
184    #[error("delegate output unparseable: {0}")]
185    Parse(String),
186    #[error("delegate cancelled: {0}")]
187    Cancelled(String),
188    #[error("IO error: {0}")]
189    Io(String),
190    #[error("other: {0}")]
191    Other(String),
192}
193
194/// The trait every specialist agent implements.
195#[async_trait]
196pub trait AgentDelegate: Send + Sync {
197    /// Stable identifier registered with the registry.
198    fn name(&self) -> &str;
199
200    /// Declared capabilities — used by the orchestrator to pick a delegate.
201    fn capabilities(&self) -> AgentCapabilities {
202        AgentCapabilities::default()
203    }
204
205    /// Hand off a task. Blocks until the delegate finishes, fails, or
206    /// hits `task.timeout_secs`.
207    async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError>;
208
209    /// Best-effort cancellation. Default is no-op.
210    async fn cancel(&self, _task_id: &str) -> Result<(), AgentError> {
211        Ok(())
212    }
213
214    /// Best-effort status poll. Most implementations don't keep per-task
215    /// state and return `Running` during `delegate` via other channels.
216    async fn status(&self, _task_id: &str) -> Result<AgentTaskStatus, AgentError> {
217        Ok(AgentTaskStatus::Running)
218    }
219
220    /// Cheap liveness probe — the registry calls this during startup and
221    /// before routing to mark the delegate healthy/unhealthy. Default is
222    /// "assume healthy" so stateless delegates don't have to implement it.
223    async fn health_check(&self) -> bool {
224        true
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn context_render_round_trips() {
234        let ctx = AgentContext::new()
235            .push("memory", "fact A\nfact B")
236            .push("conventions", "use tabs");
237        let rendered = ctx.render();
238        assert!(rendered.contains("### memory"));
239        assert!(rendered.contains("fact A"));
240        assert!(rendered.contains("### conventions"));
241        assert!(rendered.contains("use tabs"));
242    }
243
244    #[test]
245    fn context_render_empty_returns_empty() {
246        assert!(AgentContext::new().render().is_empty());
247    }
248
249    #[test]
250    fn agent_task_new_generates_uuid() {
251        let a = AgentTask::new("a");
252        let b = AgentTask::new("b");
253        assert_ne!(a.id, b.id);
254        assert_eq!(a.timeout_secs, 300);
255    }
256
257    #[test]
258    fn task_status_terminality() {
259        assert!(!AgentTaskStatus::Running.is_terminal());
260        assert!(AgentTaskStatus::Succeeded.is_terminal());
261        assert!(AgentTaskStatus::Failed.is_terminal());
262        assert!(AgentTaskStatus::TimedOut.is_terminal());
263        assert!(AgentTaskStatus::Cancelled.is_terminal());
264        assert!(AgentTaskStatus::Succeeded.is_success());
265        assert!(!AgentTaskStatus::Failed.is_success());
266    }
267}