polyc-agent 2026.7.1

The agent turn loop: provider + tool-call routing, shared by the control plane and harness.
Documentation
//! Delegation (in-process sub-agent task) primitive for the agent turn loop.
//!
//! # Why a reserved tool name, joining the batch (not short-circuiting it)
//!
//! This mirrors [`crate::handoff`]'s reserved-tool-name design (a model-facing
//! function the loop recognizes by name, so no new provider integration or
//! wire channel is needed) but the two primitives have OPPOSITE control flow:
//!
//!   * A **handoff** suspends the whole turn — the parent conversation stops,
//!     a child `Conversation` is created, and the parent resumes only once
//!     the child's `HandoffReturn` lands (possibly turns later). It short-
//!     circuits the batch: no other tool in the same batch executes.
//!   * A **delegation** (`__delegate_to`, #870) runs a nested, context-
//!     isolated turn IN-PROCESS, synchronously, as part of dispatching this
//!     SAME batch — it joins `run_turn_with`'s ordinary `tool_futures`
//!     alongside every other call in the batch, and its result is just
//!     another `tool_result` the SAME turn's next provider step sees. There
//!     is no suspend, no child resource, no later turn.
//!
//! This is the tracer bullet for PRD #867: exactly one task, to exactly one
//! worker, capped at one level deep (a worker's own advertised tool set never
//! includes `__delegate_to` — see [`crate::run_turn_with`]'s tool-spec
//! pinning).

use std::sync::Arc;

use polyc_llm::{DynProvider, ToolSpec};

/// The reserved tool name the model emits to request an in-process
/// delegation to a scoped worker agent.
///
/// Advertised only when [`crate::RunTurnOptions::delegate_descriptors`] is
/// non-empty (see [`delegate_tool_spec`]'s call site in `run_turn_with`) — a
/// conversation whose agent declares no delegation targets never sees this
/// name at all, so it can't collide with a real tool of the same name either.
pub const DELEGATE_TOOL_NAME: &str = "__delegate_to";

/// JSON-schema spec for the delegate tool. Provided alongside the user's tool
/// specs, but ONLY when at least one [`DelegateDescriptor`] is configured —
/// see [`delegate_tool_spec`].
#[must_use]
pub fn delegate_tool_spec() -> ToolSpec {
    // Like the handoff primitive, delegation is a runtime mechanism the
    // capability gate never mediates (the orchestrator-level call is always
    // allowed) — the worker's OWN nested turn re-applies the full gate to
    // everything it does, fail-closed (see `run_turn_with`'s unattended-mode
    // wiring for the nested options).
    ToolSpec::new(
        DELEGATE_TOOL_NAME,
        "Hand a single, self-contained task to a specialized worker and wait for its answer. \
         The worker runs in an isolated context — it does NOT see this conversation's history, \
         only `task` and, if given, `context` — so state everything the worker needs to know. \
         `target_agent_id` selects which worker runs the task. Set `result_schema` (a JSON \
         Schema) to force the worker's answer into that shape instead of free text — the worker \
         gets one retry if its first answer doesn't match, and reports a structured failure if it \
         still can't conform.",
        serde_json::json!({
            "type": "object",
            "properties": {
                "target_agent_id": {
                    "type": "string",
                    "description": "Identifier of the worker agent to run the task."
                },
                "task": {
                    "type": "string",
                    "description": "The self-contained task for the worker to perform."
                },
                "context": {
                    "type": "string",
                    "description": "Optional extra context the worker needs — the worker sees no \
                        other history, so include anything relevant here."
                },
                "result_schema": {
                    "type": "object",
                    "description": "Optional JSON Schema the worker's final answer must satisfy. \
                        Omit for a free-text answer."
                }
            },
            "required": ["target_agent_id", "task"]
        }),
    )
}

