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

View file

@ -1,6 +1,7 @@
pub mod browser;
pub mod browser_open;
pub mod composio;
pub mod delegate;
pub mod file_read;
pub mod file_write;
pub mod image_info;
@ -14,6 +15,7 @@ pub mod traits;
pub use browser::BrowserTool;
pub use browser_open::BrowserOpenTool;
pub use composio::ComposioTool;
pub use delegate::DelegateTool;
pub use file_read::FileReadTool;
pub use file_write::FileWriteTool;
pub use image_info::ImageInfoTool;
@ -26,9 +28,11 @@ pub use traits::Tool;
#[allow(unused_imports)]
pub use traits::{ToolResult, ToolSpec};
use crate::config::DelegateAgentConfig;
use crate::memory::Memory;
use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::SecurityPolicy;
use std::collections::HashMap;
use std::sync::Arc;
/// Create the default tool registry
@ -54,6 +58,8 @@ pub fn all_tools(
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
browser_config: &crate::config::BrowserConfig,
agents: &HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<&str>,
) -> Vec<Box<dyn Tool>> {
all_tools_with_runtime(
security,
@ -61,6 +67,8 @@ pub fn all_tools(
memory,
composio_key,
browser_config,
agents,
fallback_api_key,
)
}
@ -71,6 +79,8 @@ pub fn all_tools_with_runtime(
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
browser_config: &crate::config::BrowserConfig,
agents: &HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<&str>,
) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = vec![
Box::new(ShellTool::new(security.clone(), runtime)),
@ -105,6 +115,14 @@ pub fn all_tools_with_runtime(
}
}
// Add delegation tool when agents are configured
if !agents.is_empty() {
tools.push(Box::new(DelegateTool::new(
agents.clone(),
fallback_api_key.map(String::from),
)));
}
tools
}
@ -138,7 +156,7 @@ mod tests {
session_name: None,
};
let tools = all_tools(&security, mem, None, &browser);
let tools = all_tools(&security, mem, None, &browser, &HashMap::new(), None);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"browser_open"));
}
@ -160,7 +178,7 @@ mod tests {
session_name: None,
};
let tools = all_tools(&security, mem, None, &browser);
let tools = all_tools(&security, mem, None, &browser, &HashMap::new(), None);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"browser_open"));
}
@ -258,4 +276,53 @@ mod tests {
assert_eq!(parsed.name, "test");
assert_eq!(parsed.description, "A test tool");
}
#[test]
fn all_tools_includes_delegate_when_agents_configured() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let mut agents = HashMap::new();
agents.insert(
"researcher".to_string(),
DelegateAgentConfig {
provider: "ollama".to_string(),
model: "llama3".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
},
);
let tools = all_tools(&security, mem, None, &browser, &agents, Some("sk-test"));
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"delegate"));
}
#[test]
fn all_tools_excludes_delegate_when_no_agents() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let tools = all_tools(&security, mem, None, &browser, &HashMap::new(), None);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"delegate"));
}
}