use std::sync::Arc;
use polyc_llm::{DynProvider, ToolSpec};
pub const DELEGATE_TOOL_NAME: &str = "__delegate_to";
#[must_use]
pub fn delegate_tool_spec() -> ToolSpec {
ToolSpec::new(
DELEGATE_TOOL_NAME,
"Hand a single, self-contained task to a specialized worker and wait for its answer. \
The worker runs in an isolated context — it does NOT see this conversation's history, \
only `task` and, if given, `context` — so state everything the worker needs to know. \
`target_agent_id` selects which worker runs the task. Set `result_schema` (a JSON \
Schema) to force the worker's answer into that shape instead of free text — the worker \
gets one retry if its first answer doesn't match, and reports a structured failure if it \
still can't conform.",
serde_json::json!({
"type": "object",
"properties": {
"target_agent_id": {
"type": "string",
"description": "Identifier of the worker agent to run the task."
},
"task": {
"type": "string",
"description": "The self-contained task for the worker to perform."
},
"context": {
"type": "string",
"description": "Optional extra context the worker needs — the worker sees no \
other history, so include anything relevant here."
},
"result_schema": {
"type": "object",
"description": "Optional JSON Schema the worker's final answer must satisfy. \
Omit for a free-text answer."
}
},
"required": ["target_agent_id", "task"]
}),
)
}
#[derive(Clone)]
pub struct DelegateDescriptor {
pub agent_id: String,
pub instructions: Option<String>,
pub provider: Arc<DynProvider>,
pub provider_name: String,
pub model: String,
pub tool_specs: Vec<ToolSpec>,
pub max_steps: usize,
}
impl std::fmt::Debug for DelegateDescriptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DelegateDescriptor")
.field("agent_id", &self.agent_id)
.field("provider_name", &self.provider_name)
.field("model", &self.model)
.field(
"tool_specs",
&self.tool_specs.iter().map(|s| &s.name).collect::<Vec<_>>(),
)
.field("max_steps", &self.max_steps)
.finish_non_exhaustive()
}
}
fn trailing_name(entry: &str) -> &str {
entry.rsplit('/').next().unwrap_or(entry)
}
#[must_use]
pub fn find_descriptor<'a>(
descriptors: &'a [DelegateDescriptor],
target_agent_id: &str,
) -> Option<&'a DelegateDescriptor> {
let target = trailing_name(target_agent_id);
descriptors
.iter()
.find(|d| trailing_name(&d.agent_id) == target)
}
#[derive(Debug, Clone)]
pub struct DelegateRequest {
pub call_id: String,
pub target_agent_id: String,
pub task: String,
pub context: Option<String>,
pub result_schema: Option<serde_json::Value>,
}
#[must_use]
pub fn parse_delegate_args(call_id: &str, args_json: &str) -> Option<DelegateRequest> {
let v: serde_json::Value = serde_json::from_str(args_json).ok()?;
let target_agent_id = v.get("target_agent_id")?.as_str()?.to_owned();
if target_agent_id.is_empty() {
return None;
}
let task = v.get("task")?.as_str()?.to_owned();
if task.is_empty() {
return None;
}
let context = v
.get("context")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let result_schema = v.get("result_schema").cloned();
Some(DelegateRequest {
call_id: call_id.to_owned(),
target_agent_id,
task,
context,
result_schema,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn descriptor(agent_id: &str) -> DelegateDescriptor {
DelegateDescriptor {
agent_id: agent_id.to_owned(),
instructions: None,
provider: polyc_llm::into_dyn(polyc_llm::turn::StubProvider),
provider_name: "stub".to_owned(),
model: "stub".to_owned(),
tool_specs: Vec::new(),
max_steps: 4,
}
}
#[test]
fn parses_minimum_required_args() {
let req = parse_delegate_args(
"c-1",
r#"{"target_agent_id":"researcher","task":"find the answer"}"#,
)
.unwrap();
assert_eq!(req.target_agent_id, "researcher");
assert_eq!(req.task, "find the answer");
assert!(req.context.is_none());
assert_eq!(req.call_id, "c-1");
}
#[test]
fn parses_optional_context() {
let req = parse_delegate_args(
"c-2",
r#"{"target_agent_id":"x","task":"t","context":"extra"}"#,
)
.unwrap();
assert_eq!(req.context.as_deref(), Some("extra"));
}
#[test]
fn parses_optional_result_schema() {
let req = parse_delegate_args(
"c-3",
r#"{"target_agent_id":"x","task":"t","result_schema":{"type":"object"}}"#,
)
.unwrap();
assert_eq!(
req.result_schema,
Some(serde_json::json!({"type":"object"}))
);
}
#[test]
fn result_schema_absent_by_default() {
let req = parse_delegate_args("c-4", r#"{"target_agent_id":"x","task":"t"}"#).unwrap();
assert!(req.result_schema.is_none());
}
#[test]
fn rejects_missing_target_agent_id() {
assert!(parse_delegate_args("c", r#"{"task":"t"}"#).is_none());
}
#[test]
fn rejects_empty_target_agent_id() {
assert!(parse_delegate_args("c", r#"{"target_agent_id":"","task":"t"}"#).is_none());
}
#[test]
fn rejects_missing_task() {
assert!(parse_delegate_args("c", r#"{"target_agent_id":"x"}"#).is_none());
}
#[test]
fn rejects_empty_task() {
assert!(parse_delegate_args("c", r#"{"target_agent_id":"x","task":""}"#).is_none());
}
#[test]
fn rejects_garbage_json() {
assert!(parse_delegate_args("c", "not-json").is_none());
}
#[test]
fn delegate_tool_spec_has_required_fields() {
let spec = delegate_tool_spec();
assert_eq!(spec.name, DELEGATE_TOOL_NAME);
let required = spec
.schema_json
.get("required")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
assert!(required.iter().any(|v| v == "target_agent_id"));
assert!(required.iter().any(|v| v == "task"));
}
#[test]
fn find_descriptor_matches_by_trailing_name() {
let descriptors = vec![descriptor("agent:default/researcher"), descriptor("coder")];
assert!(find_descriptor(&descriptors, "researcher").is_some());
assert!(find_descriptor(&descriptors, "agent:other-ns/researcher").is_some());
assert!(find_descriptor(&descriptors, "coder").is_some());
assert!(find_descriptor(&descriptors, "ghost").is_none());
}
}