Skip to main content

roder_ext_task_subagent/
task.rs

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}