Skip to main content

roder_api/
subagents.rs

1use serde::{Deserialize, Serialize};
2
3use crate::events::{ThreadId, TurnId};
4use crate::extension::SubagentDispatcherId;
5use crate::inference::TokenUsage;
6use crate::trace::SubagentTraceSink;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct SubagentRequest {
10    pub description: String,
11    pub prompt: String,
12    pub subagent_type: Option<String>,
13    pub model: Option<String>,
14    pub tools: Option<Vec<String>>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub lane: Option<SubagentLane>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub max_concurrent: Option<usize>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub allowed_tools: Option<Vec<String>>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub parent_deadline_seconds: Option<u64>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub inputs: Option<serde_json::Value>,
25    pub timeout_seconds: Option<u64>,
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
29#[serde(rename_all = "snake_case")]
30pub enum SubagentLane {
31    Scout,
32    Editor,
33    Reviewer,
34    Runner,
35}
36
37impl SubagentLane {
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Scout => "scout",
41            Self::Editor => "editor",
42            Self::Reviewer => "reviewer",
43            Self::Runner => "runner",
44        }
45    }
46
47    pub fn preset(self) -> SubagentLanePreset {
48        match self {
49            Self::Scout => SubagentLanePreset {
50                lane: self,
51                description: "Read and search without changing state.",
52                max_concurrent: 4,
53                timeout_seconds: 120,
54                allowed_tools: &[
55                    "Read",
56                    "Grep",
57                    "Glob",
58                    "read_file",
59                    "grep",
60                    "glob",
61                    "list_files",
62                ],
63            },
64            Self::Editor => SubagentLanePreset {
65                lane: self,
66                description: "Make a bounded file-change slice.",
67                max_concurrent: 2,
68                timeout_seconds: 180,
69                allowed_tools: &[
70                    "Read",
71                    "Grep",
72                    "Glob",
73                    "read_file",
74                    "grep",
75                    "glob",
76                    "list_files",
77                    "write_file",
78                    "edit",
79                    "multi_edit",
80                    "apply_patch",
81                ],
82            },
83            Self::Reviewer => SubagentLanePreset {
84                lane: self,
85                description: "Review and verify with evidence.",
86                max_concurrent: 2,
87                timeout_seconds: 120,
88                allowed_tools: &[
89                    "Read",
90                    "Grep",
91                    "Glob",
92                    "read_file",
93                    "grep",
94                    "glob",
95                    "list_files",
96                ],
97            },
98            Self::Runner => SubagentLanePreset {
99                lane: self,
100                description: "Run commands or tests when process policy allows it.",
101                max_concurrent: 1,
102                timeout_seconds: 120,
103                allowed_tools: &["Shell", "shell", "exec_command", "run_command"],
104            },
105        }
106    }
107}
108
109#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
110#[serde(rename_all = "camelCase")]
111pub struct SubagentLanePreset {
112    pub lane: SubagentLane,
113    pub description: &'static str,
114    pub max_concurrent: usize,
115    pub timeout_seconds: u64,
116    pub allowed_tools: &'static [&'static str],
117}
118
119pub fn built_in_subagent_lane_presets() -> [SubagentLanePreset; 4] {
120    [
121        SubagentLane::Scout.preset(),
122        SubagentLane::Editor.preset(),
123        SubagentLane::Reviewer.preset(),
124        SubagentLane::Runner.preset(),
125    ]
126}
127
128pub const SUBAGENT_SUMMARY_CONTRACT: &str = "Child summary must include these labels: Conclusion, Evidence, Files inspected, Files changed, Remaining uncertainty.";
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct SubagentDefinition {
132    pub agent_type: String,
133    pub description: String,
134    pub tools: Vec<String>,
135    pub model: Option<String>,
136    pub system_prompt: Option<String>,
137    pub permission_mode: SubagentPermissionMode,
138    pub max_turns: Option<u32>,
139    pub max_result_chars: Option<usize>,
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "snake_case")]
144pub enum SubagentPermissionMode {
145    ReadOnly,
146    #[default]
147    Default,
148    AutoEdit,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct SubagentResult {
153    pub thread_id: ThreadId,
154    pub turn_id: TurnId,
155    pub agent_type: String,
156    pub model: Option<String>,
157    pub final_message: String,
158    pub usage: Option<TokenUsage>,
159    pub exit_reason: SubagentExitReason,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub transcript: Option<serde_json::Value>,
162    #[serde(default)]
163    pub metadata: serde_json::Value,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167#[serde(rename_all = "snake_case")]
168pub enum SubagentExitReason {
169    Completed,
170    MaxTurns,
171    Timeout,
172    Cancelled,
173    Failed,
174}
175
176#[async_trait::async_trait]
177pub trait SubagentDispatcher: Send + Sync + 'static {
178    fn id(&self) -> SubagentDispatcherId;
179
180    fn definitions(&self) -> Vec<SubagentDefinition>;
181
182    async fn dispatch(
183        &self,
184        parent_thread_id: ThreadId,
185        parent_turn_id: TurnId,
186        request: SubagentRequest,
187    ) -> anyhow::Result<SubagentResult>;
188
189    async fn dispatch_traced(
190        &self,
191        parent_thread_id: ThreadId,
192        parent_turn_id: TurnId,
193        request: SubagentRequest,
194        trace_sink: Option<std::sync::Arc<dyn SubagentTraceSink>>,
195    ) -> anyhow::Result<SubagentResult> {
196        let _ = trace_sink;
197        self.dispatch(parent_thread_id, parent_turn_id, request)
198            .await
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use std::sync::Arc;
205
206    use super::*;
207
208    struct NoopDispatcher;
209
210    #[async_trait::async_trait]
211    impl SubagentDispatcher for NoopDispatcher {
212        fn id(&self) -> SubagentDispatcherId {
213            "noop".to_string()
214        }
215
216        fn definitions(&self) -> Vec<SubagentDefinition> {
217            vec![SubagentDefinition {
218                agent_type: "explore".to_string(),
219                description: "Explore the workspace".to_string(),
220                tools: vec!["Read".to_string()],
221                model: Some("test-model".to_string()),
222                system_prompt: Some("Report findings only".to_string()),
223                permission_mode: SubagentPermissionMode::ReadOnly,
224                max_turns: Some(4),
225                max_result_chars: Some(4000),
226            }]
227        }
228
229        async fn dispatch(
230            &self,
231            _parent_thread_id: ThreadId,
232            _parent_turn_id: TurnId,
233            request: SubagentRequest,
234        ) -> anyhow::Result<SubagentResult> {
235            Ok(SubagentResult {
236                thread_id: "child-thread".to_string(),
237                turn_id: "child-turn".to_string(),
238                agent_type: request
239                    .subagent_type
240                    .unwrap_or_else(|| "explore".to_string()),
241                model: request.model,
242                final_message: "done".to_string(),
243                usage: None,
244                exit_reason: SubagentExitReason::Completed,
245                transcript: None,
246                metadata: serde_json::json!({}),
247            })
248        }
249    }
250
251    #[tokio::test]
252    async fn subagent_dispatcher_trait_is_object_safe() {
253        let dispatcher: Arc<dyn SubagentDispatcher> = Arc::new(NoopDispatcher);
254
255        assert_eq!(dispatcher.id(), "noop");
256        assert_eq!(dispatcher.definitions()[0].agent_type, "explore");
257
258        let result = dispatcher
259            .dispatch(
260                "parent-thread".to_string(),
261                "parent-turn".to_string(),
262                SubagentRequest {
263                    description: "Check files".to_string(),
264                    prompt: "Find the API entrypoint".to_string(),
265                    subagent_type: Some("explore".to_string()),
266                    model: Some("test-model".to_string()),
267                    tools: Some(vec!["Read".to_string()]),
268                    lane: None,
269                    max_concurrent: None,
270                    allowed_tools: None,
271                    parent_deadline_seconds: None,
272                    inputs: None,
273                    timeout_seconds: Some(10),
274                },
275            )
276            .await
277            .unwrap();
278
279        assert_eq!(result.thread_id, "child-thread");
280        assert_eq!(result.exit_reason, SubagentExitReason::Completed);
281    }
282}