mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
feat: implement subagent delegation for task management
- Introduced the `delegate_task` tool, allowing the main agent to spawn child AIAgent instances with isolated context for complex tasks. - Supported both single-task and batch processing (up to 3 concurrent tasks) to enhance task management capabilities. - Updated configuration options for delegation, including maximum iterations and default toolsets for subagents. - Enhanced documentation to provide clear guidance on using the delegation feature and its configuration. - Added comprehensive tests to ensure the functionality and reliability of the delegation logic.
This commit is contained in:
parent
c0d412a736
commit
90e5211128
12 changed files with 822 additions and 5 deletions
237
tests/test_delegate.py
Normal file
237
tests/test_delegate.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for the subagent delegation tool.
|
||||
|
||||
Uses mock AIAgent instances to test the delegation logic without
|
||||
requiring API keys or real LLM calls.
|
||||
|
||||
Run with: python -m pytest tests/test_delegate.py -v
|
||||
or: python tests/test_delegate.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from tools.delegate_tool import (
|
||||
DELEGATE_BLOCKED_TOOLS,
|
||||
DELEGATE_TASK_SCHEMA,
|
||||
MAX_CONCURRENT_CHILDREN,
|
||||
MAX_DEPTH,
|
||||
check_delegate_requirements,
|
||||
delegate_task,
|
||||
_build_child_system_prompt,
|
||||
_strip_blocked_tools,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_parent(depth=0):
|
||||
"""Create a mock parent agent with the fields delegate_task expects."""
|
||||
parent = MagicMock()
|
||||
parent.base_url = "https://openrouter.ai/api/v1"
|
||||
parent.model = "anthropic/claude-sonnet-4"
|
||||
parent.platform = "cli"
|
||||
parent.providers_allowed = None
|
||||
parent.providers_ignored = None
|
||||
parent.providers_order = None
|
||||
parent.provider_sort = None
|
||||
parent._session_db = None
|
||||
parent._delegate_depth = depth
|
||||
parent._active_children = []
|
||||
return parent
|
||||
|
||||
|
||||
class TestDelegateRequirements(unittest.TestCase):
|
||||
def test_always_available(self):
|
||||
self.assertTrue(check_delegate_requirements())
|
||||
|
||||
def test_schema_valid(self):
|
||||
self.assertEqual(DELEGATE_TASK_SCHEMA["name"], "delegate_task")
|
||||
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
|
||||
self.assertIn("goal", props)
|
||||
self.assertIn("tasks", props)
|
||||
self.assertIn("context", props)
|
||||
self.assertIn("toolsets", props)
|
||||
self.assertIn("model", props)
|
||||
self.assertIn("max_iterations", props)
|
||||
self.assertEqual(props["tasks"]["maxItems"], 3)
|
||||
|
||||
|
||||
class TestChildSystemPrompt(unittest.TestCase):
|
||||
def test_goal_only(self):
|
||||
prompt = _build_child_system_prompt("Fix the tests")
|
||||
self.assertIn("Fix the tests", prompt)
|
||||
self.assertIn("YOUR TASK", prompt)
|
||||
self.assertNotIn("CONTEXT", prompt)
|
||||
|
||||
def test_goal_with_context(self):
|
||||
prompt = _build_child_system_prompt("Fix the tests", "Error: assertion failed in test_foo.py line 42")
|
||||
self.assertIn("Fix the tests", prompt)
|
||||
self.assertIn("CONTEXT", prompt)
|
||||
self.assertIn("assertion failed", prompt)
|
||||
|
||||
def test_empty_context_ignored(self):
|
||||
prompt = _build_child_system_prompt("Do something", " ")
|
||||
self.assertNotIn("CONTEXT", prompt)
|
||||
|
||||
|
||||
class TestStripBlockedTools(unittest.TestCase):
|
||||
def test_removes_blocked_toolsets(self):
|
||||
result = _strip_blocked_tools(["terminal", "file", "delegation", "clarify", "memory", "code_execution"])
|
||||
self.assertEqual(sorted(result), ["file", "terminal"])
|
||||
|
||||
def test_preserves_allowed_toolsets(self):
|
||||
result = _strip_blocked_tools(["terminal", "file", "web", "browser"])
|
||||
self.assertEqual(sorted(result), ["browser", "file", "terminal", "web"])
|
||||
|
||||
def test_empty_input(self):
|
||||
result = _strip_blocked_tools([])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
class TestDelegateTask(unittest.TestCase):
|
||||
def test_no_parent_agent(self):
|
||||
result = json.loads(delegate_task(goal="test"))
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("parent agent", result["error"])
|
||||
|
||||
def test_depth_limit(self):
|
||||
parent = _make_mock_parent(depth=2)
|
||||
result = json.loads(delegate_task(goal="test", parent_agent=parent))
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("depth limit", result["error"].lower())
|
||||
|
||||
def test_no_goal_or_tasks(self):
|
||||
parent = _make_mock_parent()
|
||||
result = json.loads(delegate_task(parent_agent=parent))
|
||||
self.assertIn("error", result)
|
||||
|
||||
def test_empty_goal(self):
|
||||
parent = _make_mock_parent()
|
||||
result = json.loads(delegate_task(goal=" ", parent_agent=parent))
|
||||
self.assertIn("error", result)
|
||||
|
||||
def test_task_missing_goal(self):
|
||||
parent = _make_mock_parent()
|
||||
result = json.loads(delegate_task(tasks=[{"context": "no goal here"}], parent_agent=parent))
|
||||
self.assertIn("error", result)
|
||||
|
||||
@patch("tools.delegate_tool._run_single_child")
|
||||
def test_single_task_mode(self, mock_run):
|
||||
mock_run.return_value = {
|
||||
"task_index": 0, "status": "completed",
|
||||
"summary": "Done!", "api_calls": 3, "duration_seconds": 5.0
|
||||
}
|
||||
parent = _make_mock_parent()
|
||||
result = json.loads(delegate_task(goal="Fix tests", context="error log...", parent_agent=parent))
|
||||
self.assertIn("results", result)
|
||||
self.assertEqual(len(result["results"]), 1)
|
||||
self.assertEqual(result["results"][0]["status"], "completed")
|
||||
self.assertEqual(result["results"][0]["summary"], "Done!")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("tools.delegate_tool._run_single_child")
|
||||
def test_batch_mode(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
{"task_index": 0, "status": "completed", "summary": "Result A", "api_calls": 2, "duration_seconds": 3.0},
|
||||
{"task_index": 1, "status": "completed", "summary": "Result B", "api_calls": 4, "duration_seconds": 6.0},
|
||||
]
|
||||
parent = _make_mock_parent()
|
||||
tasks = [
|
||||
{"goal": "Research topic A"},
|
||||
{"goal": "Research topic B"},
|
||||
]
|
||||
result = json.loads(delegate_task(tasks=tasks, parent_agent=parent))
|
||||
self.assertIn("results", result)
|
||||
self.assertEqual(len(result["results"]), 2)
|
||||
self.assertEqual(result["results"][0]["summary"], "Result A")
|
||||
self.assertEqual(result["results"][1]["summary"], "Result B")
|
||||
self.assertIn("total_duration_seconds", result)
|
||||
|
||||
@patch("tools.delegate_tool._run_single_child")
|
||||
def test_batch_capped_at_3(self, mock_run):
|
||||
mock_run.return_value = {
|
||||
"task_index": 0, "status": "completed",
|
||||
"summary": "Done", "api_calls": 1, "duration_seconds": 1.0
|
||||
}
|
||||
parent = _make_mock_parent()
|
||||
tasks = [{"goal": f"Task {i}"} for i in range(5)]
|
||||
result = json.loads(delegate_task(tasks=tasks, parent_agent=parent))
|
||||
# Should only run 3 tasks (MAX_CONCURRENT_CHILDREN)
|
||||
self.assertEqual(mock_run.call_count, 3)
|
||||
|
||||
@patch("tools.delegate_tool._run_single_child")
|
||||
def test_batch_ignores_toplevel_goal(self, mock_run):
|
||||
"""When tasks array is provided, top-level goal/context/toolsets are ignored."""
|
||||
mock_run.return_value = {
|
||||
"task_index": 0, "status": "completed",
|
||||
"summary": "Done", "api_calls": 1, "duration_seconds": 1.0
|
||||
}
|
||||
parent = _make_mock_parent()
|
||||
result = json.loads(delegate_task(
|
||||
goal="This should be ignored",
|
||||
tasks=[{"goal": "Actual task"}],
|
||||
parent_agent=parent,
|
||||
))
|
||||
# The mock was called with the tasks array item, not the top-level goal
|
||||
call_args = mock_run.call_args
|
||||
self.assertEqual(call_args.kwargs.get("goal") or call_args[1].get("goal", call_args[0][1] if len(call_args[0]) > 1 else None), "Actual task")
|
||||
|
||||
@patch("tools.delegate_tool._run_single_child")
|
||||
def test_failed_child_included_in_results(self, mock_run):
|
||||
mock_run.return_value = {
|
||||
"task_index": 0, "status": "error",
|
||||
"summary": None, "error": "Something broke",
|
||||
"api_calls": 0, "duration_seconds": 0.5
|
||||
}
|
||||
parent = _make_mock_parent()
|
||||
result = json.loads(delegate_task(goal="Break things", parent_agent=parent))
|
||||
self.assertEqual(result["results"][0]["status"], "error")
|
||||
self.assertIn("Something broke", result["results"][0]["error"])
|
||||
|
||||
def test_depth_increments(self):
|
||||
"""Verify child gets parent's depth + 1."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done", "completed": True, "api_calls": 1
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
delegate_task(goal="Test depth", parent_agent=parent)
|
||||
self.assertEqual(mock_child._delegate_depth, 1)
|
||||
|
||||
def test_active_children_tracking(self):
|
||||
"""Verify children are registered/unregistered for interrupt propagation."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done", "completed": True, "api_calls": 1
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
delegate_task(goal="Test tracking", parent_agent=parent)
|
||||
self.assertEqual(len(parent._active_children), 0)
|
||||
|
||||
|
||||
class TestBlockedTools(unittest.TestCase):
|
||||
def test_blocked_tools_constant(self):
|
||||
for tool in ["delegate_task", "clarify", "memory", "send_message", "execute_code"]:
|
||||
self.assertIn(tool, DELEGATE_BLOCKED_TOOLS)
|
||||
|
||||
def test_constants(self):
|
||||
self.assertEqual(MAX_CONCURRENT_CHILDREN, 3)
|
||||
self.assertEqual(MAX_DEPTH, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue