fix: validate decoded skills shape before use

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)
This commit is contained in:
jinglun010-cpu 2026-07-19 21:59:25 +08:00
parent 730230effb
commit c1a04fe575
2 changed files with 26 additions and 4 deletions

View file

@ -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:

View file

@ -27,12 +27,23 @@ async def load_skill(ctx: RunContextWrapper, skills: str | list[str]) -> str:
``strix/skills/<category>/<name>.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: