Skip to main content

vv_agent/
handoffs.rs

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}