Skip to main content

heartbit_core/tool/
handoff.rs

1//! Handoff tool for agent-to-agent conversation transfer.
2//!
3//! When an agent calls `handoff`, it signals that conversation control should
4//! transfer to a different agent. The `HandoffRunner` detects this sentinel
5//! output and routes the conversation accordingly.
6
7#![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/// How conversation context is transferred during a handoff.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HandoffContextMode {
21    /// Forward the full conversation history to the target agent.
22    Full,
23    /// Summarize the conversation and forward only the summary.
24    Summary,
25}
26
27/// A target agent that can receive a handoff.
28#[derive(Debug, Clone)]
29pub struct HandoffTarget {
30    pub name: String,
31    pub description: String,
32}
33
34/// Sentinel prefix in tool output that signals a handoff.
35pub(crate) const HANDOFF_SENTINEL: &str = "__handoff__:";
36
37/// Tool that allows an agent to hand off conversation control to a peer agent.
38///
39/// When called, returns a sentinel `ToolOutput` that the `HandoffRunner`
40/// detects to trigger the conversation transfer. The agent loop treats this
41/// as a normal tool result, but `HandoffRunner` inspects the final output.
42pub struct HandoffTool {
43    targets: Vec<HandoffTarget>,
44    cached_definition: ToolDefinition,
45}
46
47impl HandoffTool {
48    /// Create a new handoff tool with the given target agents.
49    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    /// Returns the list of valid target agent names.
94    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            // Validate target
130            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            // Return sentinel that HandoffRunner detects
148            Ok(ToolOutput::success(format!(
149                "{HANDOFF_SENTINEL}{target}:{mode}:{reason}",
150                target = handoff.target,
151                reason = handoff.reason,
152            )))
153        })
154    }
155}
156
157/// Parse a handoff sentinel from agent output text.
158///
159/// Returns `(target_name, context_mode, reason)` if the text contains a handoff sentinel.
160pub(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    // Format: target:mode:reason
167    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}