diff --git a/s16code/planner.py b/s16code/planner.py index 22bec6c..5c1a0cc 100644 --- a/s16code/planner.py +++ b/s16code/planner.py @@ -171,6 +171,29 @@ async def plan(self, graph: GraphSnapshot, event: Event) -> GraphPatch: def _parse(self, text: str, graph: GraphSnapshot) -> GraphPatch: data = _json_object(text) + # A few local/open-weight providers label the advertised next frontier + # ``new_tasks`` even after repair. Treat that as a wire alias for + # ``add`` while keeping the strict task and capability validation below. + if "new_tasks" in data and "add" not in data: + data["add"] = data.pop("new_tasks") + additions = data.get("add") + if isinstance(additions, list): + normalized_additions: list[Any] = [] + for index, raw in enumerate(additions, start=1): + if isinstance(raw, dict): + raw = dict(raw) + if "id" not in raw and isinstance(raw.get("node_id"), str): + raw["id"] = raw.pop("node_id") + if "capability" not in raw: + provider_name = raw.get("name") + if isinstance(provider_name, str) and provider_name in self.registry: + raw["capability"] = raw.pop("name") + if "id" not in raw and isinstance(raw.get("capability"), str): + raw["id"] = f"{raw['capability']}_{len(graph.nodes) + index}" + if set(raw).issubset({"id", "capability", "arguments", "depends_on"}): + raw.setdefault("depends_on", []) + normalized_additions.append(raw) + data["add"] = normalized_additions # Normalize the provider-style {"capability": {arguments}} shorthand # for every advertised capability. This is a protocol adapter, not a # task, benchmark, or domain fallback. diff --git a/tests/test_general_planner.py b/tests/test_general_planner.py index bb88def..325337b 100644 --- a/tests/test_general_planner.py +++ b/tests/test_general_planner.py @@ -176,6 +176,52 @@ async def test_task_keyed_provider_json_is_normalized_by_capability_schema(): ("research_github", "researcher"), ("research_gmail", "researcher")] +@pytest.mark.asyncio +async def test_new_tasks_provider_alias_is_normalized_to_add(): + reply = Replies({ + "new_tasks": [{ + "name": "answer_with_evidence", + "arguments": {"query": "Give a concise greeting."}, + }], + "cancel": [], + "finish": False, + "reason": "answer the direct request", + }) + planner = GeneralAgentPlanner( + reply, + default_registry(), + goal="Give a concise greeting.", + review_terminal=False, + ) + patch = await planner.plan(snapshot(), Event(1, "run_started", None, {})) + assert [(task.id, task.skill) for task in patch.add] == [ + ("answer_with_evidence_1", "answer_with_evidence") + ] + + +@pytest.mark.asyncio +async def test_new_tasks_node_id_alias_and_missing_dependencies_are_normalized(): + reply = Replies({ + "new_tasks": [{ + "node_id": "clock", + "capability": "current_datetime", + "arguments": {"timezone": "UTC"}, + }], + "cancel": [], + "finish": False, + "reason": "read the clock", + }) + planner = GeneralAgentPlanner( + reply, + default_registry(), + goal="What time is it?", + review_terminal=False, + ) + patch = await planner.plan(snapshot(), Event(1, "run_started", None, {})) + assert [(task.id, task.skill) for task in patch.add] == [("clock", "current_datetime")] + assert patch.connect == () + + @pytest.mark.asyncio async def test_capability_prefixed_task_key_is_normalized_without_a_channel_table(): reply = Replies({"send_channel_message_3": {