mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-26 06:01:49 +00:00
fix(delegate): accept JSON string batch tasks
Recover delegate_task batch inputs when open-weight models emit tasks as a JSON-encoded array string, and return clear errors for malformed task lists. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4632be123d
commit
326ca754ad
2 changed files with 90 additions and 0 deletions
|
|
@ -167,6 +167,63 @@ class TestDelegateTask(unittest.TestCase):
|
||||||
self.assertEqual(result["results"][1]["summary"], "Result B")
|
self.assertEqual(result["results"][1]["summary"], "Result B")
|
||||||
self.assertIn("total_duration_seconds", result)
|
self.assertIn("total_duration_seconds", result)
|
||||||
|
|
||||||
|
@patch("tools.delegate_tool._run_single_child")
|
||||||
|
def test_batch_mode_accepts_json_string_tasks(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 = json.dumps(
|
||||||
|
[
|
||||||
|
{"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")
|
||||||
|
|
||||||
|
@patch("tools.delegate_tool._run_single_child")
|
||||||
|
def test_batch_mode_rejects_non_object_tasks(self, mock_run):
|
||||||
|
parent = _make_mock_parent()
|
||||||
|
|
||||||
|
result = json.loads(
|
||||||
|
delegate_task(tasks=["not a task object"], parent_agent=parent)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("error", result)
|
||||||
|
self.assertIn("Task 0 must be an object", result["error"])
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
@patch("tools.delegate_tool._run_single_child")
|
||||||
|
def test_batch_mode_rejects_malformed_json_string_tasks(self, mock_run):
|
||||||
|
parent = _make_mock_parent()
|
||||||
|
|
||||||
|
result = json.loads(
|
||||||
|
delegate_task(tasks='[{"goal": "bad}', parent_agent=parent)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("error", result)
|
||||||
|
self.assertIn("could not be parsed as JSON", result["error"])
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
@patch("tools.delegate_tool._run_single_child")
|
@patch("tools.delegate_tool._run_single_child")
|
||||||
def test_batch_capped_at_3(self, mock_run):
|
def test_batch_capped_at_3(self, mock_run):
|
||||||
mock_run.return_value = {
|
mock_run.return_value = {
|
||||||
|
|
|
||||||
|
|
@ -1867,6 +1867,29 @@ def _run_single_child(
|
||||||
logger.debug("Failed to close child agent after delegation")
|
logger.debug("Failed to close child agent after delegation")
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_tasks_from_json_string(
|
||||||
|
tasks: Any,
|
||||||
|
) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
|
||||||
|
if not isinstance(tasks, str):
|
||||||
|
return None, None
|
||||||
|
raw = tasks.strip()
|
||||||
|
if not raw:
|
||||||
|
return None, "Provide either 'goal' (single task) or 'tasks' (batch)."
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return None, (
|
||||||
|
"tasks must be a JSON array of task objects; received a string "
|
||||||
|
f"that could not be parsed as JSON ({exc.msg})."
|
||||||
|
)
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return None, (
|
||||||
|
f"tasks must be a JSON array of task objects; parsed "
|
||||||
|
f"{type(parsed).__name__} instead."
|
||||||
|
)
|
||||||
|
return parsed, None
|
||||||
|
|
||||||
|
|
||||||
def delegate_task(
|
def delegate_task(
|
||||||
goal: Optional[str] = None,
|
goal: Optional[str] = None,
|
||||||
context: Optional[str] = None,
|
context: Optional[str] = None,
|
||||||
|
|
@ -1951,6 +1974,12 @@ def delegate_task(
|
||||||
|
|
||||||
# Normalize to task list
|
# Normalize to task list
|
||||||
max_children = _get_max_concurrent_children()
|
max_children = _get_max_concurrent_children()
|
||||||
|
recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks)
|
||||||
|
if tasks_error:
|
||||||
|
return tool_error(tasks_error)
|
||||||
|
if recovered_tasks is not None:
|
||||||
|
tasks = recovered_tasks
|
||||||
|
|
||||||
if tasks and isinstance(tasks, list):
|
if tasks and isinstance(tasks, list):
|
||||||
if len(tasks) > max_children:
|
if len(tasks) > max_children:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
|
|
@ -1973,6 +2002,10 @@ def delegate_task(
|
||||||
|
|
||||||
# Validate each task has a goal
|
# Validate each task has a goal
|
||||||
for i, task in enumerate(task_list):
|
for i, task in enumerate(task_list):
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
return tool_error(
|
||||||
|
f"Task {i} must be an object, got {type(task).__name__}."
|
||||||
|
)
|
||||||
if not task.get("goal", "").strip():
|
if not task.get("goal", "").strip():
|
||||||
return tool_error(f"Task {i} is missing a 'goal'.")
|
return tool_error(f"Task {i} is missing a 'goal'.")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue