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/// The condensation contract appended to every delegated worker's
39/// synthesized instructions when the call carries no `result_schema`
40/// (INV-C25, #1140).
41///
42/// The worker's final message is the sole return channel back to the
43/// caller, so the worker is told to make that message a self-contained
44/// summary of the outcome. When a `result_schema` IS in force, the
45/// schema-forced finalize path bounds the answer's shape instead and this
46/// text is not injected. The per-call result cap (`MAX_TOOL_RESULT_BYTES`
47/// middle-elision) stays as the hard backstop either way — this contract
48/// instructs, the cap enforces.
49pub const WORKER_CONDENSATION_CONTRACT: &str = "You are completing one delegated task. The \
50    caller sees only your final message — none of your tool calls, intermediate work, or \
51    earlier drafts reach it. Make your final message a self-contained summary of the outcome: \
52    what you did or found, the key details the caller needs, and anything that failed. Keep it \
53    concise — an overlong answer is trimmed from the middle.";
54
55/// Composes a delegated worker's synthesized system message text
56/// (INV-C25, `#1140`), given the descriptor's own (already trimmed,
57/// non-empty-or-`None`) `instructions` and whether the call carries a
58/// `result_schema`.
59///
60/// With no `result_schema` in force, the worker's final message is the sole
61/// return channel, so [`WORKER_CONDENSATION_CONTRACT`] is always appended
62/// (on its own line pair after `instructions`, or standalone when
63/// `instructions` is `None`) — this branch never returns `None`. With a
64/// `result_schema` in force, the schema-forced finalize path bounds the
65/// answer's shape instead, so the contract text is NOT injected and
66/// `instructions` passes through unchanged (`None` stays `None`).
67#[must_use]
68pub(crate) fn worker_system_text(
69    instructions: Option<&str>,
70    has_result_schema: bool,
71) -> Option<String> {
72    if has_result_schema {
73        return instructions.map(str::to_owned);
74    }
75    Some(instructions.map_or_else(
76        || WORKER_CONDENSATION_CONTRACT.to_owned(),
77        |instructions| format!("{instructions}\n\n{WORKER_CONDENSATION_CONTRACT}"),
78    ))
79}
80
81/// Renders a delegated worker's turn-start system message (`#1323`),
82/// mirroring `polyc_control_plane`'s top-level `turn_start_block` — same
83/// wording, same UTC-at-minute-precision rendering — from `unix_ms`, the
84/// PARENT turn's frozen dispatch clock
85/// (`RunTurnOptions::turn_start_unix_ms`), never an independent read: a
86/// worker's nested turn has no dispatch clock of its own to freeze, and
87/// reading one here would break replay determinism (INV-11).
88///
89/// `None` when `unix_ms` falls outside `jiff::Timestamp`'s representable
90/// range (in practice, only a caller passing `u64::MAX`) — a worker told
91/// nothing is safer than one told a wrong time, the same rule the top-level
92/// stamp follows.
93///
94/// Pushed as its OWN system message (see [`crate::run_delegate_call`]),
95/// never folded into [`worker_system_text`]'s returned string: the
96/// instructions/condensation text is the worker prompt's stable content,
97/// and this value changes on every dispatch, so joining them would defeat
98/// any future caching of the stable part.
99#[must_use]
100pub(crate) fn worker_turn_start_block(unix_ms: u64) -> Option<String> {
101    let instant = i64::try_from(unix_ms)
102        .ok()
103        .and_then(|ms| jiff::Timestamp::from_millisecond(ms).ok())?
104        .strftime("%Y-%m-%d %H:%M")
105        .to_string();
106    Some(format!(
107        "This turn started at {instant} UTC. Later steps in this turn may \
108         run after this instant."
109    ))
110}
111
112/// JSON-schema spec for the delegate tool. Provided alongside the user's tool
113/// specs, but ONLY when at least one [`DelegateDescriptor`] is configured —
114/// see [`delegate_tool_spec`].
115#[must_use]
116pub fn delegate_tool_spec() -> ToolSpec {
117    // Like the handoff primitive, delegation is a runtime mechanism the
118    // capability gate never mediates (the orchestrator-level call is always
119    // allowed) — the worker's OWN nested turn re-applies the full gate to
120    // everything it does, fail-closed (see `run_turn_with`'s unattended-mode
121    // wiring for the nested options).
122    ToolSpec::new(
123        DELEGATE_TOOL_NAME,
124        "Hand a single, self-contained task to a specialized worker and wait for its answer. \
125         The worker runs in an isolated context — it does NOT see this conversation's history, \
126         only `task` and, if given, `context` — so state everything the worker needs to know. \
127         `target_agent_id` selects which worker runs the task. Set `result_schema` (a JSON \
128         Schema) to force the worker's answer into that shape instead of free text — the worker \
129         gets one retry if its first answer doesn't match, and reports a structured failure if it \
130         still can't conform.",
131        serde_json::json!({
132            "type": "object",
133            "properties": {
134                "target_agent_id": {
135                    "type": "string",
136                    "description": "Identifier of the worker agent to run the task."
137                },
138                "task": {
139                    "type": "string",
140                    "description": "The self-contained task for the worker to perform."
141                },
142                "context": {
143                    "type": "string",
144                    "description": "Optional extra context the worker needs — the worker sees no \
145                        other history, so include anything relevant here."
146                },
147                "result_schema": {
148                    "type": "object",
149                    "description": "Optional JSON Schema the worker's final answer must satisfy. \
150                        Omit for a free-text answer."
151                }
152            },
153            "required": ["target_agent_id", "task"],
154            "additionalProperties": false
155        }),
156    )
157}
158
159/// A resolved, self-contained worker configuration for one `can_delegate_to`
160/// target (#870).
161///
162/// Built by the control plane at turn dispatch — NEVER by this crate — and
163/// threaded down through the wire (`TurnInput.delegate_descriptors`) and the
164/// harness's tool-executor composition
165/// (`polyc_turn_runner::resolve_delegate_descriptors`) into
166/// [`crate::RunTurnOptions::delegate_descriptors`]. See
167/// `crates/control-plane/src/delegate.rs` for how the fields here are
168/// resolved (provider/model fallback, connector-scope intersection, the
169/// read-only-by-default built-in allowlist).
170#[derive(Clone)]
171pub struct DelegateDescriptor {
172    /// The target `Agent` resource name — matched (trailing-name, mirroring
173    /// [`crate::HandoffRequest::child_agent_id`]'s resolution) against the
174    /// model's `__delegate_to(target_agent_id, ...)` argument to pick this
175    /// descriptor. See [`find_descriptor`].
176    pub agent_id: String,
177    /// System instructions for the worker's nested turn. `None` ⇒ no
178    /// agent-specific instructions.
179    pub instructions: Option<String>,
180    /// The worker's resolved backend, already picked from the deployment's
181    /// registered providers — this crate never resolves a provider selector
182    /// string itself.
183    pub provider: Arc<DynProvider>,
184    /// The registry key of [`Self::provider`] (e.g. `"vertex"`, `"stub"`) —
185    /// carried alongside the erased backend so a forensic record (`#872`,
186    /// `DelegateRecord::resolved_provider`) can name the provider without
187    /// this crate needing a `Debug`/name accessor on [`DynProvider`] itself.
188    pub provider_name: String,
189    /// The worker's resolved model id.
190    pub model: String,
191    /// The worker's advertised tool specs. Never includes
192    /// [`DELEGATE_TOOL_NAME`] — this is what caps delegation depth at one,
193    /// since [`crate::run_turn_with`] only advertises the delegate tool when
194    /// its OWN `delegate_descriptors` option is non-empty, and a nested turn
195    /// always runs with that option empty.
196    pub tool_specs: Vec<ToolSpec>,
197    /// The worker's step budget, applied to the nested turn's
198    /// `RunTurnOptions::max_steps`.
199    pub max_steps: usize,
200    /// Whether the worker's nested turn may ground on the provider's native
201    /// web-search primitive (`RunTurnOptions::native_search_allowed`).
202    /// Derived the same way the parent turn's own scoping is (`#1226`): the
203    /// resolved descriptor's `builtin_tools` named
204    /// `polyc_tools::web::NATIVE_SEARCH_GROUNDING` — never hardcoded true,
205    /// since that would hand every worker a capability its own agent
206    /// manifest never granted.
207    pub native_search_allowed: bool,
208}
209
210impl std::fmt::Debug for DelegateDescriptor {
211    /// Hand-rolled: [`DynProvider`] carries no `Debug` impl (the `LlmProvider`
212    /// trait doesn't require one), so this can't be `#[derive(Debug)]`d.
213    /// Prints tool names, not full specs, to stay short in a turn-level log.
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.debug_struct("DelegateDescriptor")
216            .field("agent_id", &self.agent_id)
217            .field("provider_name", &self.provider_name)
218            .field("model", &self.model)
219            .field(
220                "tool_specs",
221                &self.tool_specs.iter().map(|s| &s.name).collect::<Vec<_>>(),
222            )
223            .field("max_steps", &self.max_steps)
224            .finish_non_exhaustive()
225    }
226}
227
228/// The trailing name segment of a `target_agent_id` / descriptor `agent_id`,
229/// mirroring [`crate::handoff`]'s equivalent (own copy — see that module for
230/// why the tolerant match exists: an operator may author either a bare name
231/// or a namespaced `agent:ns/name` ref).
232fn trailing_name(entry: &str) -> &str {
233    entry.rsplit('/').next().unwrap_or(entry)
234}
235
236/// Find the [`DelegateDescriptor`] matching `target_agent_id` by trailing
237/// name.
238#[must_use]
239pub fn find_descriptor<'a>(
240    descriptors: &'a [DelegateDescriptor],
241    target_agent_id: &str,
242) -> Option<&'a DelegateDescriptor> {
243    let target = trailing_name(target_agent_id);
244    descriptors
245        .iter()
246        .find(|d| trailing_name(&d.agent_id) == target)
247}
248
249/// Parsed `__delegate_to` arguments, produced by [`parse_delegate_args`].
250#[derive(Debug, Clone)]
251pub struct DelegateRequest {
252    /// Tool-call id the provider assigned to the `__delegate_to` call. Echoed
253    /// back as the `tool_result` id so the function-calling loop sees a
254    /// matched call → result pair.
255    pub call_id: String,
256    /// The model's chosen worker agent identifier.
257    pub target_agent_id: String,
258    /// The self-contained task for the worker to perform — becomes the sole
259    /// user message of the worker's fresh transcript.
260    pub task: String,
261    /// Optional extra context, appended to the worker's transcript alongside
262    /// `task`. `None` when the model supplied none.
263    pub context: Option<String>,
264    /// Optional JSON Schema the worker's final answer must satisfy (`#871`).
265    /// `None` ⇒ the worker answers in free text under the
266    /// [`WORKER_CONDENSATION_CONTRACT`] appended to its instructions
267    /// (INV-C25, `#1140`). The raw schema value is not validated for well-formedness
268    /// here (compiling it into a [`jsonschema::Validator`] is the caller's
269    /// job, at the point it's actually used) — a bad schema is an argument
270    /// error the caller surfaces the same way a missing `task` is.
271    pub result_schema: Option<serde_json::Value>,
272}
273
274/// Parse the JSON arguments of a `__delegate_to` tool call into a structured
275/// [`DelegateRequest`].
276///
277/// Returns `None` if `args_json` doesn't parse, or either required field
278/// (`target_agent_id`, `task`) is missing or empty — the caller then
279/// surfaces a legible tool-result error rather than dispatching a malformed
280/// delegation.
281#[must_use]
282pub fn parse_delegate_args(call_id: &str, args_json: &str) -> Option<DelegateRequest> {
283    let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
284    let target_agent_id = v.get("target_agent_id")?.as_str()?.to_owned();
285    if target_agent_id.is_empty() {
286        return None;
287    }
288    let task = v.get("task")?.as_str()?.to_owned();
289    if task.is_empty() {
290        return None;
291    }
292    let context = v
293        .get("context")
294        .and_then(serde_json::Value::as_str)
295        .filter(|s| !s.is_empty())
296        .map(str::to_owned);
297    let result_schema = v.get("result_schema").cloned();
298    Some(DelegateRequest {
299        call_id: call_id.to_owned(),
300        target_agent_id,
301        task,
302        context,
303        result_schema,
304    })
305}
306
307#[cfg(test)]
308mod tests {
309    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
310
311    use super::*;
312
313    fn descriptor(agent_id: &str) -> DelegateDescriptor {
314        DelegateDescriptor {
315            agent_id: agent_id.to_owned(),
316            instructions: None,
317            provider: polyc_llm::into_dyn(polyc_llm::turn::StubProvider),
318            provider_name: "stub".to_owned(),
319            model: "stub".to_owned(),
320            tool_specs: Vec::new(),
321            max_steps: 4,
322            native_search_allowed: false,
323        }
324    }
325
326    #[test]
327    fn parses_minimum_required_args() {
328        let req = parse_delegate_args(
329            "c-1",
330            r#"{"target_agent_id":"researcher","task":"find the answer"}"#,
331        )
332        .unwrap();
333        assert_eq!(req.target_agent_id, "researcher");
334        assert_eq!(req.task, "find the answer");
335        assert!(req.context.is_none());
336        assert_eq!(req.call_id, "c-1");
337    }
338
339    #[test]
340    fn parses_optional_context() {
341        let req = parse_delegate_args(
342            "c-2",
343            r#"{"target_agent_id":"x","task":"t","context":"extra"}"#,
344        )
345        .unwrap();
346        assert_eq!(req.context.as_deref(), Some("extra"));
347    }
348
349    #[test]
350    fn parses_optional_result_schema() {
351        let req = parse_delegate_args(
352            "c-3",
353            r#"{"target_agent_id":"x","task":"t","result_schema":{"type":"object"}}"#,
354        )
355        .unwrap();
356        assert_eq!(
357            req.result_schema,
358            Some(serde_json::json!({"type":"object"}))
359        );
360    }
361
362    #[test]
363    fn result_schema_absent_by_default() {
364        let req = parse_delegate_args("c-4", r#"{"target_agent_id":"x","task":"t"}"#).unwrap();
365        assert!(req.result_schema.is_none());
366    }
367
368    #[test]
369    fn rejects_missing_target_agent_id() {
370        assert!(parse_delegate_args("c", r#"{"task":"t"}"#).is_none());
371    }
372
373    #[test]
374    fn rejects_empty_target_agent_id() {
375        assert!(parse_delegate_args("c", r#"{"target_agent_id":"","task":"t"}"#).is_none());
376    }
377
378    #[test]
379    fn rejects_missing_task() {
380        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x"}"#).is_none());
381    }
382
383    #[test]
384    fn rejects_empty_task() {
385        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x","task":""}"#).is_none());
386    }
387
388    #[test]
389    fn rejects_garbage_json() {
390        assert!(parse_delegate_args("c", "not-json").is_none());
391    }
392
393    #[test]
394    fn delegate_tool_spec_has_required_fields() {
395        let spec = delegate_tool_spec();
396        assert_eq!(spec.name, DELEGATE_TOOL_NAME);
397        let required = spec
398            .schema_json
399            .get("required")
400            .and_then(|v| v.as_array())
401            .cloned()
402            .unwrap_or_default();
403        assert!(required.iter().any(|v| v == "target_agent_id"));
404        assert!(required.iter().any(|v| v == "task"));
405        // #1141: strict schema — no undeclared arguments.
406        assert_eq!(
407            spec.schema_json.get("additionalProperties"),
408            Some(&serde_json::json!(false))
409        );
410    }
411
412    #[test]
413    fn find_descriptor_matches_by_trailing_name() {
414        let descriptors = vec![descriptor("agent:default/researcher"), descriptor("coder")];
415        assert!(find_descriptor(&descriptors, "researcher").is_some());
416        assert!(find_descriptor(&descriptors, "agent:other-ns/researcher").is_some());
417        assert!(find_descriptor(&descriptors, "coder").is_some());
418        assert!(find_descriptor(&descriptors, "ghost").is_none());
419    }
420
421    // ── #1140 / INV-C25: `worker_system_text` ────────────────────────────────
422    //
423    // TEST-17 (CONF-17), as direct unit tests of the pure composition helper
424    // (PR #1152 review finding) rather than round-tripping a full worker turn
425    // through a provider-capture harness — the schema×instructions matrix
426    // lives entirely in this one function.
427
428    /// TEST-17, first half: no `result_schema` ⇒ the contract is appended
429    /// after the descriptor's own instructions.
430    #[test]
431    fn contract_appended_after_instructions_without_schema() {
432        let text = worker_system_text(Some("You are a scoped worker."), false)
433            .expect("no-schema path always returns Some");
434        assert_eq!(
435            text,
436            format!("You are a scoped worker.\n\n{WORKER_CONDENSATION_CONTRACT}")
437        );
438    }
439
440    /// TEST-17 corollary: no instructions of its own and no `result_schema`
441    /// ⇒ the contract alone — a worker is never dispatched untold that its
442    /// final message is the sole return channel.
443    #[test]
444    fn contract_alone_without_instructions_or_schema() {
445        let text = worker_system_text(None, false).expect("no-schema path always returns Some");
446        assert_eq!(text, WORKER_CONDENSATION_CONTRACT);
447    }
448
449    /// TEST-17, second half: with a `result_schema` in force, the
450    /// schema-forced finalize path satisfies INV-C25 instead — the contract
451    /// text is NOT injected and the descriptor's own instructions pass
452    /// through unchanged.
453    #[test]
454    fn instructions_unchanged_with_schema() {
455        let text = worker_system_text(Some("You are a scoped worker."), true);
456        assert_eq!(text.as_deref(), Some("You are a scoped worker."));
457    }
458
459    /// With a `result_schema` in force AND no instructions, there is nothing
460    /// to inject or pass through — no system message at all.
461    #[test]
462    fn no_system_text_with_schema_and_no_instructions() {
463        assert_eq!(worker_system_text(None, true), None);
464    }
465
466    // ── #1323: `worker_turn_start_block` ────────────────────────────────────
467
468    // Keep in lockstep with
469    // `turn_start_block_labels_the_start_time_states_utc_and_omits_on_derivation_failure`
470    // (crates/control-plane/src/grpc/tests.rs) — that test pins the same full
471    // string for the same input ms against `turn_start_block`, the top-level
472    // renderer this one deliberately mirrors.
473    #[test]
474    fn renders_utc_at_minute_precision() {
475        // 2024-05-17T09:33:59Z, truncated to its own minute (never rounded).
476        let block = worker_turn_start_block(1_715_938_439_000).expect("in-range");
477        assert_eq!(
478            block,
479            "This turn started at 2024-05-17 09:33 UTC. Later steps in this turn may run after \
480             this instant."
481        );
482    }
483
484    #[test]
485    fn matches_the_top_level_blocks_wording() {
486        // Mirrors `polyc_control_plane::grpc::turn_start_block`'s phrasing
487        // exactly — "started" framing, never "now".
488        let block = worker_turn_start_block(0).expect("epoch is in range");
489        assert!(block.starts_with("This turn started at "));
490        assert!(!block.to_lowercase().contains("now"));
491    }
492
493    #[test]
494    fn out_of_range_instant_renders_no_block() {
495        assert_eq!(worker_turn_start_block(u64::MAX), None);
496    }
497
498    #[test]
499    fn same_input_ms_renders_identical_bytes() {
500        // Determinism: replaying the same recorded dispatch clock must
501        // reproduce the exact same stamp, never a fresh clock's drift.
502        let a = worker_turn_start_block(1_715_938_439_000);
503        let b = worker_turn_start_block(1_715_938_439_000);
504        assert_eq!(a, b);
505    }
506}