1use std::sync::Arc;
2
3use serde_json::{json, Value};
4
5use crate::agent::Agent;
6use crate::tools::{ToolContext, ToolHandler, ToolSpec, ToolSpecKind};
7use crate::types::{ToolArguments, ToolDirective, ToolExecutionResult, ToolResultStatus};
8
9#[derive(Clone)]
10pub struct Handoff {
11 target: Arc<Agent>,
12 description: Option<String>,
13 tool_name: String,
14 metadata: std::collections::BTreeMap<String, Value>,
15}
16
17impl Handoff {
18 pub fn target(&self) -> &Agent {
19 &self.target
20 }
21
22 pub fn description(&self) -> Option<&str> {
23 self.description.as_deref()
24 }
25
26 pub fn tool_name(&self) -> &str {
27 &self.tool_name
28 }
29
30 pub fn metadata(&self) -> &std::collections::BTreeMap<String, Value> {
31 &self.metadata
32 }
33
34 pub fn as_tool_spec(&self, from_agent: &str) -> ToolSpec {
35 let target = self.target.name().to_string();
36 let description = self
37 .description
38 .clone()
39 .unwrap_or_else(|| format!("Transfer the conversation to {target}."));
40 let from_agent = from_agent.to_string();
41 let tool_name = self.tool_name.clone();
42 let tool_name_for_handler = tool_name.clone();
43 let handoff_metadata = self.metadata.clone();
44 let target_for_handler = target.clone();
45 let handler: ToolHandler = Arc::new(
46 move |_context: &mut ToolContext, arguments: &ToolArguments| {
47 handoff_tool_result(
48 &from_agent,
49 &target_for_handler,
50 &tool_name_for_handler,
51 arguments,
52 &handoff_metadata,
53 )
54 },
55 );
56 let mut spec = ToolSpec::new(tool_name.clone(), description.clone(), handler);
57 spec.kind = ToolSpecKind::Handoff;
58 spec.schema = json!({
59 "type": "function",
60 "function": {
61 "name": tool_name,
62 "description": description,
63 "parameters": {
64 "type": "object",
65 "properties": {
66 "input": {
67 "type": "string",
68 "description": "Input or handoff summary for the target agent.",
69 "minLength": 1
70 }
71 },
72 "required": ["input"],
73 "additionalProperties": false
74 }
75 }
76 });
77 spec
78 }
79}
80
81pub fn handoff(agent: &Agent) -> HandoffBuilder {
82 HandoffBuilder {
83 target: Arc::new(agent.clone()),
84 description: None,
85 tool_name: None,
86 metadata: std::collections::BTreeMap::new(),
87 }
88}
89
90pub struct HandoffBuilder {
91 target: Arc<Agent>,
92 description: Option<String>,
93 tool_name: Option<String>,
94 metadata: std::collections::BTreeMap<String, Value>,
95}
96
97impl HandoffBuilder {
98 pub fn description(mut self, description: impl Into<String>) -> Self {
99 self.description = Some(description.into());
100 self
101 }
102
103 pub fn name(mut self, name: impl Into<String>) -> Self {
104 self.tool_name = Some(name.into());
105 self
106 }
107
108 pub fn metadata(mut self, key: impl Into<String>, value: Value) -> Self {
109 self.metadata.insert(key.into(), value);
110 self
111 }
112
113 pub fn build(self) -> Handoff {
114 let target_name = self.target.name().to_string();
115 Handoff {
116 target: self.target,
117 description: self.description,
118 tool_name: self
119 .tool_name
120 .unwrap_or_else(|| format!("transfer_to_{}", slugify(&target_name))),
121 metadata: self.metadata,
122 }
123 }
124}
125
126impl From<HandoffBuilder> for Handoff {
127 fn from(builder: HandoffBuilder) -> Self {
128 builder.build()
129 }
130}
131
132fn handoff_tool_result(
133 from_agent: &str,
134 to_agent: &str,
135 tool_name: &str,
136 arguments: &ToolArguments,
137 handoff_metadata: &std::collections::BTreeMap<String, Value>,
138) -> ToolExecutionResult {
139 let input = arguments
140 .get("input")
141 .and_then(Value::as_str)
142 .map(str::trim)
143 .filter(|value| !value.is_empty());
144 if arguments.len() != 1 || input.is_none() {
145 return ToolExecutionResult {
146 tool_call_id: String::new(),
147 content: json!({
148 "ok": false,
149 "error": "handoff requires a non-empty input string and no additional arguments",
150 "error_code": "invalid_handoff_arguments",
151 })
152 .to_string(),
153 status: ToolResultStatus::Error,
154 directive: ToolDirective::Continue,
155 error_code: Some("invalid_handoff_arguments".to_string()),
156 metadata: std::collections::BTreeMap::new(),
157 image_url: None,
158 image_path: None,
159 };
160 }
161 let input = input.expect("validated handoff input").to_string();
162 let mut metadata = handoff_metadata.clone();
163 metadata.insert("mode".to_string(), Value::String("handoff".to_string()));
164 metadata.insert(
165 "handoff_from".to_string(),
166 Value::String(from_agent.to_string()),
167 );
168 metadata.insert(
169 "handoff_to".to_string(),
170 Value::String(to_agent.to_string()),
171 );
172 metadata.insert("handoff_input".to_string(), Value::String(input.clone()));
173 metadata.insert(
174 "handoff_tool_name".to_string(),
175 Value::String(tool_name.to_string()),
176 );
177 ToolExecutionResult {
178 tool_call_id: String::new(),
179 content: json!({
180 "ok": true,
181 "handoff": true,
182 "from_agent": from_agent,
183 "to_agent": to_agent,
184 "input": input,
185 })
186 .to_string(),
187 status: ToolResultStatus::Success,
188 directive: ToolDirective::Finish,
189 error_code: None,
190 metadata,
191 image_url: None,
192 image_path: None,
193 }
194}
195
196fn slugify(value: &str) -> String {
197 let mut normalized = String::new();
198 let mut pending_separator = false;
199 for character in value.chars() {
200 if character.is_ascii_alphanumeric() || character == '_' {
201 if pending_separator && !normalized.is_empty() && !normalized.ends_with('_') {
202 normalized.push('_');
203 }
204 pending_separator = false;
205 normalized.push(character.to_ascii_lowercase());
206 } else {
207 pending_separator = true;
208 }
209 }
210 let normalized = normalized.trim_matches('_').to_string();
211 if normalized.is_empty() {
212 "agent".to_string()
213 } else {
214 normalized
215 }
216}