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/// Reference to a credential that should be injected at execution time.
11/// The delegate resolves this against the vault — raw values never
12/// appear in `AgentTask`.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CredentialRef {
15    /// Vault key (e.g. `"github_token"`).
16    pub key: String,
17    /// Environment variable to expose the value under, if any.
18    pub env: Option<String>,
19}
20
21/// Token-budgeted context passed to the delegate alongside the task spec.
22/// Free-form fields so different agents can lift what they need — keys
23/// like `"memory_facts"`, `"recent_episodes"`, `"project_conventions"`.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct AgentContext {
26    /// Human-readable context segments (key → content), already truncated
27    /// by the caller to fit the agent's context window.
28    pub segments: Vec<(String, String)>,
29    /// Approximate token count used by the caller's budget tracker.
30    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    /// Render as a single prompt-friendly string — each segment becomes a
44    /// `### {key}` block. Keeps the layout predictable across delegates.
45    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/// One unit of work handed to a delegate.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AgentTask {
64    pub id: String,
65    pub description: String,
66    pub context: AgentContext,
67    /// Working directory; the delegate should cd here before executing.
68    pub workdir: Option<PathBuf>,
69    /// Credentials to inject (never raw values — vault refs only).
70    #[serde(default)]
71    pub credentials: Vec<CredentialRef>,
72    /// Hard ceiling in seconds. The delegate kills the underlying
73    /// process/request at or before this mark.
74    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/// An artifact the delegate produced — a file, a test result, a PR URL.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct Artifact {
108    /// Classifier like `"file"`, `"test"`, `"url"`.
109    pub kind: String,
110    /// Opaque reference — path, URL, or JSON blob — interpreted by kind.
111    pub reference: String,
112    /// Optional summary (one line).
113    #[serde(default)]
114    pub summary: Option<String>,
115}
116
117/// Terminal status of a delegated task.
118#[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/// What the delegate came back with.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct AgentResult {
140    pub task_id: String,
141    pub status: AgentTaskStatus,
142    /// Agent's own summary of what happened (first-person, human-readable).
143    pub summary: String,
144    /// Structured artifacts (files changed, test runs, links).
145    #[serde(default)]
146    pub artifacts: Vec<Artifact>,
147    /// Raw stdout, truncated by the delegate to avoid blowing context.
148    #[serde(default)]
149    pub stdout: String,
150    /// Raw stderr, likewise truncated.
151    #[serde(default)]
152    pub stderr: String,
153    /// Exit code for subprocess-backed delegates; `None` for HTTP agents.
154    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/// Declarative statement of what an agent can do — the orchestrator uses
168/// this to pick a delegate, and the registry uses it for search.
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct AgentCapabilities {
171    /// Tags like `"code-edit"`, `"plan"`, `"research"`.
172    #[serde(default)]
173    pub tags: Vec<String>,
174    /// Preferred languages/frameworks (`"rust"`, `"typescript"`).
175    #[serde(default)]
176    pub languages: Vec<String>,
177    /// Maximum concurrent delegations (conservative default = 1).
178    #[serde(default = "default_concurrency")]
179    pub max_concurrency: u32,
180    /// Whether this delegate needs network — informs sandbox policy.
181    #[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/// The trait every specialist agent implements.
210#[async_trait]
211pub trait AgentDelegate: Send + Sync {
212    /// Stable identifier registered with the registry.
213    fn name(&self) -> &str;
214
215    /// Declared capabilities — used by the orchestrator to pick a delegate.
216    fn capabilities(&self) -> AgentCapabilities {
217        AgentCapabilities::default()
218    }
219
220    /// Hand off a task. Blocks until the delegate finishes, fails, or
221    /// hits `task.timeout_secs`.
222    async fn delegate(&self, task: AgentTask) -> Result<AgentResult, AgentError>;
223
224    /// Best-effort cancellation. Default is no-op.
225    async fn cancel(&self, _task_id: &str) -> Result<(), AgentError> {
226        Ok(())
227    }
228
229    /// Best-effort status poll. Most implementations don't keep per-task
230    /// state and return `Running` during `delegate` via other channels.
231    async fn status(&self, _task_id: &str) -> Result<AgentTaskStatus, AgentError> {
232        Ok(AgentTaskStatus::Running)
233    }
234
235    /// Cheap liveness probe — the registry calls this during startup and
236    /// before routing to mark the delegate healthy/unhealthy. Default is
237    /// "assume healthy" so stateless delegates don't have to implement it.
238    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}