From 326ca754ad780d1ba22b51970210a9631a3d7196 Mon Sep 17 00:00:00 2001 From: Bartok Date: Fri, 8 May 2026 12:30:08 -0400 Subject: [PATCH] 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 --- tests/tools/test_delegate.py | 57 ++++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 33 +++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 3a6df2bcf41..8a3efe8eeef 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -167,6 +167,63 @@ class TestDelegateTask(unittest.TestCase): self.assertEqual(result["results"][1]["summary"], "Result B") 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") def test_batch_capped_at_3(self, mock_run): mock_run.return_value = { diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 5a1ec534f82..3856ce77662 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1867,6 +1867,29 @@ def _run_single_child( 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( goal: Optional[str] = None, context: Optional[str] = None, @@ -1951,6 +1974,12 @@ def delegate_task( # Normalize to task list 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 len(tasks) > max_children: return tool_error( @@ -1973,6 +2002,10 @@ def delegate_task( # Validate each task has a goal 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(): return tool_error(f"Task {i} is missing a 'goal'.")