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//!     and a child `Conversation` is created. The transfer is one-way: the
11//!     child's result never returns to the parent. It short-circuits the
12//!     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/// Operator-declared ceiling on the parent files a delegated worker may be
30/// seeded with (`#2295`).
31///
32/// `#2286` fenced every worker in its own workspace subtree and re-rooted the
33/// coding tools against it, reads included — which ended the previously
34/// implicit contract that a worker could read the parent's workspace. This
35/// ceiling is the static half of restoring it: the target agent's manifest
36/// declares the maximum reachable set, and a `__delegate_to` call names
37/// specific paths *within* it (see [`DelegateRequest::share_in`]). The
38/// orchestrating model can therefore pick the file a task is about without
39/// being able to widen what any worker of that agent can ever see.
40///
41/// The default is CLOSED: an empty [`Self::allow`] seeds nothing, so an agent
42/// whose manifest says nothing about share-in keeps `#2286`'s behavior exactly.
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct ShareInCeiling {
45    /// Glob patterns, matched against parent-workspace-relative file paths.
46    /// A file is eligible only if it matches at least one pattern. Empty ⇒
47    /// nothing is eligible.
48    pub allow: Vec<String>,
49    /// Maximum number of files one delegation may seed. Zero ⇒ nothing is
50    /// eligible, matching the empty-`allow` closed default.
51    pub max_files: usize,
52    /// Maximum total bytes one delegation may seed, so a fan-out of workers
53    /// cannot exhaust the workspace volume's `sizeLimit`. Zero ⇒ nothing is
54    /// eligible.
55    pub max_bytes: u64,
56}
57
58impl ShareInCeiling {
59    /// Whether this ceiling can admit any file at all.
60    ///
61    /// Every zero/empty field independently closes the ceiling, so a partially
62    /// configured manifest fails closed rather than admitting an unbounded set.
63    #[must_use]
64    pub const fn admits_anything(&self) -> bool {
65        !self.allow.is_empty() && self.max_files > 0 && self.max_bytes > 0
66    }
67}
68
69/// One delegated worker's identity and share-in request, handed to
70/// [`crate::ToolExecutor::for_worker`] (`#2295`).
71///
72/// Carries the call-site request and the descriptor ceiling together so the
73/// executor that owns the workspace — the only layer that knows both the
74/// parent root and the worker root — can enforce one against the other in a
75/// single step, rather than re-rooting first and seeding through a second
76/// method a wrapper could forget to forward.
77#[derive(Debug, Clone, Copy)]
78pub struct WorkerScope<'a> {
79    /// The worker's delegate call id, which keys its workspace subtree.
80    pub worker_id: &'a str,
81    /// Parent-workspace-relative paths the call asked to seed. Each entry is a
82    /// literal path — a file, or a directory seeded recursively. Empty ⇒ no
83    /// seeding, and the worker starts on an empty scratch space.
84    pub share_in: &'a [String],
85    /// The target agent's ceiling, which [`Self::share_in`] must fall within.
86    pub ceiling: &'a ShareInCeiling,
87}
88
89impl<'a> WorkerScope<'a> {
90    /// A scope that seeds nothing — `#2286`'s behavior, and what every caller
91    /// that has no share-in request to make should pass.
92    #[must_use]
93    pub const fn bare(worker_id: &'a str) -> Self {
94        Self {
95            worker_id,
96            share_in: &[],
97            ceiling: &EMPTY_CEILING,
98        }
99    }
100}
101
102/// Backing storage for [`WorkerScope::bare`]'s ceiling reference.
103///
104/// A `static` rather than a `const`: [`ShareInCeiling`] owns a [`Vec`], so it
105/// carries a `Drop` impl and `&CONST` would not promote to `'static`.
106static EMPTY_CEILING: ShareInCeiling = ShareInCeiling {
107    allow: Vec::new(),
108    max_files: 0,
109    max_bytes: 0,
110};
111
112/// Why a share-in request was refused (`#2295`).
113///
114/// Every variant is a hard refusal that fails the delegation: seeding is
115/// bounded by an operator ceiling precisely so exceeding it is an error the
116/// caller sees, not a silent truncation that hands the worker an arbitrary
117/// subset of what the task needed.
118#[derive(Debug, thiserror::Error)]
119pub enum ShareInError {
120    /// The path left the parent workspace, or named an absolute location.
121    #[error("cannot share in `{path}`: {reason}")]
122    Escapes {
123        /// The offending request entry.
124        path: String,
125        /// [`polyc_tools`-style lexical containment's] own reason string.
126        ///
127        /// [`polyc_tools`-style lexical containment's]: crate::ToolExecutor::for_worker
128        reason: String,
129    },
130    /// The path reached into a delegated worker's own subtree — a sibling's
131    /// scratch space, or this worker's. Seeding from one would hand a worker
132    /// exactly the cross-worker read `#2286` fenced off.
133    #[error("cannot share in `{path}`: it is inside a delegated worker's workspace")]
134    WorkerSubtree {
135        /// The offending request entry.
136        path: String,
137    },
138    /// The file exists and is inside the workspace, but no ceiling pattern
139    /// admits it.
140    #[error("cannot share in `{path}`: this agent's share-in ceiling does not include it")]
141    OutsideCeiling {
142        /// The offending workspace-relative file path.
143        path: String,
144    },
145    /// The request named more files than the ceiling admits.
146    #[error("cannot share in {found} files: this agent's ceiling allows {limit}")]
147    TooManyFiles {
148        /// How many files the request resolved to.
149        found: usize,
150        /// [`ShareInCeiling::max_files`].
151        limit: usize,
152    },
153    /// The request named more bytes than the ceiling admits.
154    #[error("cannot share in {found} bytes: this agent's ceiling allows {limit}")]
155    TooManyBytes {
156        /// How many bytes the request resolved to.
157        found: u64,
158        /// [`ShareInCeiling::max_bytes`].
159        limit: u64,
160    },
161    /// The path named nothing in the parent workspace.
162    #[error("cannot share in `{path}`: no such file or directory in the workspace")]
163    NotFound {
164        /// The offending request entry.
165        path: String,
166    },
167    /// Reading the parent file or writing the worker's copy failed.
168    #[error("could not share in `{path}`: {reason}")]
169    Io {
170        /// The offending workspace-relative path.
171        path: String,
172        /// The underlying I/O error, rendered.
173        reason: String,
174    },
175}
176
177/// A delegated worker's re-rooted executor plus what was seeded into it
178/// (`#2295`).
179pub struct WorkerHandoff {
180    /// The executor scoped to the worker's own workspace subtree.
181    pub tools: Arc<dyn crate::ToolExecutor>,
182    /// Workspace-relative paths seeded into that subtree, in the order they
183    /// were copied — recorded on the delegation's forensic record so a seed is
184    /// attributable to the delegate call id that requested it.
185    pub seeded: Vec<String>,
186}
187
188impl std::fmt::Debug for WorkerHandoff {
189    /// Hand-rolled: [`crate::ToolExecutor`] carries no `Debug` bound, so the
190    /// executor is elided and only the seeded set is rendered.
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        f.debug_struct("WorkerHandoff")
193            .field("tools", &"<dyn ToolExecutor>")
194            .field("seeded", &self.seeded)
195            .finish()
196    }
197}
198
199/// The reserved tool name the model emits to request an in-process
200/// delegation to a scoped worker agent.
201///
202/// Advertised only when [`crate::RunTurnOptions::delegate_descriptors`] is
203/// non-empty (see [`delegate_tool_spec`]'s call site in `run_turn_with`) — a
204/// conversation whose agent declares no delegation targets never sees this
205/// name at all, so it can't collide with a real tool of the same name either.
206pub const DELEGATE_TOOL_NAME: &str = "__delegate_to";
207
208/// The condensation contract appended to every delegated worker's
209/// synthesized instructions when the call carries no `result_schema`
210/// (INV-C25, #1140).
211///
212/// The worker's final message is the sole return channel back to the
213/// caller, so the worker is told to make that message a self-contained
214/// summary of the outcome. When a `result_schema` IS in force, the
215/// schema-forced finalize path bounds the answer's shape instead and this
216/// text is not injected. The per-call result cap (`MAX_TOOL_RESULT_BYTES`
217/// middle-elision) stays as the hard backstop either way — this contract
218/// instructs, the cap enforces.
219pub const WORKER_CONDENSATION_CONTRACT: &str = "You are completing one delegated task. The \
220    caller sees only your final message — none of your tool calls, intermediate work, or \
221    earlier drafts reach it. Make your final message a self-contained summary of the outcome: \
222    what you did or found, the key details the caller needs, and anything that failed. Keep it \
223    concise — an overlong answer is trimmed from the middle.";
224
225/// Composes a delegated worker's synthesized system message text
226/// (INV-C25, `#1140`), given the descriptor's own (already trimmed,
227/// non-empty-or-`None`) `instructions` and whether the call carries a
228/// `result_schema`.
229///
230/// With no `result_schema` in force, the worker's final message is the sole
231/// return channel, so [`WORKER_CONDENSATION_CONTRACT`] is always appended
232/// (on its own line pair after `instructions`, or standalone when
233/// `instructions` is `None`) — this branch never returns `None`. With a
234/// `result_schema` in force, the schema-forced finalize path bounds the
235/// answer's shape instead, so the contract text is NOT injected and
236/// `instructions` passes through unchanged (`None` stays `None`).
237#[must_use]
238pub(crate) fn worker_system_text(
239    instructions: Option<&str>,
240    has_result_schema: bool,
241) -> Option<String> {
242    if has_result_schema {
243        return instructions.map(str::to_owned);
244    }
245    Some(instructions.map_or_else(
246        || WORKER_CONDENSATION_CONTRACT.to_owned(),
247        |instructions| format!("{instructions}\n\n{WORKER_CONDENSATION_CONTRACT}"),
248    ))
249}
250
251/// Renders a delegated worker's turn-start system message (`#1323`),
252/// mirroring `polyc_control_plane`'s top-level `turn_start_block` — same
253/// wording, same UTC-at-minute-precision rendering — from `unix_ms`, the
254/// PARENT turn's frozen dispatch clock
255/// (`RunTurnOptions::turn_start_unix_ms`), never an independent read: a
256/// worker's nested turn has no dispatch clock of its own to freeze, and
257/// reading one here would break replay determinism (INV-11).
258///
259/// `None` when `unix_ms` falls outside `jiff::Timestamp`'s representable
260/// range (in practice, only a caller passing `u64::MAX`) — a worker told
261/// nothing is safer than one told a wrong time, the same rule the top-level
262/// stamp follows.
263///
264/// Pushed as its OWN system message (see [`crate::run_delegate_call`]),
265/// never folded into [`worker_system_text`]'s returned string: the
266/// instructions/condensation text is the worker prompt's stable content,
267/// and this value changes on every dispatch, so joining them would defeat
268/// any future caching of the stable part.
269#[must_use]
270pub(crate) fn worker_turn_start_block(unix_ms: u64) -> Option<String> {
271    let instant = i64::try_from(unix_ms)
272        .ok()
273        .and_then(|ms| jiff::Timestamp::from_millisecond(ms).ok())?
274        .strftime("%Y-%m-%d %H:%M")
275        .to_string();
276    Some(format!(
277        "This turn started at {instant} UTC. Later steps in this turn may \
278         run after this instant."
279    ))
280}
281
282/// JSON-schema spec for the delegate tool. Provided alongside the user's tool
283/// specs, but ONLY when at least one [`DelegateDescriptor`] is configured —
284/// see [`delegate_tool_spec`].
285#[must_use]
286pub fn delegate_tool_spec() -> ToolSpec {
287    // Like the handoff primitive, delegation is a runtime mechanism the
288    // capability gate never mediates (the orchestrator-level call is always
289    // allowed) — the worker's OWN nested turn re-applies the full gate to
290    // everything it does, fail-closed (see `run_turn_with`'s unattended-mode
291    // wiring for the nested options).
292    ToolSpec::new(
293        DELEGATE_TOOL_NAME,
294        "Hand a single, self-contained task to a specialized worker and wait for its answer. \
295         The worker runs in an isolated context — it does NOT see this conversation's history, \
296         only `task` and, if given, `context` — so state everything the worker needs to know. \
297         `target_agent_id` selects which worker runs the task. Set `result_schema` (a JSON \
298         Schema) to force the worker's answer into that shape instead of free text — the worker \
299         gets one retry if its first answer doesn't match, and reports a structured failure if it \
300         still can't conform.",
301        serde_json::json!({
302            "type": "object",
303            "properties": {
304                "target_agent_id": {
305                    "type": "string",
306                    "description": "Identifier of the worker agent to run the task."
307                },
308                "task": {
309                    "type": "string",
310                    "description": "The self-contained task for the worker to perform."
311                },
312                "context": {
313                    "type": "string",
314                    "description": "Optional extra context the worker needs — the worker sees no \
315                        other history, so include anything relevant here."
316                },
317                "result_schema": {
318                    "type": "object",
319                    "description": "Optional JSON Schema the worker's final answer must satisfy. \
320                        Omit for a free-text answer."
321                },
322                "share_in": {
323                    "type": "array",
324                    "items": {"type": "string"},
325                    "description": "Optional workspace files to copy into the worker's own \
326                        workspace before it starts. The worker has a separate workspace and \
327                        cannot see yours, so name every file its task is about — each entry is \
328                        a path relative to the workspace, either a file or a directory. The \
329                        worker gets its own copy; its edits never reach your files."
330                }
331            },
332            "required": ["target_agent_id", "task"],
333            "additionalProperties": false
334        }),
335    )
336}
337
338/// A resolved, self-contained worker configuration for one `can_delegate_to`
339/// target (#870).
340///
341/// Built by the control plane at turn dispatch — NEVER by this crate — and
342/// threaded down through the wire (`TurnInput.delegate_descriptors`) and the
343/// harness's tool-executor composition
344/// (`polyc_turn_runner::resolve_delegate_descriptors`) into
345/// [`crate::RunTurnOptions::delegate_descriptors`]. See
346/// `crates/control-plane/src/delegate.rs` for how the fields here are
347/// resolved (provider/model fallback, connector-scope intersection, the
348/// read-only-by-default built-in allowlist).
349#[derive(Clone)]
350pub struct DelegateDescriptor {
351    /// The target `Agent` resource name — matched (trailing-name, mirroring
352    /// [`crate::HandoffRequest::child_agent_id`]'s resolution) against the
353    /// model's `__delegate_to(target_agent_id, ...)` argument to pick this
354    /// descriptor. See [`find_descriptor`].
355    pub agent_id: String,
356    /// System instructions for the worker's nested turn. `None` ⇒ no
357    /// agent-specific instructions.
358    pub instructions: Option<String>,
359    /// The worker's resolved backend, already picked from the deployment's
360    /// registered providers — this crate never resolves a provider selector
361    /// string itself.
362    pub provider: Arc<DynProvider>,
363    /// The registry key of [`Self::provider`] (e.g. `"vertex"`, `"stub"`) —
364    /// carried alongside the erased backend so a forensic record (`#872`,
365    /// `DelegateRecord::resolved_provider`) can name the provider without
366    /// this crate needing a `Debug`/name accessor on [`DynProvider`] itself.
367    pub provider_name: String,
368    /// The worker's resolved model id.
369    pub model: String,
370    /// The worker's advertised tool specs. Never includes
371    /// [`DELEGATE_TOOL_NAME`] — this is what caps delegation depth at one,
372    /// since [`crate::run_turn_with`] only advertises the delegate tool when
373    /// its OWN `delegate_descriptors` option is non-empty, and a nested turn
374    /// always runs with that option empty.
375    pub tool_specs: Vec<ToolSpec>,
376    /// The worker's step budget, applied to the nested turn's
377    /// `RunTurnOptions::max_steps`.
378    pub max_steps: usize,
379    /// Whether the worker's nested turn may ground on the provider's native
380    /// web-search primitive (`RunTurnOptions::native_search_allowed`).
381    /// Derived the same way the parent turn's own scoping is (`#1226`): the
382    /// resolved descriptor's `builtin_tools` named
383    /// `polyc_tools::web::NATIVE_SEARCH_GROUNDING` — never hardcoded true,
384    /// since that would hand every worker a capability its own agent
385    /// manifest never granted.
386    pub native_search_allowed: bool,
387    /// The ceiling on parent files a worker of this agent may be seeded with
388    /// (`#2295`), from the target agent's manifest. Defaults closed, so an
389    /// agent that declares no share-in keeps `#2286`'s fully-fenced worker.
390    pub share_in: ShareInCeiling,
391}
392
393impl std::fmt::Debug for DelegateDescriptor {
394    /// Hand-rolled: [`DynProvider`] carries no `Debug` impl (the `LlmProvider`
395    /// trait doesn't require one), so this can't be `#[derive(Debug)]`d.
396    /// Prints tool names, not full specs, to stay short in a turn-level log.
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        f.debug_struct("DelegateDescriptor")
399            .field("agent_id", &self.agent_id)
400            .field("provider_name", &self.provider_name)
401            .field("model", &self.model)
402            .field(
403                "tool_specs",
404                &self.tool_specs.iter().map(|s| &s.name).collect::<Vec<_>>(),
405            )
406            .field("max_steps", &self.max_steps)
407            .finish_non_exhaustive()
408    }
409}
410
411/// The trailing name segment of a `target_agent_id` / descriptor `agent_id`,
412/// mirroring [`crate::handoff`]'s equivalent (own copy — see that module for
413/// why the tolerant match exists: an operator may author either a bare name
414/// or a namespaced `agent:ns/name` ref).
415fn trailing_name(entry: &str) -> &str {
416    entry.rsplit('/').next().unwrap_or(entry)
417}
418
419/// Find the [`DelegateDescriptor`] matching `target_agent_id` by trailing
420/// name.
421#[must_use]
422pub fn find_descriptor<'a>(
423    descriptors: &'a [DelegateDescriptor],
424    target_agent_id: &str,
425) -> Option<&'a DelegateDescriptor> {
426    let target = trailing_name(target_agent_id);
427    descriptors
428        .iter()
429        .find(|d| trailing_name(&d.agent_id) == target)
430}
431
432/// Parsed `__delegate_to` arguments, produced by [`parse_delegate_args`].
433#[derive(Debug, Clone)]
434pub struct DelegateRequest {
435    /// Tool-call id the provider assigned to the `__delegate_to` call. Echoed
436    /// back as the `tool_result` id so the function-calling loop sees a
437    /// matched call → result pair.
438    pub call_id: String,
439    /// The model's chosen worker agent identifier.
440    pub target_agent_id: String,
441    /// The self-contained task for the worker to perform — becomes the sole
442    /// user message of the worker's fresh transcript.
443    pub task: String,
444    /// Optional extra context, appended to the worker's transcript alongside
445    /// `task`. `None` when the model supplied none.
446    pub context: Option<String>,
447    /// Optional JSON Schema the worker's final answer must satisfy (`#871`).
448    /// `None` ⇒ the worker answers in free text under the
449    /// [`WORKER_CONDENSATION_CONTRACT`] appended to its instructions
450    /// (INV-C25, `#1140`). The raw schema value is not validated for well-formedness
451    /// here (compiling it into a [`jsonschema::Validator`] is the caller's
452    /// job, at the point it's actually used) — a bad schema is an argument
453    /// error the caller surfaces the same way a missing `task` is.
454    pub result_schema: Option<serde_json::Value>,
455    /// Parent-workspace paths to seed into the worker's own workspace before
456    /// its nested turn starts (`#2295`). Each entry is a literal relative path
457    /// — a file, or a directory seeded recursively — and every one must fall
458    /// inside the target agent's [`DelegateDescriptor::share_in`] ceiling.
459    /// Empty when the model named none, which leaves the worker on the empty
460    /// scratch space `#2286` gives it.
461    pub share_in: Vec<String>,
462}
463
464/// Parse the JSON arguments of a `__delegate_to` tool call into a structured
465/// [`DelegateRequest`].
466///
467/// Returns `None` if `args_json` doesn't parse, or either required field
468/// (`target_agent_id`, `task`) is missing or empty — the caller then
469/// surfaces a legible tool-result error rather than dispatching a malformed
470/// delegation.
471#[must_use]
472pub fn parse_delegate_args(call_id: &str, args_json: &str) -> Option<DelegateRequest> {
473    let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
474    let target_agent_id = v.get("target_agent_id")?.as_str()?.to_owned();
475    if target_agent_id.is_empty() {
476        return None;
477    }
478    let task = v.get("task")?.as_str()?.to_owned();
479    if task.is_empty() {
480        return None;
481    }
482    let context = v
483        .get("context")
484        .and_then(serde_json::Value::as_str)
485        .filter(|s| !s.is_empty())
486        .map(str::to_owned);
487    let result_schema = v.get("result_schema").cloned();
488    // A non-array `share_in`, or an array with non-string members, yields an
489    // empty request rather than failing the parse: the delegation still has
490    // everything it needs to run, and a worker that starts unseeded fails
491    // visibly on its task instead of the orchestrator losing the whole call to
492    // an argument-shape error. Empty entries are dropped — they would resolve
493    // to the workspace root and seed everything.
494    let share_in = v
495        .get("share_in")
496        .and_then(serde_json::Value::as_array)
497        .map(|entries| {
498            entries
499                .iter()
500                .filter_map(serde_json::Value::as_str)
501                .filter(|s| !s.is_empty())
502                .map(str::to_owned)
503                .collect()
504        })
505        .unwrap_or_default();
506    Some(DelegateRequest {
507        call_id: call_id.to_owned(),
508        target_agent_id,
509        task,
510        context,
511        result_schema,
512        share_in,
513    })
514}
515
516#[cfg(test)]
517mod tests {
518    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
519
520    use super::*;
521
522    fn descriptor(agent_id: &str) -> DelegateDescriptor {
523        DelegateDescriptor {
524            agent_id: agent_id.to_owned(),
525            instructions: None,
526            provider: polyc_llm::into_dyn(polyc_llm::turn::StubProvider),
527            provider_name: "stub".to_owned(),
528            model: "stub".to_owned(),
529            tool_specs: Vec::new(),
530            max_steps: 4,
531            native_search_allowed: false,
532            share_in: ShareInCeiling::default(),
533        }
534    }
535
536    #[test]
537    fn parses_minimum_required_args() {
538        let req = parse_delegate_args(
539            "c-1",
540            r#"{"target_agent_id":"researcher","task":"find the answer"}"#,
541        )
542        .unwrap();
543        assert_eq!(req.target_agent_id, "researcher");
544        assert_eq!(req.task, "find the answer");
545        assert!(req.context.is_none());
546        assert_eq!(req.call_id, "c-1");
547    }
548
549    #[test]
550    fn parses_optional_context() {
551        let req = parse_delegate_args(
552            "c-2",
553            r#"{"target_agent_id":"x","task":"t","context":"extra"}"#,
554        )
555        .unwrap();
556        assert_eq!(req.context.as_deref(), Some("extra"));
557    }
558
559    #[test]
560    fn parses_optional_result_schema() {
561        let req = parse_delegate_args(
562            "c-3",
563            r#"{"target_agent_id":"x","task":"t","result_schema":{"type":"object"}}"#,
564        )
565        .unwrap();
566        assert_eq!(
567            req.result_schema,
568            Some(serde_json::json!({"type":"object"}))
569        );
570    }
571
572    #[test]
573    fn result_schema_absent_by_default() {
574        let req = parse_delegate_args("c-4", r#"{"target_agent_id":"x","task":"t"}"#).unwrap();
575        assert!(req.result_schema.is_none());
576    }
577
578    #[test]
579    fn rejects_missing_target_agent_id() {
580        assert!(parse_delegate_args("c", r#"{"task":"t"}"#).is_none());
581    }
582
583    #[test]
584    fn rejects_empty_target_agent_id() {
585        assert!(parse_delegate_args("c", r#"{"target_agent_id":"","task":"t"}"#).is_none());
586    }
587
588    #[test]
589    fn rejects_missing_task() {
590        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x"}"#).is_none());
591    }
592
593    #[test]
594    fn rejects_empty_task() {
595        assert!(parse_delegate_args("c", r#"{"target_agent_id":"x","task":""}"#).is_none());
596    }
597
598    #[test]
599    fn rejects_garbage_json() {
600        assert!(parse_delegate_args("c", "not-json").is_none());
601    }
602
603    #[test]
604    fn delegate_tool_spec_has_required_fields() {
605        let spec = delegate_tool_spec();
606        assert_eq!(spec.name, DELEGATE_TOOL_NAME);
607        let required = spec
608            .schema_json
609            .get("required")
610            .and_then(|v| v.as_array())
611            .cloned()
612            .unwrap_or_default();
613        assert!(required.iter().any(|v| v == "target_agent_id"));
614        assert!(required.iter().any(|v| v == "task"));
615        // #1141: strict schema — no undeclared arguments.
616        assert_eq!(
617            spec.schema_json.get("additionalProperties"),
618            Some(&serde_json::json!(false))
619        );
620    }
621
622    #[test]
623    fn find_descriptor_matches_by_trailing_name() {
624        let descriptors = vec![descriptor("agent:default/researcher"), descriptor("coder")];
625        assert!(find_descriptor(&descriptors, "researcher").is_some());
626        assert!(find_descriptor(&descriptors, "agent:other-ns/researcher").is_some());
627        assert!(find_descriptor(&descriptors, "coder").is_some());
628        assert!(find_descriptor(&descriptors, "ghost").is_none());
629    }
630
631    // ── #1140 / INV-C25: `worker_system_text` ────────────────────────────────
632    //
633    // TEST-17 (CONF-17), as direct unit tests of the pure composition helper
634    // (PR #1152 review finding) rather than round-tripping a full worker turn
635    // through a provider-capture harness — the schema×instructions matrix
636    // lives entirely in this one function.
637
638    /// TEST-17, first half: no `result_schema` ⇒ the contract is appended
639    /// after the descriptor's own instructions.
640    #[test]
641    fn contract_appended_after_instructions_without_schema() {
642        let text = worker_system_text(Some("You are a scoped worker."), false)
643            .expect("no-schema path always returns Some");
644        assert_eq!(
645            text,
646            format!("You are a scoped worker.\n\n{WORKER_CONDENSATION_CONTRACT}")
647        );
648    }
649
650    /// TEST-17 corollary: no instructions of its own and no `result_schema`
651    /// ⇒ the contract alone — a worker is never dispatched untold that its
652    /// final message is the sole return channel.
653    #[test]
654    fn contract_alone_without_instructions_or_schema() {
655        let text = worker_system_text(None, false).expect("no-schema path always returns Some");
656        assert_eq!(text, WORKER_CONDENSATION_CONTRACT);
657    }
658
659    /// TEST-17, second half: with a `result_schema` in force, the
660    /// schema-forced finalize path satisfies INV-C25 instead — the contract
661    /// text is NOT injected and the descriptor's own instructions pass
662    /// through unchanged.
663    #[test]
664    fn instructions_unchanged_with_schema() {
665        let text = worker_system_text(Some("You are a scoped worker."), true);
666        assert_eq!(text.as_deref(), Some("You are a scoped worker."));
667    }
668
669    /// With a `result_schema` in force AND no instructions, there is nothing
670    /// to inject or pass through — no system message at all.
671    #[test]
672    fn no_system_text_with_schema_and_no_instructions() {
673        assert_eq!(worker_system_text(None, true), None);
674    }
675
676    // ── #1323: `worker_turn_start_block` ────────────────────────────────────
677
678    // Keep in lockstep with
679    // `turn_start_block_labels_the_start_time_states_utc_and_omits_on_derivation_failure`
680    // (crates/control-plane/src/grpc/tests.rs) — that test pins the same full
681    // string for the same input ms against `turn_start_block`, the top-level
682    // renderer this one deliberately mirrors.
683    #[test]
684    fn renders_utc_at_minute_precision() {
685        // 2024-05-17T09:33:59Z, truncated to its own minute (never rounded).
686        let block = worker_turn_start_block(1_715_938_439_000).expect("in-range");
687        assert_eq!(
688            block,
689            "This turn started at 2024-05-17 09:33 UTC. Later steps in this turn may run after \
690             this instant."
691        );
692    }
693
694    #[test]
695    fn matches_the_top_level_blocks_wording() {
696        // Mirrors `polyc_control_plane::grpc::turn_start_block`'s phrasing
697        // exactly — "started" framing, never "now".
698        let block = worker_turn_start_block(0).expect("epoch is in range");
699        assert!(block.starts_with("This turn started at "));
700        assert!(!block.to_lowercase().contains("now"));
701    }
702
703    #[test]
704    fn out_of_range_instant_renders_no_block() {
705        assert_eq!(worker_turn_start_block(u64::MAX), None);
706    }
707
708    #[test]
709    fn same_input_ms_renders_identical_bytes() {
710        // Determinism: replaying the same recorded dispatch clock must
711        // reproduce the exact same stamp, never a fresh clock's drift.
712        let a = worker_turn_start_block(1_715_938_439_000);
713        let b = worker_turn_start_block(1_715_938_439_000);
714        assert_eq!(a, b);
715    }
716}