use ai_agents_core::{AgentError, AgentResponse, Result};
use ai_agents_hooks::AgentHooks;
use ai_agents_llm::{ChatMessage, LLMProvider};
use ai_agents_observability::{ObservationPurpose, with_observation_purpose};
use serde_json::Value;
use tracing::{debug, info};
use super::types::{HandoffEvent, HandoffResult};
use crate::Agent;
use crate::spawner::AgentRegistry;
use crate::turn_context::current_turn_actor_context;
struct HandoffDecision {
action: String,
confidence: f32,
reason: String,
}
pub async fn handoff(
registry: &AgentRegistry,
input: &str,
initial_agent: &str,
available_agents: &[String],
max_handoffs: u32,
llm: &dyn LLMProvider,
hooks: Option<&dyn AgentHooks>,
) -> Result<HandoffResult> {
let mut current_agent = initial_agent.to_string();
let mut current_input = input.to_string();
let mut chain: Vec<HandoffEvent> = Vec::new();
let mut final_response = AgentResponse::new("");
let mut actor_context = current_turn_actor_context();
if let Some(h) = hooks {
h.on_handoff_start(initial_agent).await;
}
for _step in 0..=max_handoffs {
let agent = registry.get(¤t_agent).ok_or_else(|| {
AgentError::Other(format!("Handoff agent not found: {}", current_agent))
})?;
debug!(agent = %current_agent, "Handoff chain executing agent");
let response = if let Some(context) = actor_context.clone() {
agent
.chat_with_actor_context(¤t_input, context)
.await?
} else {
agent.chat(¤t_input).await?
};
final_response = response.clone();
let candidates: Vec<&str> = available_agents
.iter()
.filter(|a| a.as_str() != current_agent.as_str())
.map(|a| a.as_str())
.collect();
if candidates.is_empty() {
debug!("No other agents available for handoff, ending chain");
break;
}
let decision = evaluate_handoff(
llm,
¤t_agent,
&candidates,
¤t_input,
&response.content,
)
.await?;
if decision.action != "stay" {
let next_agent = decision.action.clone();
if available_agents.contains(&next_agent) && registry.contains(&next_agent) {
info!(
from = %current_agent,
to = %next_agent,
confidence = %decision.confidence,
reason = %decision.reason,
"Handoff"
);
chain.push(HandoffEvent {
from_agent: current_agent.clone(),
to_agent: next_agent.clone(),
reason: decision.reason,
});
if let Some(h) = hooks {
let last = chain.last().unwrap();
h.on_handoff(&last.from_agent, &last.to_agent, &last.reason)
.await;
}
current_input = format!(
"Continuing conversation from another agent. Previous context: {}",
response.content
);
if let Some(context) = actor_context.as_mut() {
*context = context.for_sender(current_agent.clone());
}
current_agent = next_agent;
continue;
}
debug!(
candidate = %next_agent,
"LLM suggested handoff to unknown or unavailable agent, staying"
);
}
break;
}
info!(
initial = %initial_agent,
final_agent = %current_agent,
handoffs = chain.len(),
"Handoff chain completed"
);
Ok(HandoffResult {
response: final_response,
handoff_chain: chain,
final_agent: current_agent,
})
}
async fn evaluate_handoff(
llm: &dyn LLMProvider,
current_agent: &str,
candidates: &[&str],
user_input: &str,
agent_response: &str,
) -> Result<HandoffDecision> {
let agent_list = candidates.join(", ");
let messages = vec![
ChatMessage::system(format!(
"You are evaluating whether a conversation should be handed off to a different specialist.\n\
Current agent: {current_agent}\n\
Available agents: {agent_list}\n\n\
Respond with ONLY a JSON object in this exact format:\n\
{{\"action\": \"stay\" or \"<agent_id>\", \"confidence\": 0.0 to 1.0, \"reason\": \"brief explanation\"}}\n\n\
Set action to \"stay\" if the current agent is handling the conversation well.\n\
Set action to one of the available agent IDs if a handoff is needed."
)),
ChatMessage::user(format!(
"User message: {user_input}\n\nAgent response: {agent_response}"
)),
];
let response = with_observation_purpose(
ObservationPurpose::OrchestrationRouting,
llm.complete(&messages, None),
)
.await
.map_err(|e| AgentError::LLM(format!("Handoff decision failed: {}", e)))?;
let raw = response.content.trim();
if let Some(decision) = try_parse_handoff_json(raw, candidates) {
return Ok(decision);
}
debug!("JSON parse failed, falling back to fuzzy parse");
Ok(fuzzy_parse_handoff(raw, candidates))
}
fn try_parse_handoff_json(raw: &str, candidates: &[&str]) -> Option<HandoffDecision> {
let value = extract_json_value(raw)?;
let action_raw = value.get("action")?.as_str()?;
let confidence = value
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.5) as f32;
let reason = value
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("no reason provided")
.to_string();
let action_lower = action_raw.trim().to_lowercase();
if action_lower == "stay" {
return Some(HandoffDecision {
action: "stay".to_string(),
confidence,
reason,
});
}
let matched = candidates
.iter()
.find(|c| c.to_lowercase() == action_lower)?;
Some(HandoffDecision {
action: matched.to_string(),
confidence,
reason,
})
}
fn extract_json_value(raw: &str) -> Option<Value> {
if let Ok(v) = serde_json::from_str::<Value>(raw)
&& v.is_object()
{
return Some(v);
}
if let Some(start) = raw.find("```") {
let after_fence = &raw[start + 3..];
let body_start = after_fence.find('\n').map(|i| i + 1).unwrap_or(0);
let body = &after_fence[body_start..];
if let Some(end) = body.find("```") {
let block = body[..end].trim();
if let Ok(v) = serde_json::from_str::<Value>(block)
&& v.is_object()
{
return Some(v);
}
}
}
let open = raw.find('{')?;
let close = raw.rfind('}')?;
if close > open {
let slice = &raw[open..=close];
if let Ok(v) = serde_json::from_str::<Value>(slice)
&& v.is_object()
{
return Some(v);
}
}
None
}
fn fuzzy_parse_handoff(raw: &str, candidates: &[&str]) -> HandoffDecision {
let lower = raw.to_lowercase();
let trimmed = lower.trim_start();
if trimmed.starts_with("stay")
|| trimmed.starts_with("no handoff")
|| trimmed.starts_with("no,")
{
return HandoffDecision {
action: "stay".to_string(),
confidence: 0.5,
reason: "fuzzy: detected stay signal".to_string(),
};
}
let mut sorted: Vec<&str> = candidates.to_vec();
sorted.sort_by_key(|candidate| std::cmp::Reverse(candidate.len()));
for candidate in &sorted {
if lower.contains(&candidate.to_lowercase()) {
return HandoffDecision {
action: candidate.to_string(),
confidence: 0.3,
reason: "fuzzy: matched agent name in text".to_string(),
};
}
}
HandoffDecision {
action: "stay".to_string(),
confidence: 0.1,
reason: "fuzzy: no match found, defaulting to stay".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_try_parse_handoff_json_valid() {
let raw = r#"{"action": "billing_agent", "confidence": 0.9, "reason": "user asked about billing"}"#;
let candidates = &["billing_agent", "support_agent"];
let decision = try_parse_handoff_json(raw, candidates).unwrap();
assert_eq!(decision.action, "billing_agent");
assert!((decision.confidence - 0.9).abs() < 0.01);
assert_eq!(decision.reason, "user asked about billing");
}
#[test]
fn test_try_parse_handoff_json_markdown_block() {
let raw = "Here is my decision:\n```json\n{\"action\": \"stay\", \"confidence\": 0.8, \"reason\": \"handling well\"}\n```";
let candidates = &["billing_agent"];
let decision = try_parse_handoff_json(raw, candidates).unwrap();
assert_eq!(decision.action, "stay");
assert!((decision.confidence - 0.8).abs() < 0.01);
}
#[test]
fn test_try_parse_handoff_json_with_preamble() {
let raw = "I think we should hand off. {\"action\": \"support_agent\", \"confidence\": 0.7, \"reason\": \"needs help\"}";
let candidates = &["support_agent", "billing_agent"];
let decision = try_parse_handoff_json(raw, candidates).unwrap();
assert_eq!(decision.action, "support_agent");
assert!((decision.confidence - 0.7).abs() < 0.01);
}
#[test]
fn test_try_parse_handoff_json_case_insensitive() {
let raw =
r#"{"action": "Billing_Agent", "confidence": 0.85, "reason": "billing question"}"#;
let candidates = &["billing_agent", "support_agent"];
let decision = try_parse_handoff_json(raw, candidates).unwrap();
assert_eq!(decision.action, "billing_agent");
}
#[test]
fn test_try_parse_handoff_json_unknown_agent() {
let raw = r#"{"action": "nonexistent_agent", "confidence": 0.9, "reason": "some reason"}"#;
let candidates = &["billing_agent", "support_agent"];
let result = try_parse_handoff_json(raw, candidates);
assert!(result.is_none());
}
#[test]
fn test_fuzzy_parse_stay() {
let candidates = &["billing_agent", "support_agent"];
let d1 = fuzzy_parse_handoff("Stay with current agent", candidates);
assert_eq!(d1.action, "stay");
let d2 = fuzzy_parse_handoff("No handoff is needed here", candidates);
assert_eq!(d2.action, "stay");
let d3 = fuzzy_parse_handoff("No, the agent is doing fine", candidates);
assert_eq!(d3.action, "stay");
}
#[test]
fn test_fuzzy_parse_agent_match() {
let candidates = &["billing_agent", "support_agent"];
let decision = fuzzy_parse_handoff(
"I think we should transfer to billing_agent for this",
candidates,
);
assert_eq!(decision.action, "billing_agent");
}
#[test]
fn test_fuzzy_parse_longest_match_first() {
let candidates = &["agent", "super_agent"];
let decision = fuzzy_parse_handoff("Route to super_agent please", candidates);
assert_eq!(decision.action, "super_agent");
}
#[test]
fn test_fuzzy_parse_no_match() {
let candidates = &["billing_agent", "support_agent"];
let decision = fuzzy_parse_handoff("Something completely unrelated", candidates);
assert_eq!(decision.action, "stay");
assert!(decision.confidence < 0.2);
}
#[test]
fn test_extract_json_value_raw() {
let raw = r#"{"action": "stay", "confidence": 0.5, "reason": "ok"}"#;
let value = extract_json_value(raw).unwrap();
assert_eq!(value["action"], "stay");
}
#[test]
fn test_extract_json_value_no_json() {
let raw = "This is just plain text with no JSON at all.";
assert!(extract_json_value(raw).is_none());
}
}