Skip to main content

vtcode_core/core/agent/
handoff.rs

1//! Structured agent handoff protocol (Swarm pattern).
2//!
3//! Implements handoff types that allow one agent to transfer control to another
4//! agent with full conversation context.  This follows the Swarm pattern
5//! described in "The Hitchhiker's Guide to Agentic AI" ยง18.5.3:
6//!
7//! - Agents have instructions and tools.
8//! - Handoffs are special tools that transfer control.
9//! - Context variables are shared state passed between agents.
10//! - The active agent changes dynamically based on task needs.
11
12use serde::{Deserialize, Serialize};
13
14/// Status of a single feature or deliverable at handoff time.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum BoundaryStatus {
18    /// Completed and verified.
19    Done,
20    /// Actively being worked on.
21    InProgress,
22    /// Not yet started.
23    NotStarted,
24    /// Cannot proceed without external input or a replan.
25    Blocked,
26}
27
28/// A single item in the boundary list that explicitly marks what is done,
29/// in-progress, or not started. This prevents the next agent from guessing
30/// whether something is intentionally incomplete or a leftover mess.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BoundaryItem {
33    /// Feature or deliverable name.
34    pub feature: String,
35    /// Current status.
36    pub status: BoundaryStatus,
37    /// Optional notes (e.g. "blocked on API key", "tests pass but UI needs polish").
38    #[serde(default)]
39    pub notes: Option<String>,
40}
41
42/// A handoff request from one agent to another.
43///
44/// Carries the current state context so the receiving agent can continue
45/// without re-explaining the situation. Enriched with test results, boundary
46/// status, known issues, and recommended next actions following the long-running
47/// harness pattern: the next agent must be able to orient from this artifact alone.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HandoffRequest {
50    /// The target agent name (subagent spec name or alias).
51    pub target_agent: String,
52
53    /// Current state summary: what was accomplished, what remains.
54    pub state_summary: String,
55
56    /// Files that have been modified so far.
57    #[serde(default)]
58    pub modified_files: Vec<String>,
59
60    /// Any unresolved decisions the target needs to know about.
61    #[serde(default)]
62    pub open_decisions: Vec<String>,
63
64    /// The original task context (carried through).
65    pub task_context: String,
66
67    /// Optional file attachment paths for additional context.
68    #[serde(default)]
69    pub context_files: Vec<String>,
70
71    /// Last test run outcome: pass/fail summary with actual output.
72    /// None if no tests were run in this session.
73    #[serde(default)]
74    pub test_results: Option<String>,
75
76    /// Explicit boundary list: what is done, in-progress, not started, or blocked.
77    /// Prevents the next agent from guessing intent vs. incompleteness.
78    #[serde(default)]
79    pub boundary_status: Vec<BoundaryItem>,
80
81    /// Known issues the next agent should be aware of (bugs, limitations, tech debt).
82    #[serde(default)]
83    pub known_issues: Vec<String>,
84
85    /// Recommended next actions for the receiving agent.
86    #[serde(default)]
87    pub next_actions: Vec<String>,
88}
89
90/// The handoff receipt that the target agent writes back.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct HandoffReceipt {
93    /// Whether the handoff was accepted.
94    pub accepted: bool,
95
96    /// A message from the target agent about how it will proceed.
97    #[serde(default)]
98    pub continuation_message: String,
99
100    /// Optional updated plan from the target agent.
101    #[serde(default)]
102    pub revised_plan: Option<String>,
103}
104
105impl HandoffRequest {
106    /// Serialize the handoff context into a prompt for the target agent.
107    ///
108    /// The prompt follows the long-running harness pattern: it gives the next
109    /// agent everything it needs to orient -- state, boundaries, test results,
110    /// known issues, and recommended next actions -- so it can pick up without
111    /// re-exploring the codebase from scratch.
112    pub fn to_handoff_prompt(&self) -> String {
113        let mut parts = vec![
114            "## HANDOFF FROM PREVIOUS AGENT".to_string(),
115            String::new(),
116            "Previous agent completed work and is handing off to you.".to_string(),
117            String::new(),
118            format!("### Current State\n{}", self.state_summary),
119        ];
120
121        if !self.boundary_status.is_empty() {
122            let items: Vec<String> = self
123                .boundary_status
124                .iter()
125                .map(|item| {
126                    let status_str = match item.status {
127                        BoundaryStatus::Done => "DONE",
128                        BoundaryStatus::InProgress => "IN PROGRESS",
129                        BoundaryStatus::NotStarted => "NOT STARTED",
130                        BoundaryStatus::Blocked => "BLOCKED",
131                    };
132                    match &item.notes {
133                        Some(notes) => format!("- [{}] {} -- {}", status_str, item.feature, notes),
134                        None => format!("- [{}] {}", status_str, item.feature),
135                    }
136                })
137                .collect();
138            parts.push(format!("### Boundary Status\n{}", items.join("\n")));
139        }
140
141        if !self.modified_files.is_empty() {
142            parts.push(format!("### Modified Files\n{}", self.modified_files.join("\n")));
143        }
144
145        if let Some(test_results) = &self.test_results {
146            parts.push(format!("### Test Results\n{test_results}"));
147        }
148
149        if !self.open_decisions.is_empty() {
150            parts.push(format!("### Open Decisions\n{}", self.open_decisions.join("\n")));
151        }
152
153        if !self.known_issues.is_empty() {
154            parts.push(format!(
155                "### Known Issues\n{}",
156                self.known_issues
157                    .iter()
158                    .map(|i| format!("- {i}"))
159                    .collect::<Vec<_>>()
160                    .join("\n")
161            ));
162        }
163
164        if !self.next_actions.is_empty() {
165            parts.push(format!(
166                "### Recommended Next Actions\n{}",
167                self.next_actions
168                    .iter()
169                    .map(|a| format!("1. {a}"))
170                    .collect::<Vec<_>>()
171                    .join("\n")
172            ));
173        }
174
175        parts.push(format!(
176            "### Task Context\n{}\n\n\
177             Continue from where the previous agent left off. \
178             Read the modified files to understand the current state. \
179             Check boundary status to know what's done vs. what needs work.",
180            self.task_context
181        ));
182
183        parts.join("\n\n")
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn minimal_request() -> HandoffRequest {
192        HandoffRequest {
193            target_agent: "coder".into(),
194            state_summary: "Implemented the login feature".into(),
195            modified_files: vec!["src/auth.rs".into()],
196            open_decisions: vec!["Should we use JWT?".into()],
197            task_context: "Build authentication system".into(),
198            context_files: vec![],
199            test_results: None,
200            boundary_status: vec![],
201            known_issues: vec![],
202            next_actions: vec![],
203        }
204    }
205
206    #[test]
207    fn handoff_request_round_trip() {
208        let request = minimal_request();
209        let json = serde_json::to_string(&request).expect("serialize");
210        let deserialized: HandoffRequest = serde_json::from_str(&json).expect("deserialize");
211
212        assert_eq!(deserialized.target_agent, "coder");
213        assert_eq!(deserialized.state_summary, "Implemented the login feature");
214    }
215
216    #[test]
217    fn handoff_request_round_trip_with_new_fields() {
218        let request = HandoffRequest {
219            target_agent: "verifier".into(),
220            state_summary: "All features implemented".into(),
221            modified_files: vec!["src/main.rs".into()],
222            open_decisions: vec![],
223            task_context: "Review the implementation".into(),
224            context_files: vec![],
225            test_results: Some("12 passed, 0 failed".into()),
226            boundary_status: vec![
227                BoundaryItem {
228                    feature: "login".into(),
229                    status: BoundaryStatus::Done,
230                    notes: Some("JWT auth working".into()),
231                },
232                BoundaryItem {
233                    feature: "signup".into(),
234                    status: BoundaryStatus::InProgress,
235                    notes: None,
236                },
237            ],
238            known_issues: vec!["Rate limiting not implemented".into()],
239            next_actions: vec!["Add rate limiting".into(), "Write integration tests".into()],
240        };
241
242        let json = serde_json::to_string(&request).expect("serialize");
243        let deserialized: HandoffRequest = serde_json::from_str(&json).expect("deserialize");
244
245        assert_eq!(deserialized.test_results.as_deref(), Some("12 passed, 0 failed"));
246        assert_eq!(deserialized.boundary_status.len(), 2);
247        assert_eq!(deserialized.boundary_status[0].status, BoundaryStatus::Done);
248        assert_eq!(deserialized.boundary_status[1].status, BoundaryStatus::InProgress);
249        assert_eq!(deserialized.known_issues.len(), 1);
250        assert_eq!(deserialized.next_actions.len(), 2);
251    }
252
253    #[test]
254    fn handoff_prompt_includes_state() {
255        let request = minimal_request();
256        let prompt = request.to_handoff_prompt();
257        assert!(!prompt.contains("Code is ready for review")); // not in minimal
258        assert!(prompt.contains("src/auth.rs"));
259        assert!(prompt.contains("Build authentication system"));
260        assert!(prompt.contains("HANDOFF FROM PREVIOUS AGENT"));
261    }
262
263    #[test]
264    fn handoff_prompt_includes_boundary_status() {
265        let request = HandoffRequest {
266            target_agent: "verifier".into(),
267            state_summary: "Code ready".into(),
268            modified_files: vec![],
269            open_decisions: vec![],
270            task_context: "Review".into(),
271            context_files: vec![],
272            test_results: None,
273            boundary_status: vec![
274                BoundaryItem {
275                    feature: "auth".into(),
276                    status: BoundaryStatus::Done,
277                    notes: None,
278                },
279                BoundaryItem {
280                    feature: "api".into(),
281                    status: BoundaryStatus::InProgress,
282                    notes: Some("endpoints working, tests pending".into()),
283                },
284            ],
285            known_issues: vec![],
286            next_actions: vec![],
287        };
288
289        let prompt = request.to_handoff_prompt();
290        assert!(prompt.contains("[DONE] auth"));
291        assert!(prompt.contains("[IN PROGRESS] api -- endpoints working, tests pending"));
292    }
293
294    #[test]
295    fn handoff_prompt_includes_test_results() {
296        let request = HandoffRequest {
297            target_agent: "verifier".into(),
298            state_summary: "Done".into(),
299            modified_files: vec![],
300            open_decisions: vec![],
301            task_context: "Review".into(),
302            context_files: vec![],
303            test_results: Some(
304                "15 passed, 2 failed\n  FAIL: test_login_rate_limit\n  FAIL: test_signup_validation".into(),
305            ),
306            boundary_status: vec![],
307            known_issues: vec!["Rate limiting missing".into()],
308            next_actions: vec!["Fix failing tests".into()],
309        };
310
311        let prompt = request.to_handoff_prompt();
312        assert!(prompt.contains("### Test Results"));
313        assert!(prompt.contains("15 passed, 2 failed"));
314        assert!(prompt.contains("### Known Issues"));
315        assert!(prompt.contains("- Rate limiting missing"));
316        assert!(prompt.contains("### Recommended Next Actions"));
317        assert!(prompt.contains("1. Fix failing tests"));
318    }
319
320    #[test]
321    fn handoff_prompt_omits_empty_sections() {
322        let request = minimal_request();
323        let prompt = request.to_handoff_prompt();
324        // These sections should be absent when empty/None
325        assert!(!prompt.contains("### Boundary Status"));
326        assert!(!prompt.contains("### Test Results"));
327        assert!(!prompt.contains("### Known Issues"));
328        assert!(!prompt.contains("### Recommended Next Actions"));
329        // These should always be present
330        assert!(prompt.contains("### Current State"));
331        assert!(prompt.contains("### Modified Files"));
332        assert!(prompt.contains("### Open Decisions"));
333        assert!(prompt.contains("### Task Context"));
334    }
335
336    #[test]
337    fn handoff_receipt_round_trip() {
338        let receipt = HandoffReceipt {
339            accepted: true,
340            continuation_message: "Will review the code".into(),
341            revised_plan: Some("1. Check auth".into()),
342        };
343
344        let json = serde_json::to_string(&receipt).expect("serialize");
345        let deserialized: HandoffReceipt = serde_json::from_str(&json).expect("deserialize");
346
347        assert!(deserialized.accepted);
348        assert_eq!(deserialized.continuation_message, "Will review the code");
349    }
350
351    #[test]
352    fn boundary_status_default_deserialization() {
353        // Verify that old-format JSON without new fields still deserializes
354        let json = r#"{
355            "target_agent": "coder",
356            "state_summary": "done",
357            "task_context": "build",
358            "modified_files": [],
359            "open_decisions": [],
360            "context_files": []
361        }"#;
362        let request: HandoffRequest = serde_json::from_str(json).expect("deserialize");
363        assert!(request.test_results.is_none());
364        assert!(request.boundary_status.is_empty());
365        assert!(request.known_issues.is_empty());
366        assert!(request.next_actions.is_empty());
367    }
368}