#![allow(missing_docs)]
use std::future::Future;
use std::pin::Pin;
use serde::Deserialize;
use serde_json::json;
use crate::error::Error;
use crate::llm::types::ToolDefinition;
use crate::tool::{Tool, ToolOutput};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandoffContextMode {
Full,
Summary,
}
#[derive(Debug, Clone)]
pub struct HandoffTarget {
pub name: String,
pub description: String,
}
pub(crate) const HANDOFF_SENTINEL: &str = "__handoff__:";
pub struct HandoffTool {
targets: Vec<HandoffTarget>,
cached_definition: ToolDefinition,
}
impl HandoffTool {
pub fn new(targets: Vec<HandoffTarget>) -> Self {
let target_descriptions: Vec<serde_json::Value> = targets
.iter()
.map(|t| json!({"name": t.name, "description": t.description}))
.collect();
let cached_definition = ToolDefinition {
name: "handoff".into(),
description: format!(
"Transfer conversation control to another agent. Use this when the user's \
request is better handled by a different specialist. The target agent will \
receive the conversation context and continue where you left off.\n\n\
Available targets: {}",
serde_json::to_string(&target_descriptions)
.expect("target serialization is infallible")
),
input_schema: json!({
"type": "object",
"properties": {
"target": {
"type": "string",
"description": "Name of the agent to hand off to"
},
"reason": {
"type": "string",
"description": "Brief explanation of why you're handing off (forwarded to the target agent as context)"
},
"context_mode": {
"type": "string",
"enum": ["full", "summary"],
"default": "summary",
"description": "How to transfer conversation context: 'full' forwards the entire history, 'summary' sends a compact summary (default)"
}
},
"required": ["target", "reason"]
}),
};
Self {
targets,
cached_definition,
}
}
pub fn target_names(&self) -> Vec<&str> {
self.targets.iter().map(|t| t.name.as_str()).collect()
}
}
#[derive(Deserialize)]
struct HandoffInput {
target: String,
reason: String,
#[serde(default)]
context_mode: HandoffContextModeInput,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "lowercase")]
enum HandoffContextModeInput {
Full,
#[default]
Summary,
}
impl Tool for HandoffTool {
fn definition(&self) -> ToolDefinition {
self.cached_definition.clone()
}
fn execute(
&self,
_ctx: &crate::ExecutionContext,
input: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
Box::pin(async move {
let handoff: HandoffInput = serde_json::from_value(input)
.map_err(|e| Error::Agent(format!("Invalid handoff input: {e}")))?;
if !self.targets.iter().any(|t| t.name == handoff.target) {
return Ok(ToolOutput::error(format!(
"Unknown handoff target '{}'. Available: {}",
handoff.target,
self.targets
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
let mode = match handoff.context_mode {
HandoffContextModeInput::Full => "full",
HandoffContextModeInput::Summary => "summary",
};
Ok(ToolOutput::success(format!(
"{HANDOFF_SENTINEL}{target}:{mode}:{reason}",
target = handoff.target,
reason = handoff.reason,
)))
})
}
}
pub(crate) fn parse_handoff_sentinel(text: &str) -> Option<(String, HandoffContextMode, String)> {
let sentinel_line = text
.lines()
.find(|line| line.starts_with(HANDOFF_SENTINEL))?;
let payload = sentinel_line.strip_prefix(HANDOFF_SENTINEL)?;
let mut parts = payload.splitn(3, ':');
let target = parts.next()?.to_string();
let mode_str = parts.next().unwrap_or("summary");
let reason = parts.next().unwrap_or("").to_string();
let mode = match mode_str {
"full" => HandoffContextMode::Full,
_ => HandoffContextMode::Summary,
};
Some((target, mode, reason))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handoff_tool_definition() {
let tool = HandoffTool::new(vec![
HandoffTarget {
name: "billing".into(),
description: "Billing specialist".into(),
},
HandoffTarget {
name: "support".into(),
description: "General support".into(),
},
]);
let def = tool.definition();
assert_eq!(def.name, "handoff");
assert!(def.description.contains("billing"));
assert!(def.description.contains("support"));
}
#[test]
fn target_names() {
let tool = HandoffTool::new(vec![
HandoffTarget {
name: "a".into(),
description: "Agent A".into(),
},
HandoffTarget {
name: "b".into(),
description: "Agent B".into(),
},
]);
assert_eq!(tool.target_names(), vec!["a", "b"]);
}
#[tokio::test]
async fn handoff_to_valid_target() {
let tool = HandoffTool::new(vec![HandoffTarget {
name: "billing".into(),
description: "Billing".into(),
}]);
let result = tool
.execute(
&crate::ExecutionContext::default(),
json!({
"target": "billing",
"reason": "User has a billing question"
}),
)
.await
.unwrap();
assert!(!result.is_error);
assert!(result.content.contains(HANDOFF_SENTINEL));
assert!(result.content.contains("billing"));
assert!(result.content.contains("User has a billing question"));
}
#[tokio::test]
async fn handoff_to_invalid_target() {
let tool = HandoffTool::new(vec![HandoffTarget {
name: "billing".into(),
description: "Billing".into(),
}]);
let result = tool
.execute(
&crate::ExecutionContext::default(),
json!({
"target": "nonexistent",
"reason": "test"
}),
)
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("Unknown handoff target"));
}
#[tokio::test]
async fn handoff_full_context_mode() {
let tool = HandoffTool::new(vec![HandoffTarget {
name: "support".into(),
description: "Support".into(),
}]);
let result = tool
.execute(
&crate::ExecutionContext::default(),
json!({
"target": "support",
"reason": "needs help",
"context_mode": "full"
}),
)
.await
.unwrap();
assert!(result.content.contains(":full:"));
}
#[test]
fn parse_sentinel_valid() {
let text = format!("{HANDOFF_SENTINEL}billing:summary:User wants billing help");
let (target, mode, reason) = parse_handoff_sentinel(&text).unwrap();
assert_eq!(target, "billing");
assert_eq!(mode, HandoffContextMode::Summary);
assert_eq!(reason, "User wants billing help");
}
#[test]
fn parse_sentinel_full_mode() {
let text = format!("{HANDOFF_SENTINEL}support:full:Complex issue");
let (target, mode, reason) = parse_handoff_sentinel(&text).unwrap();
assert_eq!(target, "support");
assert_eq!(mode, HandoffContextMode::Full);
assert_eq!(reason, "Complex issue");
}
#[test]
fn parse_sentinel_missing() {
assert!(parse_handoff_sentinel("normal output text").is_none());
}
#[test]
fn parse_sentinel_embedded_in_output() {
let text = format!(
"I'll transfer you now.\n{HANDOFF_SENTINEL}billing:summary:billing question\nDone."
);
let (target, _, _) = parse_handoff_sentinel(&text).unwrap();
assert_eq!(target, "billing");
}
#[tokio::test]
async fn handoff_invalid_json() {
let tool = HandoffTool::new(vec![]);
let result = tool
.execute(
&crate::ExecutionContext::default(),
json!({"wrong": "fields"}),
)
.await;
assert!(result.is_err());
}
#[test]
fn parse_sentinel_reason_with_colons() {
let text = format!("{HANDOFF_SENTINEL}agent:full:reason:with:colons");
let (target, mode, reason) = parse_handoff_sentinel(&text).unwrap();
assert_eq!(target, "agent");
assert_eq!(mode, HandoffContextMode::Full);
assert_eq!(reason, "reason:with:colons");
}
}