diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index a26c1dc4..0aabc780 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -114,6 +114,28 @@ def _render_completion_report( return "\n".join(lines) +def _groq_compatible_empty_object_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Drop the empty ``required``/``properties`` pair Groq rejects. + + A no-argument strict schema is ``{"type": "object", "properties": {}, + "required": []}``. Groq's tool validator treats a present ``required`` + with no ``properties`` as invalid (``required is present but properties + is missing``) and rejects the whole request. Omitting both keys leaves an + object that accepts no arguments: ``additionalProperties`` stays false, so + extra arguments are still rejected, and tools that declare real parameters + keep their ``required`` lists. + """ + properties = schema.get("properties") + required = schema.get("required") + if properties or required: + return schema + if properties is not None and not isinstance(properties, dict): + return schema + if required is not None and not isinstance(required, list): + return schema + return {key: value for key, value in schema.items() if key not in {"properties", "required"}} + + @function_tool(timeout=30) async def view_agent_graph(ctx: RunContextWrapper) -> str: """Print the multi-agent tree — every agent, its parent, its status. @@ -166,6 +188,11 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str: ) +view_agent_graph.params_json_schema = _groq_compatible_empty_object_schema( + view_agent_graph.params_json_schema +) + + @function_tool(timeout=30) async def send_message_to_agent( ctx: RunContextWrapper, diff --git a/tests/test_view_agent_graph_schema.py b/tests/test_view_agent_graph_schema.py new file mode 100644 index 00000000..e5749b2b --- /dev/null +++ b/tests/test_view_agent_graph_schema.py @@ -0,0 +1,108 @@ +"""Groq rejects a no-argument tool schema that lists ``required`` without properties. + +``view_agent_graph`` takes only the run context, so its generated schema is an +empty object. Groq's validator fails that shape with ``required is present but +properties is missing`` and drops the whole request. The published schema must +omit that pair without turning strict validation off for tools that do take +arguments. +""" + +from __future__ import annotations + +import dataclasses +import json +from typing import Any, cast + +import pytest +from agents.models.chatcmpl_converter import Converter +from agents.tool import FunctionTool +from agents.tool_context import ToolContext + +from strix.agents.factory import build_strix_agent +from strix.core.agents import AgentCoordinator +from strix.tools.agents_graph.tools import ( + _groq_compatible_empty_object_schema, + send_message_to_agent, + view_agent_graph, +) + + +def test_view_agent_graph_schema_omits_empty_required_and_properties() -> None: + schema = view_agent_graph.params_json_schema + + assert view_agent_graph.strict_json_schema is True + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + assert "required" not in schema + assert "properties" not in schema + + +def test_published_chat_completions_schema_has_no_empty_required() -> None: + """The schema Groq receives is the chat-completions function parameters.""" + parameters = Converter.tool_to_openai(view_agent_graph)["function"]["parameters"] + + assert parameters["type"] == "object" + assert parameters["additionalProperties"] is False + assert "required" not in parameters + assert "properties" not in parameters + assert Converter.tool_to_openai(view_agent_graph)["function"]["strict"] is True + + +def test_argument_tools_keep_required_properties() -> None: + schema = send_message_to_agent.params_json_schema + + assert send_message_to_agent.strict_json_schema is True + assert "target_agent_id" in schema["properties"] + assert "target_agent_id" in schema["required"] + assert "message" in schema["required"] + + +def test_groq_schema_fix_does_not_strip_declared_parameters() -> None: + original = { + "type": "object", + "properties": {"target_agent_id": {"type": "string"}}, + "required": ["target_agent_id"], + "additionalProperties": False, + } + + assert _groq_compatible_empty_object_schema(original) == original + # A required list with no properties is not the empty no-arg shape; leave + # it alone rather than dropping a constraint this helper does not own. + mismatched = {"type": "object", "required": ["target_agent_id"]} + assert _groq_compatible_empty_object_schema(mismatched) is mismatched + + +def test_agent_copy_keeps_the_groq_compatible_schema() -> None: + agent = build_strix_agent(is_root=True) + tool = next(t for t in agent.tools if t.name == "view_agent_graph") + assert isinstance(tool, FunctionTool) + + assert tool.strict_json_schema is True + assert "required" not in tool.params_json_schema + assert "properties" not in tool.params_json_schema + # Disabling strict for another route copies the tool; that copy must not + # reintroduce the rejected empty required/properties pair. + copied = dataclasses.replace(tool, strict_json_schema=False) + assert "required" not in copied.params_json_schema + assert "properties" not in copied.params_json_schema + + +@pytest.mark.asyncio +async def test_view_agent_graph_still_returns_the_tree() -> None: + coordinator = AgentCoordinator() + await coordinator.register("root", "strix", parent_id=None) + await coordinator.register("child", "Validator", parent_id="root") + ctx = ToolContext( + context={"coordinator": coordinator, "agent_id": "root"}, + tool_name=view_agent_graph.name, + tool_call_id="call-1", + tool_arguments="{}", + ) + + result = cast("dict[str, Any]", json.loads(await view_agent_graph.on_invoke_tool(ctx, "{}"))) + + assert result["success"] is True + assert "strix (root)" in result["graph_structure"] + assert "Validator (child)" in result["graph_structure"] + assert "← you" in result["graph_structure"] + assert result["summary"]["total"] == 2