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}