Skip to main content

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
29//! signed [`polyc_proto::proto::polychrome::handoff::v1::Handoff`] event
30//! into the *parent* conversation's journal, creates a child `Conversation`
31//! resource, and suspends the parent until a
32//! `polyc_proto::proto::polychrome::handoff::v1::HandoffReturn` event
33//! lands.
34
35use polyc_llm::{Message as LlmMessage, ToolSpec};
36
37/// The reserved tool name the model emits to request a sub-agent handoff.
38///
39/// Any [`crate::ToolExecutor`] implementation that mixes user tools with the
40/// handoff primitive must avoid using this name for a real tool — the runtime
41/// short-circuits the name before [`crate::ToolExecutor::execute`] is invoked.
42pub const HANDOFF_TOOL_NAME: &str = "__handoff_to";
43
44/// JSON-schema spec for the handoff tool. Provided alongside the user's tool
45/// specs so the model knows the shape of `handoff_to(child_agent_id, ...)`.
46/// Use [`handoff_tool_spec`] to obtain it.
47#[must_use]
48pub fn handoff_tool_spec() -> ToolSpec {
49    ToolSpec {
50        name: HANDOFF_TOOL_NAME.to_owned(),
51        description:
52            "Delegate the current task to a child sub-agent. The current turn suspends; the \
53             child runs in its own isolated conversation with the carried context; the child's \
54             final message is returned to this conversation on the next turn. Use sparingly — \
55             handoffs are expensive (cold-start a sandbox). `child_agent_id` selects the \
56             planner; `reason` is recorded for operator visibility; `max_carry` bounds how many \
57             of the most recent messages the child sees (default 5)."
58                .to_owned(),
59        schema_json: serde_json::json!({
60            "type": "object",
61            "properties": {
62                "child_agent_id": {
63                    "type": "string",
64                    "description": "Identifier of the child agent / planner to spawn."
65                },
66                "reason": {
67                    "type": "string",
68                    "description": "Short rationale for the delegation."
69                },
70                "max_carry": {
71                    "type": "integer",
72                    "minimum": 0,
73                    "description": "How many of the most recent messages from this conversation to inject as the child's initial transcript. Default 5."
74                }
75            },
76            "required": ["child_agent_id"]
77        }),
78        title: None,
79        // Handoff is a control-plane delegation handled by the harness's own
80        // suspend/resume path, not the HITL spend gate; leave it ungated here.
81        needs_approval: false,
82    }
83}
84
85/// Parsed `__handoff_to` arguments, surfaced as
86/// [`crate::TurnResult::handoff`].
87///
88/// The control plane consumes this to:
89///   1. Slice the parent's transcript per [`HandoffRequest::carried_context`]
90///      (the agent crate has already done the slicing).
91///   2. Sign-and-write a `Handoff` event to the parent's eventlog partition.
92///   3. Create the child `Conversation` resource.
93#[derive(Debug, Clone)]
94pub struct HandoffRequest {
95    /// Tool-call id the provider assigned to the `__handoff_to` call. Echoed
96    /// back to the provider on the parent's *next* turn as the `tool_result`
97    /// id, so the function-calling loop on the parent's resumed turn sees a
98    /// matched call → result pair (otherwise the provider rejects the turn
99    /// for a dangling call).
100    pub call_id: String,
101    /// The model's chosen child agent identifier.
102    pub child_agent_id: String,
103    /// Optional free-form reason captured for operator visibility.
104    pub reason: String,
105    /// Sliding-window cap on the carried transcript. The runtime applies it
106    /// against the *current* turn's transcript when packaging the handoff.
107    pub max_carry: usize,
108    /// The pre-sliced carried transcript — the last `max_carry` messages of
109    /// the parent's transcript at the moment of the handoff call. Stored as
110    /// llm messages; the control plane maps them to wire `Message`s on the
111    /// way into the `Handoff` event payload.
112    pub carried_context: Vec<LlmMessage>,
113}
114
115/// Default carried-context window when the model omits `max_carry`.
116///
117/// Kept small — the child gets a fresh sandbox / new sliding window, so
118/// dragging in the parent's full context defeats the isolation a handoff is
119/// for. A future variant accepts a per-handoff `input_filter` predicate (a
120/// projection from the parent's transcript to the child's seed).
121pub const DEFAULT_MAX_CARRY: usize = 5;
122
123/// Parse the JSON arguments of a `__handoff_to` tool call into a structured
124/// [`HandoffRequest`].
125///
126/// `transcript_so_far` is the parent's *current* turn messages — the runtime
127/// slices the tail per `max_carry` (or [`DEFAULT_MAX_CARRY`] when absent)
128/// into `carried_context`.
129///
130/// Returns `None` if `args_json` doesn't parse or the required
131/// `child_agent_id` is missing — the runtime then treats the call as a
132/// no-op and lets the loop continue (so a malformed call doesn't deadlock
133/// the turn).
134#[must_use]
135pub fn parse_handoff_args(
136    call_id: &str,
137    args_json: &str,
138    transcript_so_far: &[LlmMessage],
139) -> Option<HandoffRequest> {
140    let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
141    let child_agent_id = v.get("child_agent_id")?.as_str()?.to_owned();
142    if child_agent_id.is_empty() {
143        return None;
144    }
145    let reason = v
146        .get("reason")
147        .and_then(serde_json::Value::as_str)
148        .unwrap_or("")
149        .to_owned();
150    // Clamp max_carry to the actual transcript length so the slice never
151    // panics; a malicious model asking for u64::MAX still produces a sane
152    // slice (the whole transcript).
153    #[allow(clippy::cast_possible_truncation)]
154    let raw_max_carry = v
155        .get("max_carry")
156        .and_then(serde_json::Value::as_u64)
157        .map_or(DEFAULT_MAX_CARRY, |n| n as usize);
158    let max_carry = raw_max_carry.min(transcript_so_far.len());
159    let start = transcript_so_far.len().saturating_sub(max_carry);
160    let carried_context = transcript_so_far[start..].to_vec();
161    Some(HandoffRequest {
162        call_id: call_id.to_owned(),
163        child_agent_id,
164        reason,
165        max_carry,
166        carried_context,
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
173
174    use polyc_llm::Message as LlmMessage;
175
176    use super::*;
177
178    fn transcript(n: usize) -> Vec<LlmMessage> {
179        (0..n)
180            .map(|i| {
181                if i % 2 == 0 {
182                    LlmMessage::user(format!("u{i}"))
183                } else {
184                    LlmMessage::assistant(format!("a{i}"))
185                }
186            })
187            .collect()
188    }
189
190    #[test]
191    fn parses_minimum_required_args() {
192        let t = transcript(10);
193        let h = parse_handoff_args("c-1", r#"{"child_agent_id":"researcher"}"#, &t).unwrap();
194        assert_eq!(h.child_agent_id, "researcher");
195        assert_eq!(h.max_carry, DEFAULT_MAX_CARRY);
196        assert_eq!(h.carried_context.len(), DEFAULT_MAX_CARRY);
197        assert_eq!(h.call_id, "c-1");
198    }
199
200    #[test]
201    fn slices_last_n_messages() {
202        let t = transcript(10);
203        let h = parse_handoff_args(
204            "c-2",
205            r#"{"child_agent_id":"x","max_carry":3,"reason":"because"}"#,
206            &t,
207        )
208        .unwrap();
209        assert_eq!(h.max_carry, 3);
210        assert_eq!(h.carried_context.len(), 3);
211        assert_eq!(h.reason, "because");
212        // The tail of the transcript.
213        let last_text = match h.carried_context.last().unwrap().content.first().unwrap() {
214            polyc_llm::Content::Text(s) => s.clone(),
215            _ => panic!("expected text"),
216        };
217        assert_eq!(last_text, "a9");
218    }
219
220    #[test]
221    fn clamps_max_carry_to_transcript_length() {
222        let t = transcript(2);
223        let h = parse_handoff_args("c", r#"{"child_agent_id":"x","max_carry":1000}"#, &t).unwrap();
224        assert_eq!(h.max_carry, 2, "clamped to len");
225        assert_eq!(h.carried_context.len(), 2);
226    }
227
228    #[test]
229    fn rejects_missing_child_agent_id() {
230        let t = transcript(2);
231        assert!(parse_handoff_args("c", r#"{"reason":"x"}"#, &t).is_none());
232    }
233
234    #[test]
235    fn rejects_empty_child_agent_id() {
236        let t = transcript(2);
237        assert!(parse_handoff_args("c", r#"{"child_agent_id":""}"#, &t).is_none());
238    }
239
240    #[test]
241    fn rejects_garbage_json() {
242        let t = transcript(2);
243        assert!(parse_handoff_args("c", "not-json", &t).is_none());
244    }
245
246    #[test]
247    fn empty_transcript_yields_empty_carry() {
248        let h = parse_handoff_args("c", r#"{"child_agent_id":"x"}"#, &[]).unwrap();
249        assert_eq!(h.max_carry, 0);
250        assert!(h.carried_context.is_empty());
251    }
252
253    #[test]
254    fn handoff_tool_spec_has_required_field() {
255        let spec = handoff_tool_spec();
256        assert_eq!(spec.name, HANDOFF_TOOL_NAME);
257        let required = spec
258            .schema_json
259            .get("required")
260            .and_then(|v| v.as_array())
261            .cloned()
262            .unwrap_or_default();
263        assert!(required.iter().any(|v| v == "child_agent_id"));
264    }
265}