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}