use polyc_llm::{Message as LlmMessage, ToolSpec};
pub const HANDOFF_TOOL_NAME: &str = "__handoff_to";
#[must_use]
pub fn handoff_tool_spec() -> ToolSpec {
ToolSpec {
name: HANDOFF_TOOL_NAME.to_owned(),
description:
"Delegate the current task to a child sub-agent. The current turn suspends; the \
child runs in its own isolated conversation with the carried context; the child's \
final message is returned to this conversation on the next turn. Use sparingly — \
handoffs are expensive (cold-start a sandbox). `child_agent_id` selects the \
planner; `reason` is recorded for operator visibility; `max_carry` bounds how many \
of the most recent messages the child sees (default 5)."
.to_owned(),
schema_json: serde_json::json!({
"type": "object",
"properties": {
"child_agent_id": {
"type": "string",
"description": "Identifier of the child agent / planner to spawn."
},
"reason": {
"type": "string",
"description": "Short rationale for the delegation."
},
"max_carry": {
"type": "integer",
"minimum": 0,
"description": "How many of the most recent messages from this conversation to inject as the child's initial transcript. Default 5."
}
},
"required": ["child_agent_id"]
}),
title: None,
needs_approval: false,
}
}
#[derive(Debug, Clone)]
pub struct HandoffRequest {
pub call_id: String,
pub child_agent_id: String,
pub reason: String,
pub max_carry: usize,
pub carried_context: Vec<LlmMessage>,
}
pub const DEFAULT_MAX_CARRY: usize = 5;
#[must_use]
pub fn parse_handoff_args(
call_id: &str,
args_json: &str,
transcript_so_far: &[LlmMessage],
) -> Option<HandoffRequest> {
let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
let child_agent_id = v.get("child_agent_id")?.as_str()?.to_owned();
if child_agent_id.is_empty() {
return None;
}
let reason = v
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_owned();
#[allow(clippy::cast_possible_truncation)]
let raw_max_carry = v
.get("max_carry")
.and_then(serde_json::Value::as_u64)
.map_or(DEFAULT_MAX_CARRY, |n| n as usize);
let max_carry = raw_max_carry.min(transcript_so_far.len());
let start = transcript_so_far.len().saturating_sub(max_carry);
let carried_context = transcript_so_far[start..].to_vec();
Some(HandoffRequest {
call_id: call_id.to_owned(),
child_agent_id,
reason,
max_carry,
carried_context,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use polyc_llm::Message as LlmMessage;
use super::*;
fn transcript(n: usize) -> Vec<LlmMessage> {
(0..n)
.map(|i| {
if i % 2 == 0 {
LlmMessage::user(format!("u{i}"))
} else {
LlmMessage::assistant(format!("a{i}"))
}
})
.collect()
}
#[test]
fn parses_minimum_required_args() {
let t = transcript(10);
let h = parse_handoff_args("c-1", r#"{"child_agent_id":"researcher"}"#, &t).unwrap();
assert_eq!(h.child_agent_id, "researcher");
assert_eq!(h.max_carry, DEFAULT_MAX_CARRY);
assert_eq!(h.carried_context.len(), DEFAULT_MAX_CARRY);
assert_eq!(h.call_id, "c-1");
}
#[test]
fn slices_last_n_messages() {
let t = transcript(10);
let h = parse_handoff_args(
"c-2",
r#"{"child_agent_id":"x","max_carry":3,"reason":"because"}"#,
&t,
)
.unwrap();
assert_eq!(h.max_carry, 3);
assert_eq!(h.carried_context.len(), 3);
assert_eq!(h.reason, "because");
let last_text = match h.carried_context.last().unwrap().content.first().unwrap() {
polyc_llm::Content::Text(s) => s.clone(),
_ => panic!("expected text"),
};
assert_eq!(last_text, "a9");
}
#[test]
fn clamps_max_carry_to_transcript_length() {
let t = transcript(2);
let h = parse_handoff_args("c", r#"{"child_agent_id":"x","max_carry":1000}"#, &t).unwrap();
assert_eq!(h.max_carry, 2, "clamped to len");
assert_eq!(h.carried_context.len(), 2);
}
#[test]
fn rejects_missing_child_agent_id() {
let t = transcript(2);
assert!(parse_handoff_args("c", r#"{"reason":"x"}"#, &t).is_none());
}
#[test]
fn rejects_empty_child_agent_id() {
let t = transcript(2);
assert!(parse_handoff_args("c", r#"{"child_agent_id":""}"#, &t).is_none());
}
#[test]
fn rejects_garbage_json() {
let t = transcript(2);
assert!(parse_handoff_args("c", "not-json", &t).is_none());
}
#[test]
fn empty_transcript_yields_empty_carry() {
let h = parse_handoff_args("c", r#"{"child_agent_id":"x"}"#, &[]).unwrap();
assert_eq!(h.max_carry, 0);
assert!(h.carried_context.is_empty());
}
#[test]
fn handoff_tool_spec_has_required_field() {
let spec = handoff_tool_spec();
assert_eq!(spec.name, HANDOFF_TOOL_NAME);
let required = spec
.schema_json
.get("required")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
assert!(required.iter().any(|v| v == "child_agent_id"));
}
}