polyc_agent/handoff.rs
1//! Handoff (sub-agent transfer) primitive for the agent turn loop.
2//!
3//! # Why a reserved tool name, not a wire message
4//!
5//! Two clean designs exist for "the parent wants to spawn a child agent":
6//!
7//! 1. A first-class wire message — e.g. a new `AgentResponse` oneof variant
8//! the planner emits. The control plane sees the variant on the response
9//! stream and reacts.
10//! 2. A reserved *tool name* the model can call from inside the standard
11//! function-calling loop. The tool executor recognises the name, doesn't
12//! execute anything, and surfaces it as a structured break-out of
13//! `run_turn`.
14//!
15//! We pick **(2) reserved tool name** (`HANDOFF_TOOL_NAME`) because:
16//!
17//! * It rides the existing provider function-calling shape (every modern
18//! provider models "call function X with JSON args"). No new channel is
19//! needed, no provider integration is touched.
20//! * It keeps the agent crate the single authority on the turn loop — the
21//! handoff is "the model asked to delegate", uniformly across providers.
22//! * Convergent with prior-art agent SDKs that advertise handoffs to the
23//! model as tools (turning the choice of delegation into a function the
24//! planner can reason about with the rest of its toolbox).
25//!
26//! Wire-level surfacing happens upstream in the control plane: when
27//! `run_turn` returns a [`TurnResult`](crate::TurnResult) with a populated
28//! [`TurnResult::handoff`](crate::TurnResult). The control plane emits a signed
29//! [`polyc_proto::proto::polychrome::handoff::v1::Handoff`] event into the
30//! parent journal. Child orchestration is a separate lifecycle that has not
31//! landed in this repository. The transfer record is one-way.
32
33use polyc_llm::{Message as LlmMessage, ToolSpec};
34
35/// The reserved tool name the model emits to request a sub-agent handoff.
36///
37/// Any [`crate::ToolExecutor`] implementation that mixes user tools with the
38/// handoff primitive must avoid using this name for a real tool — the runtime
39/// short-circuits the name before [`crate::ToolExecutor::execute`] is invoked.
40pub const HANDOFF_TOOL_NAME: &str = "__handoff_to";
41
42/// JSON-schema spec for the handoff tool. Provided alongside the user's tool
43/// specs so the model knows the shape of `handoff_to(child_agent_id, ...)`.
44/// Use [`handoff_tool_spec`] to obtain it.
45#[must_use]
46pub fn handoff_tool_spec() -> ToolSpec {
47 // Handoff is a control-plane delegation handled by the harness's own
48 // suspend/resume path, not the HITL spend gate; leave it ungated and
49 // un-annotated (neither read-only nor destructive in the tool sense).
50 ToolSpec::new(
51 HANDOFF_TOOL_NAME,
52 "Record a one-way request to delegate the current task to a child agent. The current \
53 turn suspends here, but this build does not start the child. No child result comes \
54 back to this conversation. Use only when an external child orchestrator is \
55 configured. `child_agent_id` reserves the planner; `reason` is recorded for later \
56 review; `max_carry` bounds the recent context stored with the request (default 5).",
57 serde_json::json!({
58 "type": "object",
59 "properties": {
60 "child_agent_id": {
61 "type": "string",
62 "description": "Identifier of the child agent or planner requested."
63 },
64 "reason": {
65 "type": "string",
66 "description": "Short rationale for the delegation."
67 },
68 "max_carry": {
69 "type": "integer",
70 "minimum": 0,
71 "description": "How many recent messages to store with the request for an external child orchestrator. Default 5."
72 }
73 },
74 "required": ["child_agent_id"],
75 "additionalProperties": false
76 }),
77 )
78}
79
80/// Parsed `__handoff_to` arguments, surfaced as
81/// [`crate::TurnResult::handoff`].
82///
83/// The control plane consumes this to:
84/// 1. Slice the parent's transcript per [`HandoffRequest::carried_context`]
85/// (the agent crate has already done the slicing).
86/// 2. Sign-and-write a `Handoff` event to the parent's eventlog partition.
87///
88/// Child-resource orchestration is outside this request path.
89#[derive(Debug, Clone)]
90pub struct HandoffRequest {
91 /// The model's chosen child agent identifier.
92 pub child_agent_id: String,
93 /// Optional free-form reason captured for operator visibility.
94 pub reason: String,
95 /// Sliding-window cap on the carried transcript. The runtime applies it
96 /// against the *current* turn's transcript when packaging the handoff.
97 pub max_carry: usize,
98 /// The pre-sliced carried transcript — the last `max_carry` messages of
99 /// the parent's transcript at the moment of the handoff call. Stored as
100 /// llm messages; the control plane maps them to wire `Message`s on the
101 /// way into the `Handoff` event payload.
102 pub carried_context: Vec<LlmMessage>,
103}
104
105/// Default carried-context window when the model omits `max_carry`.
106///
107/// Kept small — the child gets a fresh sandbox / new sliding window, so
108/// dragging in the parent's full context defeats the isolation a handoff is
109/// for. A future variant accepts a per-handoff `input_filter` predicate (a
110/// projection from the parent's transcript to the child's seed).
111pub const DEFAULT_MAX_CARRY: usize = 5;
112
113/// Parse the JSON arguments of a `__handoff_to` tool call into a structured
114/// [`HandoffRequest`].
115///
116/// `transcript_so_far` is the parent's *current* turn messages — the runtime
117/// slices the tail per `max_carry` (or [`DEFAULT_MAX_CARRY`] when absent)
118/// into `carried_context`.
119///
120/// Returns `None` if `args_json` doesn't parse or the required
121/// `child_agent_id` is missing — the runtime then treats the call as a
122/// no-op and lets the loop continue (so a malformed call doesn't deadlock
123/// the turn).
124#[must_use]
125pub fn parse_handoff_args(
126 args_json: &str,
127 transcript_so_far: &[LlmMessage],
128) -> Option<HandoffRequest> {
129 let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
130 let child_agent_id = v.get("child_agent_id")?.as_str()?.to_owned();
131 if child_agent_id.is_empty() {
132 return None;
133 }
134 let reason = v
135 .get("reason")
136 .and_then(serde_json::Value::as_str)
137 .unwrap_or("")
138 .to_owned();
139 // Clamp max_carry to the actual transcript length so the slice never
140 // panics; a malicious model asking for u64::MAX still produces a sane
141 // slice (the whole transcript).
142 #[allow(clippy::cast_possible_truncation)]
143 let raw_max_carry = v
144 .get("max_carry")
145 .and_then(serde_json::Value::as_u64)
146 .map_or(DEFAULT_MAX_CARRY, |n| n as usize);
147 let max_carry = raw_max_carry.min(transcript_so_far.len());
148 let start = transcript_so_far.len().saturating_sub(max_carry);
149 let carried_context = transcript_so_far[start..].to_vec();
150 Some(HandoffRequest {
151 child_agent_id,
152 reason,
153 max_carry,
154 carried_context,
155 })
156}
157
158#[cfg(test)]
159mod tests {
160 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
161
162 use polyc_llm::Message as LlmMessage;
163
164 use super::*;
165
166 fn transcript(n: usize) -> Vec<LlmMessage> {
167 (0..n)
168 .map(|i| {
169 if i % 2 == 0 {
170 LlmMessage::user(format!("u{i}"))
171 } else {
172 LlmMessage::assistant(format!("a{i}"))
173 }
174 })
175 .collect()
176 }
177
178 #[test]
179 fn parses_minimum_required_args() {
180 let t = transcript(10);
181 let h = parse_handoff_args(r#"{"child_agent_id":"researcher"}"#, &t).unwrap();
182 assert_eq!(h.child_agent_id, "researcher");
183 assert_eq!(h.max_carry, DEFAULT_MAX_CARRY);
184 assert_eq!(h.carried_context.len(), DEFAULT_MAX_CARRY);
185 }
186
187 #[test]
188 fn slices_last_n_messages() {
189 let t = transcript(10);
190 let h = parse_handoff_args(
191 r#"{"child_agent_id":"x","max_carry":3,"reason":"because"}"#,
192 &t,
193 )
194 .unwrap();
195 assert_eq!(h.max_carry, 3);
196 assert_eq!(h.carried_context.len(), 3);
197 assert_eq!(h.reason, "because");
198 // The tail of the transcript.
199 let last_text = match h.carried_context.last().unwrap().content.first().unwrap() {
200 polyc_llm::Content::Text(s) => s.clone(),
201 _ => panic!("expected text"),
202 };
203 assert_eq!(last_text, "a9");
204 }
205
206 #[test]
207 fn clamps_max_carry_to_transcript_length() {
208 let t = transcript(2);
209 let h = parse_handoff_args(r#"{"child_agent_id":"x","max_carry":1000}"#, &t).unwrap();
210 assert_eq!(h.max_carry, 2, "clamped to len");
211 assert_eq!(h.carried_context.len(), 2);
212 }
213
214 #[test]
215 fn rejects_missing_child_agent_id() {
216 let t = transcript(2);
217 assert!(parse_handoff_args(r#"{"reason":"x"}"#, &t).is_none());
218 }
219
220 #[test]
221 fn rejects_empty_child_agent_id() {
222 let t = transcript(2);
223 assert!(parse_handoff_args(r#"{"child_agent_id":""}"#, &t).is_none());
224 }
225
226 #[test]
227 fn rejects_garbage_json() {
228 let t = transcript(2);
229 assert!(parse_handoff_args("not-json", &t).is_none());
230 }
231
232 #[test]
233 fn empty_transcript_yields_empty_carry() {
234 let h = parse_handoff_args(r#"{"child_agent_id":"x"}"#, &[]).unwrap();
235 assert_eq!(h.max_carry, 0);
236 assert!(h.carried_context.is_empty());
237 }
238
239 #[test]
240 fn handoff_tool_spec_has_required_field() {
241 let spec = handoff_tool_spec();
242 assert_eq!(spec.name, HANDOFF_TOOL_NAME);
243 let required = spec
244 .schema_json
245 .get("required")
246 .and_then(|v| v.as_array())
247 .cloned()
248 .unwrap_or_default();
249 assert!(required.iter().any(|v| v == "child_agent_id"));
250 // Strict schema — no undeclared arguments (matches `__delegate_to`,
251 // #1141).
252 assert_eq!(
253 spec.schema_json.get("additionalProperties"),
254 Some(&serde_json::json!(false))
255 );
256 }
257}