/// A resolved, self-contained worker configuration for one `can_delegate_to`
/// target (#870).
///
/// Built by the control plane at turn dispatch — NEVER by this crate — and
/// threaded down through the wire (`TurnInput.delegate_descriptors`) and the
/// harness's tool-executor composition
/// (`polyc_turn_runner::resolve_delegate_descriptors`) into
/// [`crate::RunTurnOptions::delegate_descriptors`]. See
/// `crates/control-plane/src/delegate.rs` for how the fields here are
/// resolved (provider/model fallback, connector-scope intersection, the
/// read-only-by-default built-in allowlist).
#[derive(Clone)]
pub struct DelegateDescriptor {
    /// The target `Agent` resource name — matched (trailing-name, mirroring
    /// [`crate::HandoffRequest::child_agent_id`]'s resolution) against the
    /// model's `__delegate_to(target_agent_id, ...)` argument to pick this
    /// descriptor. See [`find_descriptor`].
    pub agent_id: String,
    /// System instructions for the worker's nested turn. `None` ⇒ no
    /// agent-specific instructions.
    pub instructions: Option<String>,
    /// The worker's resolved backend, already picked from the deployment's
    /// registered providers — this crate never resolves a provider selector
    /// string itself.
    pub provider: Arc<DynProvider>,
    /// The registry key of [`Self::provider`] (e.g. `"vertex"`, `"stub"`) —
    /// carried alongside the erased backend so a forensic record (`#872`,
    /// `DelegateRecord::resolved_provider`) can name the provider without
    /// this crate needing a `Debug`/name accessor on [`DynProvider`] itself.
    pub provider_name: String,
    /// The worker's resolved model id.
    pub model: String,
    /// The worker's advertised tool specs. Never includes
    /// [`DELEGATE_TOOL_NAME`] — this is what caps delegation depth at one,
    /// since [`crate::run_turn_with`] only advertises the delegate tool when
    /// its OWN `delegate_descriptors` option is non-empty, and a nested turn
    /// always runs with that option empty.
    pub tool_specs: Vec<ToolSpec>,
    /// The worker's step budget, applied to the nested turn's
    /// `RunTurnOptions::max_steps`.
    pub max_steps: usize,
}

impl std::fmt::Debug for DelegateDescriptor {
    /// Hand-rolled: [`DynProvider`] carries no `Debug` impl (the `LlmProvider`
    /// trait doesn't require one), so this can't be `#[derive(Debug)]`d.
    /// Prints tool names, not full specs, to stay short in a turn-level log.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DelegateDescriptor")
            .field("agent_id", &self.agent_id)
            .field("provider_name", &self.provider_name)
            .field("model", &self.model)
            .field(
                "tool_specs",
                &self.tool_specs.iter().map(|s| &s.name).collect::<Vec<_>>(),
            )
            .field("max_steps", &self.max_steps)
            .finish_non_exhaustive()
    }
}

/// The trailing name segment of a `target_agent_id` / descriptor `agent_id`,
/// mirroring [`crate::handoff`]'s equivalent (own copy — see that module for
/// why the tolerant match exists: an operator may author either a bare name
/// or a namespaced `agent:ns/name` ref).
fn trailing_name(entry: &str) -> &str {
    entry.rsplit('/').next().unwrap_or(entry)
}

/// Find the [`DelegateDescriptor`] matching `target_agent_id` by trailing
/// name.
#[must_use]
pub fn find_descriptor<'a>(
    descriptors: &'a [DelegateDescriptor],
    target_agent_id: &str,
) -> Option<&'a DelegateDescriptor> {
    let target = trailing_name(target_agent_id);
    descriptors
        .iter()
        .find(|d| trailing_name(&d.agent_id) == target)
}

/// Parsed `__delegate_to` arguments, produced by [`parse_delegate_args`].
#[derive(Debug, Clone)]
pub struct DelegateRequest {
    /// Tool-call id the provider assigned to the `__delegate_to` call. Echoed
    /// back as the `tool_result` id so the function-calling loop sees a
    /// matched call → result pair.
    pub call_id: String,
    /// The model's chosen worker agent identifier.
    pub target_agent_id: String,
    /// The self-contained task for the worker to perform — becomes the sole
    /// user message of the worker's fresh transcript.
    pub task: String,
    /// Optional extra context, appended to the worker's transcript alongside
    /// `task`. `None` when the model supplied none.
    pub context: Option<String>,
    /// Optional JSON Schema the worker's final answer must satisfy (`#871`).
    /// `None` ⇒ the worker answers in free text, exactly as `#870` shipped —
    /// this is the byte-for-byte-unaffected default the acceptance criteria
    /// require. The raw schema value is not validated for well-formedness
    /// here (compiling it into a [`jsonschema::Validator`] is the caller's
    /// job, at the point it's actually used) — a bad schema is an argument
    /// error the caller surfaces the same way a missing `task` is.
    pub result_schema: Option<serde_json::Value>,
}

