fix(agents): ensure properties key exists alongside required in tool schemas

Groq's API rejects tool schemas where 'required' is present but
'properties' is missing with a 400 BadRequestError.  This happens for
zero-parameter tools like view_agent_graph (only ctx: RunContextWrapper)
where certain openai-agents/pydantic/litellm version combinations
produce a schema with required: [] but no properties key.

Add _ensure_properties_in_schema() to the tool processing pipeline in
factory.py.  When a FunctionTool's params_json_schema has 'required'
without 'properties', inject an empty properties: {} dict.  The fix is
applied to all FunctionTools so future zero-parameter tools are covered.

Fixes #1088
This commit is contained in:
Varun Sahni 2026-08-17 05:25:04 +05:30 • committed by Varun Sahni
parent 8551339130
commit cd1f161e9b
2 changed files with 149 additions and 1 deletions

View file

@ -143,6 +143,27 @@ def _with_bounded_result(tool: FunctionTool) -> FunctionTool:
return tool
def _ensure_properties_in_schema(tool: FunctionTool) -> FunctionTool:
"""Guarantee ``properties`` exists alongside ``required`` in the tool schema.
Some provider SDKs (e.g. older openai-agents / pydantic combinations) or
LiteLLM's schema normalisation can produce a JSON schema with a
``required`` key but no ``properties`` key for tools that take no
user-facing arguments (only ``ctx: RunContextWrapper``). Strict
validators like Groq's API reject this with a 400::
'required' present but 'properties' is missing
Adding an empty ``properties: {}`` satisfies the constraint without
changing the tool's semantics. The fix is applied to *every*
FunctionTool so future zero-parameter tools are covered too.
"""
schema = tool.params_json_schema
if isinstance(schema, dict) and "required" in schema and "properties" not in schema:
tool.params_json_schema = {**schema, "properties": {}}
return tool
def _schema_types(spec: dict[str, Any]) -> set[str]:
types: set[str] = set()
raw = spec.get("type")
@ -604,7 +625,9 @@ def build_strix_agent(
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
_ensure_unique_tool_names(tools)
tools = [
_with_bounded_result(_with_coerced_arguments(tool))
_ensure_properties_in_schema(
_with_bounded_result(_with_coerced_arguments(tool))
)
if isinstance(tool, FunctionTool)
else tool
for tool in tools

View file

@ -0,0 +1,125 @@
"""Tests for _ensure_properties_in_schema in the agent factory.
Regression test for issue #1088: Groq rejects tool schemas that have a
``required`` key without a corresponding ``properties`` key. The fix
adds an empty ``properties: {}`` whenever ``required`` is present but
``properties`` is missing.
"""
from __future__ import annotations
from typing import Any
from agents.tool import FunctionTool
from strix.agents import factory
def _make_tool(schema: dict[str, Any]) -> FunctionTool:
async def invoke(_ctx: Any, _raw_input: str) -> str:
return "ok"
return FunctionTool(
name="test_tool",
description="test",
params_json_schema=schema,
on_invoke_tool=invoke,
)
class TestEnsurePropertiesInSchema:
def test_adds_properties_when_required_present_but_missing(self) -> None:
"""Schema with 'required' but no 'properties' gets properties: {} added."""
tool = _make_tool({"type": "object", "required": []})
result = factory._ensure_properties_in_schema(tool)
assert "properties" in result.params_json_schema
assert result.params_json_schema["properties"] == {}
assert result.params_json_schema["required"] == []
def test_adds_properties_when_required_has_items(self) -> None:
"""Schema with non-empty required and no properties still gets fixed."""
tool = _make_tool({"type": "object", "required": ["name", "value"]})
result = factory._ensure_properties_in_schema(tool)
assert "properties" in result.params_json_schema
assert result.params_json_schema["properties"] == {}
def test_preserves_existing_properties(self) -> None:
"""Schema that already has 'properties' is left unchanged."""
schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
"additionalProperties": False,
}
tool = _make_tool(schema)
result = factory._ensure_properties_in_schema(tool)
assert result.params_json_schema == schema
def test_noop_when_neither_required_nor_properties(self) -> None:
"""Schema with neither 'required' nor 'properties' is untouched."""
schema = {"type": "object"}
tool = _make_tool(schema)
result = factory._ensure_properties_in_schema(tool)
assert "properties" not in result.params_json_schema
assert "required" not in result.params_json_schema
def test_noop_when_properties_present_without_required(self) -> None:
"""Schema with 'properties' but no 'required' is not modified by the fix.
Note: FunctionTool.__post_init__ applies ensure_strict_json_schema which
may add 'required' and 'additionalProperties'. Our fix only triggers when
'required' is present and 'properties' is absent, so a schema that already
has 'properties' is never touched by _ensure_properties_in_schema.
"""
tool = _make_tool({"type": "object", "properties": {"opt": {"type": "string"}}})
# FunctionTool.__post_init__ adds required + additionalProperties
schema_before = dict(tool.params_json_schema)
result = factory._ensure_properties_in_schema(tool)
# The fix should NOT have modified the schema (properties already present)
assert result.params_json_schema == schema_before
def test_simulates_view_agent_graph_scenario(self) -> None:
"""Reproduce the exact scenario from issue #1088.
view_agent_graph takes only ctx: RunContextWrapper, so its
generated schema may end up with required: [] and no properties.
"""
# This is what the schema might look like after SDK processing
# on certain openai-agents / pydantic / litellm version combos
broken_schema = {
"type": "object",
"required": [],
"additionalProperties": False,
}
tool = _make_tool(broken_schema)
result = factory._ensure_properties_in_schema(tool)
# After fix: both 'required' and 'properties' are present
schema = result.params_json_schema
assert "properties" in schema
assert "required" in schema
assert schema["properties"] == {}
assert schema["required"] == []
assert schema["additionalProperties"] is False
def test_other_keys_preserved(self) -> None:
"""All other schema keys are preserved when adding properties."""
broken_schema = {
"type": "object",
"required": [],
"additionalProperties": False,
"title": "view_agent_graph_args",
}
tool = _make_tool(broken_schema)
result = factory._ensure_properties_in_schema(tool)
schema = result.params_json_schema
assert schema["type"] == "object"
assert schema["additionalProperties"] is False
assert schema["title"] == "view_agent_graph_args"
assert schema["properties"] == {}