1#![allow(missing_docs)]
8use std::future::Future;
9use std::pin::Pin;
10
11use serde::Deserialize;
12use serde_json::json;
13
14use crate::error::Error;
15use crate::llm::types::ToolDefinition;
16use crate::tool::{Tool, ToolOutput};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HandoffContextMode {
21 Full,
23 Summary,
25}
26
27#[derive(Debug, Clone)]
29pub struct HandoffTarget {
30 pub name: String,
31 pub description: String,
32}
33
34pub(crate) const HANDOFF_SENTINEL: &str = "__handoff__:";
36
37pub struct HandoffTool {
43 targets: Vec<HandoffTarget>,
44 cached_definition: ToolDefinition,
45}
46
47impl HandoffTool {
48 pub fn new(targets: Vec<HandoffTarget>) -> Self {
50 let target_descriptions: Vec<serde_json::Value> = targets
51 .iter()
52 .map(|t| json!({"name": t.name, "description": t.description}))
53 .collect();
54
55 let cached_definition = ToolDefinition {
56 name: "handoff".into(),
57 description: format!(
58 "Transfer conversation control to another agent. Use this when the user's \
59 request is better handled by a different specialist. The target agent will \
60 receive the conversation context and continue where you left off.\n\n\
61 Available targets: {}",
62 serde_json::to_string(&target_descriptions)
63 .expect("target serialization is infallible")
64 ),
65 input_schema: json!({
66 "type": "object",
67 "properties": {
68 "target": {
69 "type": "string",
70 "description": "Name of the agent to hand off to"
71 },
72 "reason": {
73 "type": "string",
74 "description": "Brief explanation of why you're handing off (forwarded to the target agent as context)"
75 },
76 "context_mode": {
77 "type": "string",
78 "enum": ["full", "summary"],
79 "default": "summary",
80 "description": "How to transfer conversation context: 'full' forwards the entire history, 'summary' sends a compact summary (default)"
81 }
82 },
83 "required": ["target", "reason"]
84 }),
85 };
86
87 Self {
88 targets,
89 cached_definition,
90 }
91 }
92
93 pub fn target_names(&self) -> Vec<&str> {
95 self.targets.iter().map(|t| t.name.as_str()).collect()
96 }
97}
98
99#[derive(Deserialize)]
100struct HandoffInput {
101 target: String,
102 reason: String,
103 #[serde(default)]
104 context_mode: HandoffContextModeInput,
105}
106
107#[derive(Deserialize, Default)]
108#[serde(rename_all = "lowercase")]
109enum HandoffContextModeInput {
110 Full,
111 #[default]
112 Summary,
113}
114
115impl Tool for HandoffTool {
116 fn definition(&self) -> ToolDefinition {
117 self.cached_definition.clone()
118 }
119
120 fn execute(
121 &self,
122 _ctx: &crate::ExecutionContext,
123 input: serde_json::Value,
124 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
125 Box::pin(async move {
126 let handoff: HandoffInput = serde_json::from_value(input)
127 .map_err(|e| Error::Agent(format!("Invalid handoff input: {e}")))?;
128
129 if !self.targets.iter().any(|t| t.name == handoff.target) {
131 return Ok(ToolOutput::error(format!(
132 "Unknown handoff target '{}'. Available: {}",
133 handoff.target,
134 self.targets
135 .iter()
136 .map(|t| t.name.as_str())
137 .collect::<Vec<_>>()
138 .join(", ")
139 )));
140 }
141
142 let mode = match handoff.context_mode {
143 HandoffContextModeInput::Full => "full",
144 HandoffContextModeInput::Summary => "summary",
145 };
146
147 Ok(ToolOutput::success(format!(
149 "{HANDOFF_SENTINEL}{target}:{mode}:{reason}",
150 target = handoff.target,
151 reason = handoff.reason,
152 )))
153 })
154 }
155}
156
157pub(crate) fn parse_handoff_sentinel(text: &str) -> Option<(String, HandoffContextMode, String)> {
161 let sentinel_line = text
162 .lines()
163 .find(|line| line.starts_with(HANDOFF_SENTINEL))?;
164 let payload = sentinel_line.strip_prefix(HANDOFF_SENTINEL)?;
165
166 let mut parts = payload.splitn(3, ':');
168 let target = parts.next()?.to_string();
169 let mode_str = parts.next().unwrap_or("summary");
170 let reason = parts.next().unwrap_or("").to_string();
171
172 let mode = match mode_str {
173 "full" => HandoffContextMode::Full,
174 _ => HandoffContextMode::Summary,
175 };
176
177 Some((target, mode, reason))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn handoff_tool_definition() {
186 let tool = HandoffTool::new(vec![
187 HandoffTarget {
188 name: "billing".into(),
189 description: "Billing specialist".into(),
190 },
191 HandoffTarget {
192 name: "support".into(),
193 description: "General support".into(),
194 },
195 ]);
196
197 let def = tool.definition();
198 assert_eq!(def.name, "handoff");
199 assert!(def.description.contains("billing"));
200 assert!(def.description.contains("support"));
201 }
202
203 #[test]
204 fn target_names() {
205 let tool = HandoffTool::new(vec![
206 HandoffTarget {
207 name: "a".into(),
208 description: "Agent A".into(),
209 },
210 HandoffTarget {
211 name: "b".into(),
212 description: "Agent B".into(),
213 },
214 ]);
215 assert_eq!(tool.target_names(), vec!["a", "b"]);
216 }
217
218 #[tokio::test]
219 async fn handoff_to_valid_target() {
220 let tool = HandoffTool::new(vec![HandoffTarget {
221 name: "billing".into(),
222 description: "Billing".into(),
223 }]);
224
225 let result = tool
226 .execute(
227 &crate::ExecutionContext::default(),
228 json!({
229 "target": "billing",
230 "reason": "User has a billing question"
231 }),
232 )
233 .await
234 .unwrap();
235
236 assert!(!result.is_error);
237 assert!(result.content.contains(HANDOFF_SENTINEL));
238 assert!(result.content.contains("billing"));
239 assert!(result.content.contains("User has a billing question"));
240 }
241
242 #[tokio::test]
243 async fn handoff_to_invalid_target() {
244 let tool = HandoffTool::new(vec![HandoffTarget {
245 name: "billing".into(),
246 description: "Billing".into(),
247 }]);
248
249 let result = tool
250 .execute(
251 &crate::ExecutionContext::default(),
252 json!({
253 "target": "nonexistent",
254 "reason": "test"
255 }),
256 )
257 .await
258 .unwrap();
259
260 assert!(result.is_error);
261 assert!(result.content.contains("Unknown handoff target"));
262 }
263
264 #[tokio::test]
265 async fn handoff_full_context_mode() {
266 let tool = HandoffTool::new(vec![HandoffTarget {
267 name: "support".into(),
268 description: "Support".into(),
269 }]);
270
271 let result = tool
272 .execute(
273 &crate::ExecutionContext::default(),
274 json!({
275 "target": "support",
276 "reason": "needs help",
277 "context_mode": "full"
278 }),
279 )
280 .await
281 .unwrap();
282
283 assert!(result.content.contains(":full:"));
284 }
285
286 #[test]
287 fn parse_sentinel_valid() {
288 let text = format!("{HANDOFF_SENTINEL}billing:summary:User wants billing help");
289 let (target, mode, reason) = parse_handoff_sentinel(&text).unwrap();
290 assert_eq!(target, "billing");
291 assert_eq!(mode, HandoffContextMode::Summary);
292 assert_eq!(reason, "User wants billing help");
293 }
294
295 #[test]
296 fn parse_sentinel_full_mode() {
297 let text = format!("{HANDOFF_SENTINEL}support:full:Complex issue");
298 let (target, mode, reason) = parse_handoff_sentinel(&text).unwrap();
299 assert_eq!(target, "support");
300 assert_eq!(mode, HandoffContextMode::Full);
301 assert_eq!(reason, "Complex issue");
302 }
303
304 #[test]
305 fn parse_sentinel_missing() {
306 assert!(parse_handoff_sentinel("normal output text").is_none());
307 }
308
309 #[test]
310 fn parse_sentinel_embedded_in_output() {
311 let text = format!(
312 "I'll transfer you now.\n{HANDOFF_SENTINEL}billing:summary:billing question\nDone."
313 );
314 let (target, _, _) = parse_handoff_sentinel(&text).unwrap();
315 assert_eq!(target, "billing");
316 }
317
318 #[tokio::test]
319 async fn handoff_invalid_json() {
320 let tool = HandoffTool::new(vec![]);
321 let result = tool
322 .execute(
323 &crate::ExecutionContext::default(),
324 json!({"wrong": "fields"}),
325 )
326 .await;
327 assert!(result.is_err());
328 }
329
330 #[test]
331 fn parse_sentinel_reason_with_colons() {
332 let text = format!("{HANDOFF_SENTINEL}agent:full:reason:with:colons");
333 let (target, mode, reason) = parse_handoff_sentinel(&text).unwrap();
334 assert_eq!(target, "agent");
335 assert_eq!(mode, HandoffContextMode::Full);
336 assert_eq!(reason, "reason:with:colons");
337 }
338}