From 730230effb348cb43b3f580b2d46331c9bc3558d Mon Sep 17 00:00:00 2001 From: jinglun010 Date: Wed, 15 Jul 2026 14:07:11 +0800 Subject: [PATCH 1/2] fix: tolerate string-encoded skills param for LLM provider compatibility Some LLM providers (e.g. qwen3.7-max via DashScope OpenAI-compatible endpoint) serialize array-typed function call parameters as JSON-encoded strings instead of native JSON arrays. This causes Pydantic validation to reject the skills parameter before the function body executes, making every create_agent and load_skill call fail. Changes: - Widen skills type annotation: list[str] -> str | list[str] - Add strict_mode=False to @function_tool decorator - Parse string inputs in function body (json.loads with comma-split fallback) Tested with qwen3.7-max: create_agent successfully spawns child agents that execute 23+ exec_command calls for SQL injection testing. Closes #770 --- strix/tools/agents_graph/tools.py | 10 ++++++++-- strix/tools/load_skill/tool.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index a72b311c3..553d094fa 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -350,13 +350,13 @@ async def wait_for_message( # noqa: PLR0911 ) -@function_tool(timeout=120) +@function_tool(timeout=120, strict_mode=False) async def create_agent( ctx: RunContextWrapper, name: str, task: str, inherit_context: bool = True, - skills: list[str] | None = None, + skills: str | list[str] | None = None, ) -> str: """Spawn a specialist child agent to run in parallel. @@ -419,6 +419,12 @@ async def create_agent( default=str, ) + # Tolerate LLM providers that pass array params as JSON-encoded strings + if isinstance(skills, str): + try: + skills = json.loads(skills) + except json.JSONDecodeError: + skills = [s.strip() for s in skills.split(",") if s.strip()] skill_list = list(skills or []) skill_error = validate_requested_skills(skill_list) if skill_error: diff --git a/strix/tools/load_skill/tool.py b/strix/tools/load_skill/tool.py index 3ef6f9ec0..f5e98b774 100644 --- a/strix/tools/load_skill/tool.py +++ b/strix/tools/load_skill/tool.py @@ -2,13 +2,15 @@ from __future__ import annotations +import json + from agents import RunContextWrapper, function_tool from strix.skills import load_skills, validate_requested_skills -@function_tool(timeout=10) -async def load_skill(ctx: RunContextWrapper, skills: list[str]) -> str: +@function_tool(timeout=10, strict_mode=False) +async def load_skill(ctx: RunContextWrapper, skills: str | list[str]) -> str: """Return the markdown body of one or more skills as reference material. Use this when you need exact syntax / workflow / payload guidance @@ -25,6 +27,12 @@ async def load_skill(ctx: RunContextWrapper, skills: list[str]) -> str: ``strix/skills//.md``. """ del ctx + # Tolerate LLM providers that pass array params as JSON-encoded strings + if isinstance(skills, str): + try: + skills = json.loads(skills) + except json.JSONDecodeError: + skills = [s.strip() for s in skills.split(",") if s.strip()] requested = list(skills or []) err = validate_requested_skills(requested) if err: From c1a04fe575fae3d09dffee194e94df133c306b40 Mon Sep 17 00:00:00 2001 From: jinglun010-cpu Date: Sun, 19 Jul 2026 21:59:25 +0800 Subject: [PATCH 2/2] fix: validate decoded skills shape before use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review on #771 flagged that the previous fix accepted any valid JSON, not just string arrays. A JSON object would be silently converted to its keys, an int array would trip downstream validation, and a JSON scalar would be split into characters. Apply the suggested shape-validation fallback: after json.loads, only accept str (wrap in list) or list[str]; otherwise fall back to treating the original string as a single skill name. This keeps the qwen-style JSON-encoded array path working while hardening the other shapes. Cases now handled: '["a", "b"]' -> ['a', 'b'] (target case, unchanged) 'a, b' -> ['a', 'b'] (comma fallback, unchanged) '"a"' -> ['a'] (JSON string scalar) '{"a": 1}' -> ['{"a": 1}'] (was: ['a'] — keys leaked) '[1, 2, 3]' -> ['[1, 2, 3]'] (was: ints passed downstream) '["a", 1, "b"]' -> ['["a", 1, "b"]'] (was: mixed types downstream) --- strix/tools/agents_graph/tools.py | 15 +++++++++++++-- strix/tools/load_skill/tool.py | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index 553d094fa..28a8c622f 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -419,12 +419,23 @@ async def create_agent( default=str, ) - # Tolerate LLM providers that pass array params as JSON-encoded strings + # Tolerate LLM providers that pass array params as JSON-encoded strings. + # Validate decoded shape: reject anything that isn't a list of strings. if isinstance(skills, str): + original_skills = skills try: - skills = json.loads(skills) + decoded_skills = json.loads(skills) except json.JSONDecodeError: skills = [s.strip() for s in skills.split(",") if s.strip()] + else: + if isinstance(decoded_skills, str): + skills = [decoded_skills] + elif isinstance(decoded_skills, list) and all( + isinstance(skill, str) for skill in decoded_skills + ): + skills = decoded_skills + else: + skills = [original_skills] skill_list = list(skills or []) skill_error = validate_requested_skills(skill_list) if skill_error: diff --git a/strix/tools/load_skill/tool.py b/strix/tools/load_skill/tool.py index f5e98b774..b3c7e413e 100644 --- a/strix/tools/load_skill/tool.py +++ b/strix/tools/load_skill/tool.py @@ -27,12 +27,23 @@ async def load_skill(ctx: RunContextWrapper, skills: str | list[str]) -> str: ``strix/skills//.md``. """ del ctx - # Tolerate LLM providers that pass array params as JSON-encoded strings + # Tolerate LLM providers that pass array params as JSON-encoded strings. + # Validate decoded shape: reject anything that isn't a list of strings. if isinstance(skills, str): + original_skills = skills try: - skills = json.loads(skills) + decoded_skills = json.loads(skills) except json.JSONDecodeError: skills = [s.strip() for s in skills.split(",") if s.strip()] + else: + if isinstance(decoded_skills, str): + skills = [decoded_skills] + elif isinstance(decoded_skills, list) and all( + isinstance(skill, str) for skill in decoded_skills + ): + skills = decoded_skills + else: + skills = [original_skills] requested = list(skills or []) err = validate_requested_skills(requested) if err: