Skip to main content

cersei_agent/
delegate.rs

1//! Parallel delegation primitive — spawn N isolated subagents with
2//! restricted toolsets and collect their summaries.
3//!
4//! Port of `_inspirations/hermes-agent/tools/delegate_tool.py`. The key
5//! invariants:
6//!
7//! - **Isolation**: child agents start with a fresh conversation — no
8//!   parent history, own session-id-equivalent.
9//! - **Restricted toolset**: caller specifies which tools the child may
10//!   call. A default blocklist strips dangerous recursion paths
11//!   (`delegate`, memory writes, user-interaction tools) at depth ≥ 1.
12//! - **Depth cap**: `max_depth = 2` (parent = 0, child = 1, no grandchildren)
13//!   prevents infinite recursion.
14//! - **Bounded parallelism**: up to `max_concurrent` children run at once
15//!   via `tokio::task::JoinSet`.
16//! - **Best-effort**: a child failure doesn't abort the batch; the parent
17//!   gets an error summary for that task and keeps going.
18//!
19//! This module does NOT pull in a Python RPC bridge for code execution —
20//! that's the 0.1.9 lift.
21
22use crate::Agent;
23use cersei_provider::Provider;
24use cersei_tools::Tool;
25use cersei_types::Result;
26use std::sync::Arc;
27
28/// Function that constructs a fresh provider for each child. Needed because
29/// `Provider` trait objects aren't cloneable — each delegated child needs
30/// its own provider instance.
31pub type ProviderFactory = Arc<dyn Fn() -> Box<dyn Provider + Send + Sync> + Send + Sync>;
32
33/// Function that constructs a fresh toolset for each child. Same reason as
34/// `ProviderFactory` — `Box<dyn Tool>` isn't cloneable.
35pub type ToolsetFactory = Arc<dyn Fn() -> Vec<Box<dyn Tool>> + Send + Sync>;
36
37/// Tools whose names match these strings are stripped from the child's
38/// toolset. These are either recursion hazards (`delegate`) or cross-agent
39/// side effects (memory writes, user-facing messages). Caller-provided
40/// blocklists are merged with this default.
41///
42/// Mirrors `DELEGATE_BLOCKED_TOOLS` in hermes-agent.
43pub const DELEGATE_BLOCKED_TOOLS: &[&str] = &[
44    "delegate",
45    "clarify",
46    "memory",
47    "memory_write",
48    "send_message",
49    "ask_user",
50    "AskUserQuestion",
51];
52
53/// Maximum delegation depth (parent=0). Children cannot spawn grandchildren.
54pub const MAX_DEPTH: u32 = 2;
55
56/// Default in-flight child limit per batch — matches hermes-agent's
57/// `max_concurrent_children`.
58pub const DEFAULT_MAX_CONCURRENT: usize = 3;
59
60/// Input to a single delegation.
61#[derive(Debug, Clone)]
62pub struct DelegateTask {
63    /// What the child should accomplish. Required.
64    pub goal: String,
65    /// Optional background context. Injected into the child system prompt.
66    pub context: Option<String>,
67    /// Optional concrete local workspace path. When present, the child is
68    /// told to use it for repo / workdir operations. Omit when we don't
69    /// have a real absolute path — the child will discover one.
70    pub workspace: Option<std::path::PathBuf>,
71}
72
73impl DelegateTask {
74    pub fn new(goal: impl Into<String>) -> Self {
75        Self {
76            goal: goal.into(),
77            context: None,
78            workspace: None,
79        }
80    }
81    pub fn with_context(mut self, ctx: impl Into<String>) -> Self {
82        self.context = Some(ctx.into());
83        self
84    }
85    pub fn with_workspace(mut self, path: impl Into<std::path::PathBuf>) -> Self {
86        self.workspace = Some(path.into());
87        self
88    }
89}
90
91/// Result from one child agent.
92#[derive(Debug, Clone)]
93pub struct DelegateResult {
94    pub goal: String,
95    pub summary: String,
96    /// None when the child ran cleanly; `Some(err)` when it failed.
97    pub error: Option<String>,
98    pub turns: u32,
99}
100
101impl DelegateResult {
102    pub fn is_ok(&self) -> bool {
103        self.error.is_none()
104    }
105}
106
107/// Top-level config for a batch delegation.
108pub struct DelegateConfig {
109    /// Tasks to run, potentially in parallel.
110    pub tasks: Vec<DelegateTask>,
111    /// Factory that mints a fresh provider per child.
112    pub provider_factory: ProviderFactory,
113    /// Factory that mints a fresh toolset per child. The blocklist is
114    /// applied inside `run_batch` — the factory returns the raw set.
115    pub toolset_factory: ToolsetFactory,
116    /// Model for child agents. Defaults to whatever the provider's default
117    /// model is when `None`.
118    pub model: Option<String>,
119    /// Upper bound on child turns.
120    pub max_turns: u32,
121    /// In-flight concurrency cap. Defaults to `DEFAULT_MAX_CONCURRENT`.
122    pub max_concurrent: usize,
123    /// Current recursion depth. Parent sets this to 1; `run_batch` refuses
124    /// to spawn when depth ≥ `MAX_DEPTH`.
125    pub depth: u32,
126    /// Extra blocked tool names (merged with `DELEGATE_BLOCKED_TOOLS`).
127    pub extra_blocked: Vec<String>,
128}
129
130impl DelegateConfig {
131    pub fn new(provider_factory: ProviderFactory, toolset_factory: ToolsetFactory) -> Self {
132        Self {
133            tasks: Vec::new(),
134            provider_factory,
135            toolset_factory,
136            model: None,
137            max_turns: 30,
138            max_concurrent: DEFAULT_MAX_CONCURRENT,
139            depth: 1,
140            extra_blocked: Vec::new(),
141        }
142    }
143}
144
145/// Run a batch of delegations. Blocks until every child completes (success
146/// or failure). On depth exhaustion, returns an error immediately without
147/// spawning anything.
148pub async fn run_batch(cfg: DelegateConfig) -> Result<Vec<DelegateResult>> {
149    if cfg.depth >= MAX_DEPTH {
150        return Err(cersei_types::CerseiError::Config(format!(
151            "delegation depth {} exceeds MAX_DEPTH={}",
152            cfg.depth, MAX_DEPTH
153        )));
154    }
155    if cfg.tasks.is_empty() {
156        return Ok(Vec::new());
157    }
158
159    let blocked: Vec<String> = DELEGATE_BLOCKED_TOOLS
160        .iter()
161        .map(|s| s.to_string())
162        .chain(cfg.extra_blocked.iter().cloned())
163        .collect();
164
165    let sem = Arc::new(tokio::sync::Semaphore::new(cfg.max_concurrent));
166    let mut set = tokio::task::JoinSet::new();
167
168    let max_turns = cfg.max_turns;
169    let model = cfg.model.clone();
170    let provider_factory = cfg.provider_factory.clone();
171    let toolset_factory = cfg.toolset_factory.clone();
172
173    for (i, task) in cfg.tasks.into_iter().enumerate() {
174        let permit = sem.clone().acquire_owned().await.unwrap();
175        let provider = (provider_factory)();
176        let tools_raw = (toolset_factory)();
177        let blocked = blocked.clone();
178        let model = model.clone();
179
180        set.spawn(async move {
181            let _permit = permit;
182            // Filter the child's toolset. Default blocklist plus caller
183            // additions. Built inside the task so parent toolset references
184            // aren't held across await points.
185            let tools: Vec<Box<dyn Tool>> = tools_raw
186                .into_iter()
187                .filter(|t| !blocked.iter().any(|b| b == t.name()))
188                .collect();
189            let res = run_single(&task, provider, model, tools, max_turns).await;
190            (i, task.goal, res)
191        });
192    }
193
194    let mut collected: Vec<(usize, DelegateResult)> = Vec::new();
195    while let Some(joined) = set.join_next().await {
196        let (i, goal, res) = joined.map_err(|e| {
197            cersei_types::CerseiError::Config(format!("delegate join: {e}"))
198        })?;
199        match res {
200            Ok(r) => collected.push((i, r)),
201            Err(e) => collected.push((
202                i,
203                DelegateResult {
204                    goal,
205                    summary: String::new(),
206                    error: Some(e.to_string()),
207                    turns: 0,
208                },
209            )),
210        }
211    }
212
213    collected.sort_by_key(|(i, _)| *i);
214    Ok(collected.into_iter().map(|(_, r)| r).collect())
215}
216
217async fn run_single(
218    task: &DelegateTask,
219    provider: Box<dyn Provider + Send + Sync>,
220    model: Option<String>,
221    tools: Vec<Box<dyn Tool>>,
222    max_turns: u32,
223) -> Result<DelegateResult> {
224    let system = build_child_system_prompt(task);
225
226    // Cast `Box<dyn Provider + Send + Sync>` to the plain `Box<dyn Provider>`
227    // the builder accepts. `Send + Sync` are strict supersets of the builder's
228    // requirement, so the cast is free.
229    let provider_boxed: Box<dyn Provider> = provider;
230    let mut builder = Agent::builder()
231        .provider_boxed(provider_boxed)
232        .system_prompt(system)
233        .max_turns(max_turns)
234        .tools(tools);
235    if let Some(m) = model {
236        builder = builder.model(m);
237    }
238
239    let child = builder.build()?;
240    let output = child.run(&task.goal).await?;
241
242    Ok(DelegateResult {
243        goal: task.goal.clone(),
244        summary: output.text().to_string(),
245        error: None,
246        turns: output.turns,
247    })
248}
249
250/// Build the child system prompt. Verbatim port of
251/// `_inspirations/hermes-agent/tools/delegate_tool.py::_build_child_system_prompt`
252/// — paraphrasing costs us the bench parity guarantee.
253pub fn build_child_system_prompt(task: &DelegateTask) -> String {
254    let mut parts: Vec<String> = Vec::with_capacity(6);
255    parts.push("You are a focused subagent working on a specific delegated task.".into());
256    parts.push(String::new());
257    parts.push(format!("YOUR TASK:\n{}", task.goal));
258
259    if let Some(ctx) = task.context.as_deref() {
260        if !ctx.trim().is_empty() {
261            parts.push(format!("\nCONTEXT:\n{ctx}"));
262        }
263    }
264
265    if let Some(wp) = task.workspace.as_ref() {
266        let s = wp.display().to_string();
267        if !s.trim().is_empty() {
268            parts.push(format!(
269                "\nWORKSPACE PATH:\n{s}\nUse this exact path for local repository/workdir operations unless the task explicitly says otherwise."
270            ));
271        }
272    }
273
274    parts.push(
275        "\nComplete this task using the tools available to you. When finished, provide a clear, concise summary of:\n- What you did\n- What you found or accomplished\n- Any files you created or modified\n- Any issues encountered\n\nImportant workspace rule: Never assume a repository lives at /workspace/... or any other container-style path unless the task/context explicitly gives that path. If no exact local path is provided, discover it first before issuing git/workdir-specific commands.\n\nBe thorough but concise — your response is returned to the parent agent as a summary.".into()
276    );
277
278    parts.join("\n")
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn child_prompt_includes_goal() {
287        let p = build_child_system_prompt(&DelegateTask::new("fix the tests"));
288        assert!(p.contains("YOUR TASK:\nfix the tests"));
289        assert!(p.contains("focused subagent"));
290        assert!(p.contains("What you did"));
291    }
292
293    #[test]
294    fn child_prompt_includes_context_when_present() {
295        let p = build_child_system_prompt(
296            &DelegateTask::new("fix").with_context("error at line 42"),
297        );
298        assert!(p.contains("CONTEXT:\nerror at line 42"));
299    }
300
301    #[test]
302    fn child_prompt_skips_empty_context() {
303        let p = build_child_system_prompt(&DelegateTask::new("g").with_context("   "));
304        assert!(!p.contains("CONTEXT:"));
305    }
306
307    #[test]
308    fn child_prompt_includes_workspace_when_present() {
309        let p = build_child_system_prompt(
310            &DelegateTask::new("g").with_workspace("/abs/path/to/repo"),
311        );
312        assert!(p.contains("WORKSPACE PATH:\n/abs/path/to/repo"));
313    }
314
315    #[test]
316    fn default_blocklist_covers_recursion_and_memory() {
317        assert!(DELEGATE_BLOCKED_TOOLS.contains(&"delegate"));
318        assert!(DELEGATE_BLOCKED_TOOLS.contains(&"memory"));
319        assert!(DELEGATE_BLOCKED_TOOLS.contains(&"send_message"));
320    }
321}