polyc-agent 2026.8.3

The agent turn loop: provider + tool-call routing, shared by the control plane and harness.
//! Handoff (sub-agent transfer) primitive for the agent turn loop.
//!
//! # Why a reserved tool name, not a wire message
//!
//! Two clean designs exist for "the parent wants to spawn a child agent":
//!
//!   1. A first-class wire message — e.g. a new `AgentResponse` oneof variant
//!      the planner emits. The control plane sees the variant on the response
//!      stream and reacts.
//!   2. A reserved *tool name* the model can call from inside the standard
//!      function-calling loop. The tool executor recognises the name, doesn't
//!      execute anything, and surfaces it as a structured break-out of
//!      `run_turn`.
//!
//! We pick **(2) reserved tool name** (`HANDOFF_TOOL_NAME`) because:
//!
//!   * It rides the existing provider function-calling shape (every modern
//!     provider models "call function X with JSON args"). No new channel is
//!     needed, no provider integration is touched.
//!   * It keeps the agent crate the single authority on the turn loop — the
//!     handoff is "the model asked to delegate", uniformly across providers.
//!   * Convergent with prior-art agent SDKs that advertise handoffs to the
//!     model as tools (turning the choice of delegation into a function the
//!     planner can reason about with the rest of its toolbox).
//!
//! Wire-level surfacing happens upstream in the control plane: when
//! `run_turn` returns a [`TurnResult`](crate::TurnResult) with a populated
//! [`TurnResult::handoff`](crate::TurnResult). The control plane emits a signed
//! [`polyc_proto::proto::polychrome::handoff::v1::Handoff`] event into the
//! parent journal. Child orchestration is a separate lifecycle that has not
//! landed in this repository. The transfer record is one-way.

use polyc_llm::{Message as LlmMessage, ToolSpec};

/// The reserved tool name the model emits to request a sub-agent handoff.
///
/// Any [`crate::ToolExecutor`] implementation that mixes user tools with the
/// handoff primitive must avoid using this name for a real tool — the runtime
/// short-circuits the name before [`crate::ToolExecutor::execute`] is invoked.
pub const HANDOFF_TOOL_NAME: &str = "__handoff_to";

/// JSON-schema spec for the handoff tool. Provided alongside the user's tool
/// specs so the model knows the shape of `handoff_to(child_agent_id, ...)`.
/// Use [`handoff_tool_spec`] to obtain it.
#[must_use]
pub fn handoff_tool_spec() -> ToolSpec {
    // Handoff is a control-plane delegation handled by the harness's own
    // suspend/resume path, not the HITL spend gate; leave it ungated and
    // un-annotated (neither read-only nor destructive in the tool sense).
    ToolSpec::new(
        HANDOFF_TOOL_NAME,
        "Record a one-way request to delegate the current task to a child agent. The current \
             turn suspends here, but this build does not start the child. No child result comes \
             back to this conversation. Use only when an external child orchestrator is \
             configured. `child_agent_id` reserves the planner; `reason` is recorded for later \
             review; `max_carry` bounds the recent context stored with the request (default 5).",
        serde_json::json!({
            "type": "object",
            "properties": {
                "child_agent_id": {
                    "type": "string",
                    "description": "Identifier of the child agent or planner requested."
                },
                "reason": {
                    "type": "string",
                    "description": "Short rationale for the delegation."
                },
                "max_carry": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "How many recent messages to store with the request for an external child orchestrator. Default 5."
                }
            },
            "required": ["child_agent_id"],
            "additionalProperties": false
        }),
    )
}

/// Parsed `__handoff_to` arguments, surfaced as
/// [`crate::TurnResult::handoff`].
///
/// The control plane consumes this to:
///   1. Slice the parent's transcript per [`HandoffRequest::carried_context`]
///      (the agent crate has already done the slicing).
///   2. Sign-and-write a `Handoff` event to the parent's eventlog partition.
///
/// Child-resource orchestration is outside this request path.
#[derive(Debug, Clone)]
pub struct HandoffRequest {
    /// The model's chosen child agent identifier.
    pub child_agent_id: String,
    /// Optional free-form reason captured for operator visibility.
    pub reason: String,
    /// Sliding-window cap on the carried transcript. The runtime applies it
    /// against the *current* turn's transcript when packaging the handoff.
    pub max_carry: usize,
    /// The pre-sliced carried transcript — the last `max_carry` messages of
    /// the parent's transcript at the moment of the handoff call. Stored as
    /// llm messages; the control plane maps them to wire `Message`s on the
    /// way into the `Handoff` event payload.
    pub carried_context: Vec<LlmMessage>,
}

/// Default carried-context window when the model omits `max_carry`.
///
/// Kept small — the child gets a fresh sandbox / new sliding window, so
/// dragging in the parent's full context defeats the isolation a handoff is
/// for. A future variant accepts a per-handoff `input_filter` predicate (a
/// projection from the parent's transcript to the child's seed).
pub const DEFAULT_MAX_CARRY: usize = 5;

/// Parse the JSON arguments of a `__handoff_to` tool call into a structured
/// [`HandoffRequest`].
///
/// `transcript_so_far` is the parent's *current* turn messages — the runtime
/// slices the tail per `max_carry` (or [`DEFAULT_MAX_CARRY`] when absent)
/// into `carried_context`.
///
/// Returns `None` if `args_json` doesn't parse or the required
/// `child_agent_id` is missing — the runtime then treats the call as a
/// no-op and lets the loop continue (so a malformed call doesn't deadlock
/// the turn).
#[must_use]
pub fn parse_handoff_args(
    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();
    // Clamp max_carry to the actual transcript length so the slice never
    // panics; a malicious model asking for u64::MAX still produces a sane
    // slice (the whole transcript).
    #[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 {
        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(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);
    }

    #[test]
    fn slices_last_n_messages() {
        let t = transcript(10);
        let h = parse_handoff_args(
            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");
        // The tail of the transcript.
        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(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(r#"{"reason":"x"}"#, &t).is_none());
    }

    #[test]
    fn rejects_empty_child_agent_id() {
        let t = transcript(2);
        assert!(parse_handoff_args(r#"{"child_agent_id":""}"#, &t).is_none());
    }

    #[test]
    fn rejects_garbage_json() {
        let t = transcript(2);
        assert!(parse_handoff_args("not-json", &t).is_none());
    }

    #[test]
    fn empty_transcript_yields_empty_carry() {
        let h = parse_handoff_args(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"));
        // Strict schema — no undeclared arguments (matches `__delegate_to`,
        // #1141).
        assert_eq!(
            spec.schema_json.get("additionalProperties"),
            Some(&serde_json::json!(false))
        );
    }
}