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}