feat: agent-to-agent handoff and delegation

* feat: add agent-to-agent delegation tool

Add `delegate` tool enabling multi-agent workflows where a primary agent
can hand off subtasks to specialized sub-agents with different
provider/model configurations.

- New `DelegateAgentConfig` in config schema with provider, model,
  system_prompt, api_key, temperature, and max_depth fields
- `delegate` tool with recursion depth limits to prevent infinite loops
- Agents configured via `[agents.<name>]` TOML sections
- Sub-agents use `ReliableProvider` with fallback API key support
- Backward-compatible: empty agents map when section is absent

Closes #218

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: encrypt agent API keys and tighten delegation input validation

Address CodeRabbit review comments on PR #224:

1. Agent API key encryption (schema.rs):
   - Config::load_or_init() now decrypts agents.*.api_key via SecretStore
   - Config::save() encrypts plaintext agent API keys before writing
   - Updated doc comment to document encryption behavior
   - Added tests for encrypt-on-save and plaintext-when-disabled

2. Delegation input validation (delegate.rs):
   - Added "additionalProperties": false to schema
   - Added "minLength": 1 for agent and prompt fields
   - Trim agent/prompt/context inputs, reject empty after trim
   - Added tests for blank agent, blank prompt, whitespace trimming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(delegate): replace mutable depth counter with immutable field

- Replace `current_depth: Arc<AtomicU32>` with `depth: u32` set at
  construction time, eliminating TOCTOU race and cancel/panic safety
  issues from fetch_add/fetch_sub pattern
- When sub-agents get their own tool registry, construct via
  `with_depth(agents, key, parent.depth + 1)` for proper propagation
- Add tokio::time::timeout (120s) around provider calls to prevent
  indefinite blocking from misbehaving sub-agent providers
- Rename misleading test whitespace_agent_name_not_found →
  whitespace_agent_name_trimmed_and_found

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix rustfmt formatting issues

Fixed all formatting issues reported by cargo fmt to pass CI lint checks.
- Line length adjustments
- Chain formatting consistency
- Trailing whitespace cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Edvard <ecschoye@stud.ntnu.no>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Argenis 2026-02-15 23:56:42 -05:00 committed by GitHub
parent e04e7191ac
commit c8ca6ff059
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 764 additions and 7 deletions

426
src/tools/delegate.rs Normal file
View file