/// Parse the JSON arguments of a `__delegate_to` tool call into a structured
/// [`DelegateRequest`].
///
/// Returns `None` if `args_json` doesn't parse, or either required field
/// (`target_agent_id`, `task`) is missing or empty — the caller then
/// surfaces a legible tool-result error rather than dispatching a malformed
/// delegation.
#[must_use]
pub fn parse_delegate_args(call_id: &str, args_json: &str) -> Option<DelegateRequest> {
    let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
    let target_agent_id = v.get("target_agent_id")?.as_str()?.to_owned();
    if target_agent_id.is_empty() {
        return None;
    }
    let task = v.get("task")?.as_str()?.to_owned();
    if task.is_empty() {
        return None;
    }
    let context = v
        .get("context")
        .and_then(serde_json::Value::as_str)
        .filter(|s| !s.is_empty())
        .map(str::to_owned);
    let result_schema = v.get("result_schema").cloned();
    Some(DelegateRequest {
        call_id: call_id.to_owned(),
        target_agent_id,
        task,
        context,
        result_schema,
    })
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;

    fn descriptor(agent_id: &str) -> DelegateDescriptor {
        DelegateDescriptor {
            agent_id: agent_id.to_owned(),
            instructions: None,
            provider: polyc_llm::into_dyn(polyc_llm::turn::StubProvider),
            provider_name: "stub".to_owned(),
            model: "stub".to_owned(),
            tool_specs: Vec::new(),
            max_steps: 4,
        }
    }

    #[test]
    fn parses_minimum_required_args() {
        let req = parse_delegate_args(
            "c-1",
            r#"{"target_agent_id":"researcher","task":"find the answer"}"#,
        )
        .unwrap();
        assert_eq!(req.target_agent_id, "researcher");
        assert_eq!(req.task, "find the answer");
        assert!(req.context.is_none());
        assert_eq!(req.call_id, "c-1");
    }

    #[test]
    fn parses_optional_context() {
        let req = parse_delegate_args(
            "c-2",
            r#"{"target_agent_id":"x","task":"t","context":"extra"}"#,
        )
        .unwrap();
        assert_eq!(req.context.as_deref(), Some("extra"));
    }

    #[test]
    fn parses_optional_result_schema() {
        let req = parse_delegate_args(
            "c-3",
            r#"{"target_agent_id":"x","task":"t","result_schema":{"type":"object"}}"#,
        )
        .unwrap();
        assert_eq!(
            req.result_schema,
            Some(serde_json::json!({"type":"object"}))
        );
    }

    #[test]
    fn result_schema_absent_by_default() {
        let req = parse_delegate_args("c-4", r#"{"target_agent_id":"x","task":"t"}"#).unwrap();
        assert!(req.result_schema.is_none());
    }

    #[test]
    fn rejects_missing_target_agent_id() {
        assert!(parse_delegate_args("c", r#"{"task":"t"}"#).is_none());
    }

    #[test]
    fn rejects_empty_target_agent_id() {
        assert!(parse_delegate_args("c", r#"{"target_agent_id":"","task":"t"}"#).is_none());
    }

    #[test]
    fn rejects_missing_task() {
        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x"}"#).is_none());
    }

    #[test]
    fn rejects_empty_task() {
        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x","task":""}"#).is_none());
    }

    #[test]
    fn rejects_garbage_json() {
        assert!(parse_delegate_args("c", "not-json").is_none());
    }

    #[test]
    fn delegate_tool_spec_has_required_fields() {
        let spec = delegate_tool_spec();
        assert_eq!(spec.name, DELEGATE_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 == "target_agent_id"));
        assert!(required.iter().any(|v| v == "task"));
    }

    #[test]
    fn find_descriptor_matches_by_trailing_name() {
        let descriptors = vec![descriptor("agent:default/researcher"), descriptor("coder")];
        assert!(find_descriptor(&descriptors, "researcher").is_some());
        assert!(find_descriptor(&descriptors, "agent:other-ns/researcher").is_some());
        assert!(find_descriptor(&descriptors, "coder").is_some());
        assert!(find_descriptor(&descriptors, "ghost").is_none());
    }
}