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