@ -0,0 +1,426 @@
use super::traits::{Tool, ToolResult};
use crate::config::DelegateAgentConfig;
use crate::providers::{self, Provider};
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
/// Default timeout for sub-agent provider calls.
const DELEGATE_TIMEOUT_SECS: u64 = 120;
/// Tool that delegates a subtask to a named agent with a different
/// provider/model configuration. Enables multi-agent workflows where
/// a primary agent can hand off specialized work (research, coding,
/// summarization) to purpose-built sub-agents.
pub struct DelegateTool {
agents: Arc<HashMap<String, DelegateAgentConfig>>,
/// Global API key fallback (from config.api_key)
fallback_api_key: Option<String>,
/// Depth at which this tool instance lives in the delegation chain.
depth: u32,
}
impl DelegateTool {
pub fn new(
agents: HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<String>,
) -> Self {
Self {
agents: Arc::new(agents),
fallback_api_key,
depth: 0,
}
}
/// Create a DelegateTool for a sub-agent (with incremented depth).
/// When sub-agents eventually get their own tool registry, construct
/// their DelegateTool via this method with `depth: parent.depth + 1`.
pub fn with_depth(
agents: HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<String>,
depth: u32,
) -> Self {
Self {
agents: Arc::new(agents),
fallback_api_key,
depth,
}
}
}
#[async_trait]
impl Tool for DelegateTool {
fn name(&self) -> &str {
"delegate"
}
fn description(&self) -> &str {
"Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \
(e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \
prompt and returns its response."
}
fn parameters_schema(&self) -> serde_json::Value {
let agent_names: Vec<&str> = self.agents.keys().map(|s: &String| s.as_str()).collect();
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"agent": {
"type": "string",
"minLength": 1,
"description": format!(
"Name of the agent to delegate to. Available: {}",
if agent_names.is_empty() {
"(none configured)".to_string()
} else {
agent_names.join(", ")
}
)
},
"prompt": {
"type": "string",
"minLength": 1,
"description": "The task/prompt to send to the sub-agent"
},
"context": {
"type": "string",
"description": "Optional context to prepend (e.g. relevant code, prior findings)"
}
},
"required": ["agent", "prompt"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let agent_name = args
.get("agent")
.and_then(|v| v.as_str())
.map(str::trim)
.ok_or_else(|| anyhow::anyhow!("Missing 'agent' parameter"))?;
if agent_name.is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'agent' parameter must not be empty".into()),
});
}
let prompt = args
.get("prompt")
.and_then(|v| v.as_str())
.map(str::trim)
.ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
if prompt.is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'prompt' parameter must not be empty".into()),
});
}
let context = args
.get("context")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
// Look up agent config
let agent_config = match self.agents.get(agent_name) {
Some(cfg) => cfg,
None => {
let available: Vec<&str> =
self.agents.keys().map(|s: &String| s.as_str()).collect();
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unknown agent '{agent_name}'. Available agents: {}",
if available.is_empty() {
"(none configured)".to_string()
} else {
available.join(", ")
}
)),
});
}
};
// Check recursion depth (immutable — set at construction, incremented for sub-agents)
if self.depth >= agent_config.max_depth {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Delegation depth limit reached ({depth}/{max}). \
Cannot delegate further to prevent infinite loops.",
depth = self.depth,
max = agent_config.max_depth
)),
});
}
// Create provider for this agent
let api_key = agent_config
.api_key
.as_deref()
.or(self.fallback_api_key.as_deref());
let provider: Box<dyn Provider> =
match providers::create_provider(&agent_config.provider, api_key) {
Ok(p) => p,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Failed to create provider '{}' for agent '{agent_name}': {e}",
agent_config.provider
)),
});
}
};
// Build the message
let full_prompt = if context.is_empty() {
prompt.to_string()
} else {
format!("[Context]\n{context}\n\n[Task]\n{prompt}")
};
let temperature = agent_config.temperature.unwrap_or(0.7);
// Wrap the provider call in a timeout to prevent indefinite blocking
let result = tokio::time::timeout(
Duration::from_secs(DELEGATE_TIMEOUT_SECS),
provider.chat_with_system(
agent_config.system_prompt.as_deref(),
&full_prompt,
&agent_config.model,
temperature,
),
)
.await;
let result = match result {
Ok(inner) => inner,
Err(_elapsed) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Agent '{agent_name}' timed out after {DELEGATE_TIMEOUT_SECS}s"
)),
});
}
};
match result {
Ok(response) => Ok(ToolResult {
success: true,
output: format!(
"[Agent '{agent_name}' ({provider}/{model})]\n{response}",
provider = agent_config.provider,
model = agent_config.model
),
error: None,
}),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Agent '{agent_name}' failed: {e}",)),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
let mut agents = HashMap::new();
agents.insert(
"researcher".to_string(),
DelegateAgentConfig {
provider: "ollama".to_string(),
model: "llama3".to_string(),
system_prompt: Some("You are a research assistant.".to_string()),
api_key: None,
temperature: Some(0.3),
max_depth: 3,
},
);
agents.insert(
"coder".to_string(),
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "anthropic/claude-sonnet-4-20250514".to_string(),
system_prompt: None,
api_key: Some("sk-test".to_string()),
temperature: None,
max_depth: 2,
},
);
agents
}
#[test]
fn name_and_schema() {
let tool = DelegateTool::new(sample_agents(), None);
assert_eq!(tool.name(), "delegate");
let schema = tool.parameters_schema();
assert!(schema["properties"]["agent"].is_object());
assert!(schema["properties"]["prompt"].is_object());
assert!(schema["properties"]["context"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("agent")));
assert!(required.contains(&json!("prompt")));
assert_eq!(schema["additionalProperties"], json!(false));
assert_eq!(schema["properties"]["agent"]["minLength"], json!(1));
assert_eq!(schema["properties"]["prompt"]["minLength"], json!(1));
}
#[test]
fn description_not_empty() {
let tool = DelegateTool::new(sample_agents(), None);
assert!(!tool.description().is_empty());
}
#[test]
fn schema_lists_agent_names() {
let tool = DelegateTool::new(sample_agents(), None);
let schema = tool.parameters_schema();
let desc = schema["properties"]["agent"]["description"]
.as_str()
.unwrap();
assert!(desc.contains("researcher") || desc.contains("coder"));
}
#[tokio::test]
async fn missing_agent_param() {
let tool = DelegateTool::new(sample_agents(), None);
let result = tool.execute(json!({"prompt": "test"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn missing_prompt_param() {
let tool = DelegateTool::new(sample_agents(), None);
let result = tool.execute(json!({"agent": "researcher"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn unknown_agent_returns_error() {
let tool = DelegateTool::new(sample_agents(), None);
let result = tool
.execute(json!({"agent": "nonexistent", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Unknown agent"));
}
#[tokio::test]
async fn depth_limit_enforced() {
let tool = DelegateTool::with_depth(sample_agents(), None, 3);
let result = tool
.execute(json!({"agent": "researcher", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("depth limit"));
}
#[tokio::test]
async fn depth_limit_per_agent() {
// coder has max_depth=2, so depth=2 should be blocked
let tool = DelegateTool::with_depth(sample_agents(), None, 2);
let result = tool
.execute(json!({"agent": "coder", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("depth limit"));
}
#[test]
fn empty_agents_schema() {
let tool = DelegateTool::new(HashMap::new(), None);
let schema = tool.parameters_schema();
let desc = schema["properties"]["agent"]["description"]
.as_str()
.unwrap();
assert!(desc.contains("none configured"));
}
#[tokio::test]
async fn invalid_provider_returns_error() {
let mut agents = HashMap::new();
agents.insert(
"broken".to_string(),
DelegateAgentConfig {
provider: "totally-invalid-provider".to_string(),
model: "model".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
},
);
let tool = DelegateTool::new(agents, None);
let result = tool
.execute(json!({"agent": "broken", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Failed to create provider"));
}
#[tokio::test]
async fn blank_agent_rejected() {
let tool = DelegateTool::new(sample_agents(), None);
let result = tool
.execute(json!({"agent": " ", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("must not be empty"));
}
#[tokio::test]
async fn blank_prompt_rejected() {
let tool = DelegateTool::new(sample_agents(), None);
let result = tool
.execute(json!({"agent": "researcher", "prompt": " \t "}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("must not be empty"));
}
#[tokio::test]
async fn whitespace_agent_name_trimmed_and_found() {
let tool = DelegateTool::new(sample_agents(), None);
// " researcher " with surrounding whitespace — after trim becomes "researcher"
let result = tool
.execute(json!({"agent": " researcher ", "prompt": "test"}))
.await
.unwrap();
// Should find "researcher" after trim — will fail at provider level
// since ollama isn't running, but must NOT get "Unknown agent".
assert!(
result.error.is_none()
|| !result
.error
.as_deref()
.unwrap_or("")
.contains("Unknown agent")
);
}
}