Skip to main content

polyc_agent/
delegate.rs

1//! Delegation (in-process sub-agent task) primitive for the agent turn loop.
2//!
3//! # Why a reserved tool name, joining the batch (not short-circuiting it)
4//!
5//! This mirrors [`crate::handoff`]'s reserved-tool-name design (a model-facing
6//! function the loop recognizes by name, so no new provider integration or
7//! wire channel is needed) but the two primitives have OPPOSITE control flow:
8//!
9//!   * A **handoff** suspends the whole turn — the parent conversation stops,
10//!     a child `Conversation` is created, and the parent resumes only once
11//!     the child's `HandoffReturn` lands (possibly turns later). It short-
12//!     circuits the batch: no other tool in the same batch executes.
13//!   * A **delegation** (`__delegate_to`, #870) runs a nested, context-
14//!     isolated turn IN-PROCESS, synchronously, as part of dispatching this
15//!     SAME batch — it joins `run_turn_with`'s ordinary `tool_futures`
16//!     alongside every other call in the batch, and its result is just
17//!     another `tool_result` the SAME turn's next provider step sees. There
18//!     is no suspend, no child resource, no later turn.
19//!
20//! This is the tracer bullet for PRD #867: exactly one task, to exactly one
21//! worker, capped at one level deep (a worker's own advertised tool set never
22//! includes `__delegate_to` — see [`crate::run_turn_with`]'s tool-spec
23//! pinning).
24
25use std::sync::Arc;
26
27use polyc_llm::{DynProvider, ToolSpec};
28
29/// The reserved tool name the model emits to request an in-process
30/// delegation to a scoped worker agent.
31///
32/// Advertised only when [`crate::RunTurnOptions::delegate_descriptors`] is
33/// non-empty (see [`delegate_tool_spec`]'s call site in `run_turn_with`) — a
34/// conversation whose agent declares no delegation targets never sees this
35/// name at all, so it can't collide with a real tool of the same name either.
36pub const DELEGATE_TOOL_NAME: &str = "__delegate_to";
37
38/// JSON-schema spec for the delegate tool. Provided alongside the user's tool
39/// specs, but ONLY when at least one [`DelegateDescriptor`] is configured —
40/// see [`delegate_tool_spec`].
41#[must_use]
42pub fn delegate_tool_spec() -> ToolSpec {
43    // Like the handoff primitive, delegation is a runtime mechanism the
44    // capability gate never mediates (the orchestrator-level call is always
45    // allowed) — the worker's OWN nested turn re-applies the full gate to
46    // everything it does, fail-closed (see `run_turn_with`'s unattended-mode
47    // wiring for the nested options).
48    ToolSpec::new(
49        DELEGATE_TOOL_NAME,
50        "Hand a single, self-contained task to a specialized worker and wait for its answer. \
51         The worker runs in an isolated context — it does NOT see this conversation's history, \
52         only `task` and, if given, `context` — so state everything the worker needs to know. \
53         `target_agent_id` selects which worker runs the task. Set `result_schema` (a JSON \
54         Schema) to force the worker's answer into that shape instead of free text — the worker \
55         gets one retry if its first answer doesn't match, and reports a structured failure if it \
56         still can't conform.",
57        serde_json::json!({
58            "type": "object",
59            "properties": {
60                "target_agent_id": {
61                    "type": "string",
62                    "description": "Identifier of the worker agent to run the task."
63                },
64                "task": {
65                    "type": "string",
66                    "description": "The self-contained task for the worker to perform."
67                },
68                "context": {
69                    "type": "string",
70                    "description": "Optional extra context the worker needs — the worker sees no \
71                        other history, so include anything relevant here."
72                },
73                "result_schema": {
74                    "type": "object",
75                    "description": "Optional JSON Schema the worker's final answer must satisfy. \
76                        Omit for a free-text answer."
77                }
78            },
79            "required": ["target_agent_id", "task"]
80        }),
81    )
82}
83
84/// A resolved, self-contained worker configuration for one `can_delegate_to`
85/// target (#870).
86///
87/// Built by the control plane at turn dispatch — NEVER by this crate — and
88/// threaded down through the wire (`TurnInput.delegate_descriptors`) and the
89/// harness's tool-executor composition
90/// (`polyc_turn_runner::resolve_delegate_descriptors`) into
91/// [`crate::RunTurnOptions::delegate_descriptors`]. See
92/// `crates/control-plane/src/delegate.rs` for how the fields here are
93/// resolved (provider/model fallback, connector-scope intersection, the
94/// read-only-by-default built-in allowlist).
95#[derive(Clone)]
96pub struct DelegateDescriptor {
97    /// The target `Agent` resource name — matched (trailing-name, mirroring
98    /// [`crate::HandoffRequest::child_agent_id`]'s resolution) against the
99    /// model's `__delegate_to(target_agent_id, ...)` argument to pick this
100    /// descriptor. See [`find_descriptor`].
101    pub agent_id: String,
102    /// System instructions for the worker's nested turn. `None` ⇒ no
103    /// agent-specific instructions.
104    pub instructions: Option<String>,
105    /// The worker's resolved backend, already picked from the deployment's
106    /// registered providers — this crate never resolves a provider selector
107    /// string itself.
108    pub provider: Arc<DynProvider>,
109    /// The registry key of [`Self::provider`] (e.g. `"vertex"`, `"stub"`) —
110    /// carried alongside the erased backend so a forensic record (`#872`,
111    /// `DelegateRecord::resolved_provider`) can name the provider without
112    /// this crate needing a `Debug`/name accessor on [`DynProvider`] itself.
113    pub provider_name: String,
114    /// The worker's resolved model id.
115    pub model: String,
116    /// The worker's advertised tool specs. Never includes
117    /// [`DELEGATE_TOOL_NAME`] — this is what caps delegation depth at one,
118    /// since [`crate::run_turn_with`] only advertises the delegate tool when
119    /// its OWN `delegate_descriptors` option is non-empty, and a nested turn
120    /// always runs with that option empty.
121    pub tool_specs: Vec<ToolSpec>,
122    /// The worker's step budget, applied to the nested turn's
123    /// `RunTurnOptions::max_steps`.
124    pub max_steps: usize,
125}
126
127impl std::fmt::Debug for DelegateDescriptor {
128    /// Hand-rolled: [`DynProvider`] carries no `Debug` impl (the `LlmProvider`
129    /// trait doesn't require one), so this can't be `#[derive(Debug)]`d.
130    /// Prints tool names, not full specs, to stay short in a turn-level log.
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("DelegateDescriptor")
133            .field("agent_id", &self.agent_id)
134            .field("provider_name", &self.provider_name)
135            .field("model", &self.model)
136            .field(
137                "tool_specs",
138                &self.tool_specs.iter().map(|s| &s.name).collect::<Vec<_>>(),
139            )
140            .field("max_steps", &self.max_steps)
141            .finish_non_exhaustive()
142    }
143}
144
145/// The trailing name segment of a `target_agent_id` / descriptor `agent_id`,
146/// mirroring [`crate::handoff`]'s equivalent (own copy — see that module for
147/// why the tolerant match exists: an operator may author either a bare name
148/// or a namespaced `agent:ns/name` ref).
149fn trailing_name(entry: &str) -> &str {
150    entry.rsplit('/').next().unwrap_or(entry)
151}
152
153/// Find the [`DelegateDescriptor`] matching `target_agent_id` by trailing
154/// name.
155#[must_use]
156pub fn find_descriptor<'a>(
157    descriptors: &'a [DelegateDescriptor],
158    target_agent_id: &str,
159) -> Option<&'a DelegateDescriptor> {
160    let target = trailing_name(target_agent_id);
161    descriptors
162        .iter()
163        .find(|d| trailing_name(&d.agent_id) == target)
164}
165
166/// Parsed `__delegate_to` arguments, produced by [`parse_delegate_args`].
167#[derive(Debug, Clone)]
168pub struct DelegateRequest {
169    /// Tool-call id the provider assigned to the `__delegate_to` call. Echoed
170    /// back as the `tool_result` id so the function-calling loop sees a
171    /// matched call → result pair.
172    pub call_id: String,
173    /// The model's chosen worker agent identifier.
174    pub target_agent_id: String,
175    /// The self-contained task for the worker to perform — becomes the sole
176    /// user message of the worker's fresh transcript.
177    pub task: String,
178    /// Optional extra context, appended to the worker's transcript alongside
179    /// `task`. `None` when the model supplied none.
180    pub context: Option<String>,
181    /// Optional JSON Schema the worker's final answer must satisfy (`#871`).
182    /// `None` ⇒ the worker answers in free text, exactly as `#870` shipped —
183    /// this is the byte-for-byte-unaffected default the acceptance criteria
184    /// require. The raw schema value is not validated for well-formedness
185    /// here (compiling it into a [`jsonschema::Validator`] is the caller's
186    /// job, at the point it's actually used) — a bad schema is an argument
187    /// error the caller surfaces the same way a missing `task` is.
188    pub result_schema: Option<serde_json::Value>,
189}
190
191/// Parse the JSON arguments of a `__delegate_to` tool call into a structured
192/// [`DelegateRequest`].
193///
194/// Returns `None` if `args_json` doesn't parse, or either required field
195/// (`target_agent_id`, `task`) is missing or empty — the caller then
196/// surfaces a legible tool-result error rather than dispatching a malformed
197/// delegation.
198#[must_use]
199pub fn parse_delegate_args(call_id: &str, args_json: &str) -> Option<DelegateRequest> {
200    let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
201    let target_agent_id = v.get("target_agent_id")?.as_str()?.to_owned();
202    if target_agent_id.is_empty() {
203        return None;
204    }
205    let task = v.get("task")?.as_str()?.to_owned();
206    if task.is_empty() {
207        return None;
208    }
209    let context = v
210        .get("context")
211        .and_then(serde_json::Value::as_str)
212        .filter(|s| !s.is_empty())
213        .map(str::to_owned);
214    let result_schema = v.get("result_schema").cloned();
215    Some(DelegateRequest {
216        call_id: call_id.to_owned(),
217        target_agent_id,
218        task,
219        context,
220        result_schema,
221    })
222}
223
224#[cfg(test)]
225mod tests {
226    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
227
228    use super::*;
229
230    fn descriptor(agent_id: &str) -> DelegateDescriptor {
231        DelegateDescriptor {
232            agent_id: agent_id.to_owned(),
233            instructions: None,
234            provider: polyc_llm::into_dyn(polyc_llm::turn::StubProvider),
235            provider_name: "stub".to_owned(),
236            model: "stub".to_owned(),
237            tool_specs: Vec::new(),
238            max_steps: 4,
239        }
240    }
241
242    #[test]
243    fn parses_minimum_required_args() {
244        let req = parse_delegate_args(
245            "c-1",
246            r#"{"target_agent_id":"researcher","task":"find the answer"}"#,
247        )
248        .unwrap();
249        assert_eq!(req.target_agent_id, "researcher");
250        assert_eq!(req.task, "find the answer");
251        assert!(req.context.is_none());
252        assert_eq!(req.call_id, "c-1");
253    }
254
255    #[test]
256    fn parses_optional_context() {
257        let req = parse_delegate_args(
258            "c-2",
259            r#"{"target_agent_id":"x","task":"t","context":"extra"}"#,
260        )
261        .unwrap();
262        assert_eq!(req.context.as_deref(), Some("extra"));
263    }
264
265    #[test]
266    fn parses_optional_result_schema() {
267        let req = parse_delegate_args(
268            "c-3",
269            r#"{"target_agent_id":"x","task":"t","result_schema":{"type":"object"}}"#,
270        )
271        .unwrap();
272        assert_eq!(
273            req.result_schema,
274            Some(serde_json::json!({"type":"object"}))
275        );
276    }
277
278    #[test]
279    fn result_schema_absent_by_default() {
280        let req = parse_delegate_args("c-4", r#"{"target_agent_id":"x","task":"t"}"#).unwrap();
281        assert!(req.result_schema.is_none());
282    }
283
284    #[test]
285    fn rejects_missing_target_agent_id() {
286        assert!(parse_delegate_args("c", r#"{"task":"t"}"#).is_none());
287    }
288
289    #[test]
290    fn rejects_empty_target_agent_id() {
291        assert!(parse_delegate_args("c", r#"{"target_agent_id":"","task":"t"}"#).is_none());
292    }
293
294    #[test]
295    fn rejects_missing_task() {
296        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x"}"#).is_none());
297    }
298
299    #[test]
300    fn rejects_empty_task() {
301        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x","task":""}"#).is_none());
302    }
303
304    #[test]
305    fn rejects_garbage_json() {
306        assert!(parse_delegate_args("c", "not-json").is_none());
307    }
308
309    #[test]
310    fn delegate_tool_spec_has_required_fields() {
311        let spec = delegate_tool_spec();
312        assert_eq!(spec.name, DELEGATE_TOOL_NAME);
313        let required = spec
314            .schema_json
315            .get("required")
316            .and_then(|v| v.as_array())
317            .cloned()
318            .unwrap_or_default();
319        assert!(required.iter().any(|v| v == "target_agent_id"));
320        assert!(required.iter().any(|v| v == "task"));
321    }
322
323    #[test]
324    fn find_descriptor_matches_by_trailing_name() {
325        let descriptors = vec![descriptor("agent:default/researcher"), descriptor("coder")];
326        assert!(find_descriptor(&descriptors, "researcher").is_some());
327        assert!(find_descriptor(&descriptors, "agent:other-ns/researcher").is_some());
328        assert!(find_descriptor(&descriptors, "coder").is_some());
329        assert!(find_descriptor(&descriptors, "ghost").is_none());
330    }
331}