{"repo":"NousResearch/hermes-agent","public_id":"27197","display_label":"xAI Responses API tool schema pattern/format handling","summary":"These items appear related to xAI Grok's Responses API returning HTTP 400 or streaming fallback errors, and two pull requests propose stripping `pattern`/`format` from Responses-format tool schemas. Titles alone don't show whether the changes are merged or whether the errors are fully explained by that cause.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":27575,"merged_at":"2026-05-17T20:13:23+00:00"},"resolution":{"state":"resolved","by":"pr-27575"},"freshness":{"state":"verified","last_verified_at":"2026-05-17T20:13:23+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":27197,"pr_number":27575,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-17T20:13:23+00:00"}],"members":[{"kind":"issue","number":27197,"title":"[Bug]: xAI Grok (xai-oauth) returns HTTP 400 or streaming fallback errors on Responses API","url":null,"state":"closed"},{"kind":"pr","number":27219,"title":"fix(schema_sanitizer): strip pattern/format from Responses-format tools for xAI compatibility","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#27575","number":27575}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27575,"title":"fix(xai): strip pattern/format from Responses-format tool schemas (closes #27197, #27219)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","tests/tools/test_schema_sanitizer.py","tools/schema_sanitizer.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -286,6 +286,21 @@ def build_api_kwargs(agent, api_messages: list) -> dict:\n         )\n         is_xai_responses = agent.provider in {\"xai\", \"xai-oauth\"} or agent._base_url_hostname == \"api.x.ai\"\n         _msgs_for_codex = agent._prepare_messages_for_non_vision_model(api_messages)\n+\n+        # xAI's /responses endpoint rejects ``pattern`` and ``format`` keywords\n+        # in tool schemas (HTTP 400 \"Invalid arguments passed to the model\").\n+        # Most commonly hit when MCP-derived tools carry JSON Schema validation\n+        # keywords through. Strip them before building kwargs. See #27197.\n+        if is_xai_responses:\n+            try:\n+                from tools.schema_sanitizer import strip_pattern_and_format\n+                tools_for_api, _ = strip_pattern_and_format(tools_for_api)\n+            except Exception as exc:\n+                logger.warning(\n+                    \"%s⚠️ Failed to sanitize tool schemas for xAI: %s\",\n+                    getattr(agent, \"log_prefix\", \"\"), exc,\n+                )\n+\n         return _ct.build_kwargs(\n             model=agent.model,\n             messages=_msgs_for_codex,"]},{"file":"tests/tools/test_schema_sanitizer.py","hunks":["@@ -304,6 +304,30 @@ def test_strip_none_returns_zero():\n     assert stripped == 0\n \n \n+\n+def test_strip_responses_format_strips_format_keyword():\n+    \"\"\"Responses-format:  keyword should be stripped.\"\"\"\n+    from tools.schema_sanitizer import strip_pattern_and_format\n+\n+    tools = [\n+        {\n+            \"name\": \"get_event\",\n+            \"parameters\": {\n+                \"type\": \"object\",\n+                \"properties\": {\n+                    \"ts\": {\"type\": \"string\", \"format\": \"date-time\"},\n+                }\n+            },\n+            \"type\": \"function\"\n+        }\n+    ]\n+\n+    result, stripped = strip_pattern_and_format(tools)\n+    assert stripped == 1, f\"Expected 1 format stripped, got {stripped}\"\n+    assert \"format\" not in result[0][\"parameters\"][\"properties\"][\"ts\"], \"format should be stripped\"\n+    assert result[0][\"parameters\"][\"properties\"][\"ts\"][\"type\"] == \"string\", \"type should be preserved\"\n+\n+\n def test_top_level_allof_stripped_for_codex_backend_compat():\n     \"\"\"OpenAI Codex backend rejects top-level allOf/oneOf/anyOf/enum/not.\"\"\"\n     tools = [_tool(\"memory\", {","@@ -360,3 +384,110 @@ def test_nested_allof_preserved():\n     nested = out[0][\"function\"][\"parameters\"][\"properties\"][\"config\"]\n     assert \"allOf\" in nested\n     assert nested[\"allOf\"] == [{\"required\": [\"mode\"]}]\n+\n+\n+def test_strip_responses_format_tools():\n+    \"\"\"strip_pattern_and_format should handle Responses-format tools (no function wrapper).\"\"\"\n+    from tools.schema_sanitizer import strip_pattern_and_format\n+\n+    # Responses-format: {\"name\": \"...\", \"parameters\": {...}, \"type\": \"function\"}\n+    tools = [\n+        {\n+            \"name\": \"mcp_firecrawl_search\",\n+            \"parameters\": {\n+                \"type\": \"object\",\n+                \"properties\": {\n+                    \"query\": {\"type\": \"string\"},\n+                    \"includeDomains\": {\n+                        \"type\": \"array\",\n+                        \"items\": {\n+                            \"type\": \"string\",\n+                            \"pattern\": \"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$\"\n+                        }\n+                    }\n+                }\n+            },\n+            \"type\": \"function\"\n+        }\n+    ]\n+\n+    result, stripped = strip_pattern_and_format(tools)\n+    assert stripped == 1, f\"Expected 1 pattern stripped, got {stripped}\"\n+    \n+    # Verify pattern keyword was removed from includeDomains\n+    domains = result[0][\"parameters\"][\"properties\"][\"includeDomains\"][\"items\"]\n+    assert \"pattern\" not in domains, f\"pattern should be stripped: {domains}\"\n+    assert domains[\"type\"] == \"string\", \"type should be preserved\"\n+\n+\n+def test_strip_responses_idempotent():\n+    \"\"\"Second call on already-stripped Responses-format tools should return 0.\"\"\"\n+    from tools.schema_sanitizer import strip_pattern_and_format\n+\n+    tools = [\n+        {\n+            \"name\": \"search_files\",\n+            \"parameters\": {\n+                \"type\": \"object\",\n+                \"properties\": {\n+                    \"pattern\": {\"type\": \"string\"}  # This is a property named pattern, NOT schema keyword\n+                }\n+            }\n+        }\n+    ]\n+\n+    # Pass 1 - property named 'pattern' should NOT be stripped\n+    result, first = strip_pattern_and_format(tools)\n+    assert first == 0, f\"Expected 0 stripped (property pattern preserved), got {first}\"\n+    assert \"pattern\" in result[0][\"parameters\"][\"properties\"], \"property named pattern should survive\"\n+    \n+    # Pass 2 - idempotent\n+    _, second = strip_pattern_and_format(tools)\n+    assert second == 0, f\"Expected 0 on second pass, got {second}\"\n+\n+\n+def test_strip_responses_mixed_formats():\n+    \"\"\"Mixed list of OpenAI-format and Responses-format tools should both be sanitized.\"\"\"\n+    from tools.schema_sanitizer import strip_pattern_and_format\n+\n+    tools = [\n+        # OpenAI-format: {\"function\": {\"parameters\": {...}}}\n+        {\n+            \"type\": \"function\",\n+            \"function\": {\n+                \"name\": \"search\",\n+                \"parameters\": {\n+                    \"type\": \"object\",\n+                    \"properties\": {\n+                        \"query\": {\"type\": \"string\", \"pattern\": \"^[a-z]+$\"}\n+                    }\n+                }\n+            }\n+        },\n+        # Responses-format: {\"name\": \"...\", \"parameters\": {...}}\n+        {\n+            \"name\": \"get_time\",\n+            \"parameters\": {\n+                \"type\": \"object\",\n+                \"properties\": {\n+                    \"tz\": {\"type\": \"string\", \"format\": \"date-time\"}\n+                }\n+            },\n+            \"type\": \"function\"\n+        }\n+    ]\n+\n+    result, stripped = strip_pattern_and_format(tools)\n+    assert stripped == 2, f\"Expected 2 stripped (1 pattern + 1 format), got {stripped}\"\n+\n+    # OpenAI-format tool: pattern stripped from parameters\n+    openai_params = result[0][\"function\"][\"parameters\"][\"properties\"][\"query\"]\n+    assert \"pattern\" not in openai_params, f\"pattern should be stripped: {openai_params}\"\n+\n+    # Responses-format tool: format stripped\n+    resp_params = result[1][\"parameters\"][\"properties\"][\"tz\"]\n+    assert \"format\" not in resp_params, f\"format should be stripped: {resp_params}\"\n+\n+    # Verify structure preserved\n+    assert result[0][\"function\"][\"parameters\"][\"type\"] == \"object\"\n+    assert result[1][\"parameters\"][\"type\"] == \"object\""]},{"file":"tools/schema_sanitizer.py","hunks":["@@ -355,11 +355,23 @@ def _walk(node: Any) -> None:\n                 _walk(item)\n \n     for tool in tools:\n-        fn = tool.get(\"function\") if isinstance(tool, dict) else None\n+        if not isinstance(tool, dict):\n+            continue\n+        \n+        # OpenAI-format: {\"function\": {\"parameters\": {...}}}\n+        fn = tool.get(\"function\")\n         if isinstance(fn, dict):\n             params = fn.get(\"parameters\")\n             if isinstance(params, dict):\n                 _walk(params)\n+                continue\n+        \n+        # Responses-format: {\"name\": \"...\", \"parameters\": {...}}\n+        # (used by codex_responses API mode — xAI, OpenAI Codex, etc.)\n+        params = tool.get(\"parameters\")\n+        if isinstance(params, dict):\n+            _walk(params)\n+            continue\n \n     if stripped:\n         logger.info(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}