1use std::sync::Arc;
2
3use anyhow::Context;
4use roder_api::subagents::{SubagentDispatcher, SubagentLane, SubagentRequest};
5use roder_api::tasks::{
6 TaskExecutionContext, TaskExecutionResult, TaskExecutor, TaskOutputStream, TaskSpec,
7};
8use serde::Deserialize;
9
10pub const SUBAGENT_TASK_EXECUTOR_ID: &str = "subagent";
11
12#[derive(Debug, Clone, Deserialize)]
13struct SubagentTaskInput {
14 description: String,
15 prompt: String,
16 #[serde(default)]
17 subagent_type: Option<String>,
18 #[serde(default)]
19 model: Option<String>,
20 #[serde(default)]
21 tools: Option<Vec<String>>,
22 #[serde(default)]
23 lane: Option<SubagentLane>,
24 #[serde(default)]
25 max_concurrent: Option<usize>,
26 #[serde(default)]
27 allowed_tools: Option<Vec<String>>,
28 #[serde(default)]
29 parent_deadline_seconds: Option<u64>,
30 #[serde(default)]
31 inputs: Option<serde_json::Value>,
32 #[serde(default)]
33 timeout_seconds: Option<u64>,
34}
35
36#[derive(Clone)]
37pub struct SubagentTaskExecutor {
38 dispatcher: Arc<dyn SubagentDispatcher>,
39}
40
41impl SubagentTaskExecutor {
42 pub fn new(dispatcher: Arc<dyn SubagentDispatcher>) -> Self {
43 Self { dispatcher }
44 }
45}
46
47#[async_trait::async_trait]
48impl TaskExecutor for SubagentTaskExecutor {
49 fn id(&self) -> String {
50 SUBAGENT_TASK_EXECUTOR_ID.to_string()
51 }
52
53 fn spec(&self) -> TaskSpec {
54 TaskSpec {
55 kind: SUBAGENT_TASK_EXECUTOR_ID.to_string(),
56 description: "Run a subagent as a background task.".to_string(),
57 input_schema: serde_json::json!({
58 "type": "object",
59 "required": ["description", "prompt"],
60 "properties": {
61 "description": { "type": "string" },
62 "prompt": { "type": "string" },
63 "subagent_type": { "type": "string" },
64 "model": { "type": "string" },
65 "tools": { "type": "array", "items": { "type": "string" } },
66 "lane": {
67 "type": "string",
68 "enum": ["scout", "editor", "reviewer", "runner"]
69 },
70 "max_concurrent": { "type": "integer", "minimum": 1 },
71 "allowed_tools": { "type": "array", "items": { "type": "string" } },
72 "parent_deadline_seconds": { "type": "integer", "minimum": 1 },
73 "inputs": {
74 "type": "object",
75 "description": "Optional freeform structured context for the child task."
76 },
77 "timeout_seconds": { "type": "integer", "minimum": 1 }
78 },
79 "additionalProperties": false
80 }),
81 default_timeout_seconds: None,
82 metadata: serde_json::json!({ "category": "subagent" }),
83 }
84 }
85
86 async fn execute(
87 &self,
88 ctx: TaskExecutionContext,
89 input: serde_json::Value,
90 ) -> anyhow::Result<TaskExecutionResult> {
91 let input: SubagentTaskInput =
92 serde_json::from_value(input).context("deserialize subagent task input")?;
93 let parent_thread_id = ctx.thread_id.unwrap_or_else(|| ctx.task_id.clone());
94 let parent_turn_id = ctx.turn_id.unwrap_or_else(|| "background-task".to_string());
95 let result = self
96 .dispatcher
97 .dispatch(
98 parent_thread_id,
99 parent_turn_id,
100 SubagentRequest {
101 description: input.description,
102 prompt: input.prompt,
103 subagent_type: input.subagent_type,
104 model: input.model,
105 tools: input.tools,
106 lane: input.lane,
107 max_concurrent: input.max_concurrent,
108 allowed_tools: input.allowed_tools,
109 parent_deadline_seconds: input.parent_deadline_seconds,
110 inputs: input.inputs,
111 timeout_seconds: input.timeout_seconds,
112 },
113 )
114 .await?;
115
116 if let Some(transcript) = &result.transcript {
117 ctx.output
118 .write(TaskOutputStream::Log, transcript.to_string())
119 .await?;
120 }
121 ctx.output
122 .write(TaskOutputStream::Log, result.final_message.clone())
123 .await?;
124
125 Ok(TaskExecutionResult {
126 exit_code: None,
127 payload: serde_json::to_value(result)?,
128 })
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use roder_api::events::{ThreadId, TurnId};
135 use roder_api::subagents::{
136 SubagentDefinition, SubagentExitReason, SubagentPermissionMode, SubagentResult,
137 };
138
139 use super::*;
140
141 struct EmptyDispatcher;
142
143 #[async_trait::async_trait]
144 impl SubagentDispatcher for EmptyDispatcher {
145 fn id(&self) -> String {
146 "empty".to_string()
147 }
148
149 fn definitions(&self) -> Vec<SubagentDefinition> {
150 vec![SubagentDefinition {
151 agent_type: "explore".to_string(),
152 description: "Explore the workspace".to_string(),
153 tools: vec!["read_file".to_string()],
154 model: None,
155 system_prompt: None,
156 permission_mode: SubagentPermissionMode::ReadOnly,
157 max_turns: Some(2),
158 max_result_chars: Some(4000),
159 }]
160 }
161
162 async fn dispatch(
163 &self,
164 _parent_thread_id: ThreadId,
165 _parent_turn_id: TurnId,
166 request: SubagentRequest,
167 ) -> anyhow::Result<SubagentResult> {
168 Ok(SubagentResult {
169 thread_id: "child-thread".to_string(),
170 turn_id: "child-turn".to_string(),
171 agent_type: request
172 .subagent_type
173 .unwrap_or_else(|| "explore".to_string()),
174 model: request.model,
175 final_message: "done".to_string(),
176 usage: None,
177 exit_reason: SubagentExitReason::Completed,
178 transcript: None,
179 metadata: serde_json::json!({ "lane": request.lane }),
180 })
181 }
182 }
183
184 #[test]
185 fn speed_schema_snapshot_covers_subagent_task_input() {
186 let executor = SubagentTaskExecutor::new(Arc::new(EmptyDispatcher));
187 let spec = executor
188 .spec()
189 .normalized_for_model(roder_api::ToolSchemaPolicy::strict());
190 let schema = serde_json::to_string(&spec.input_schema).unwrap();
191
192 assert!(
193 schema.starts_with(
194 r#"{"type":"object","required":["description","prompt"],"properties":"#
195 )
196 );
197 assert!(schema.contains(
198 r#""inputs":{"type":"object","description":"Optional freeform structured context for the child task."}"#
199 ));
200 assert!(schema.contains(r#""lane":{"type":"string""#));
201 assert!(schema.contains(r#""enum":["scout","editor","reviewer","runner"]"#));
202 assert!(schema.contains(r#""max_concurrent":{"type":"integer""#));
203 assert!(schema.contains(r#""minimum":1"#));
204 assert!(schema.contains(r#""additionalProperties":false"#));
205 }
206}