1use crate::Agent;
23use cersei_provider::Provider;
24use cersei_tools::Tool;
25use cersei_types::Result;
26use std::sync::Arc;
27
28pub type ProviderFactory = Arc<dyn Fn() -> Box<dyn Provider + Send + Sync> + Send + Sync>;
32
33pub type ToolsetFactory = Arc<dyn Fn() -> Vec<Box<dyn Tool>> + Send + Sync>;
36
37pub const DELEGATE_BLOCKED_TOOLS: &[&str] = &[
44 "delegate",
45 "clarify",
46 "memory",
47 "memory_write",
48 "send_message",
49 "ask_user",
50 "AskUserQuestion",
51];
52
53pub const MAX_DEPTH: u32 = 2;
55
56pub const DEFAULT_MAX_CONCURRENT: usize = 3;
59
60#[derive(Debug, Clone)]
62pub struct DelegateTask {
63 pub goal: String,
65 pub context: Option<String>,
67 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#[derive(Debug, Clone)]
93pub struct DelegateResult {
94 pub goal: String,
95 pub summary: String,
96 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
107pub struct DelegateConfig {
109 pub tasks: Vec<DelegateTask>,
111 pub provider_factory: ProviderFactory,
113 pub toolset_factory: ToolsetFactory,
116 pub model: Option<String>,
119 pub max_turns: u32,
121 pub max_concurrent: usize,
123 pub depth: u32,
126 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
145pub 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 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 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
250pub 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}