Python: Support list[str] arguments for file-based skill scripts (#5850)

Port of .NET PR #5475. Broadens the args type from dict[str, Any] | None
to dict[str, Any] | list[str] | None across the skill script API surface,
enabling CLI-style argv forwarding to subprocess scripts.

Changes:
- SkillScript.run(), InlineSkillScript.run(), FileSkillScript.run(): widen
  args type; InlineSkillScript rejects list with TypeError
- FileSkillScript.parameters_schema: returns array-of-strings schema
- FileSkill.content: appends <scripts> block with parameters_schema
- SkillScriptRunner protocol: widen args type
- SkillsProvider._run_skill_script: widen args type
- run_skill_script tool schema: accept object, array, or null
- subprocess_script_runner sample: accept list[str], reject dict
- class_based_skill sample: fix missing SkillFrontmatter wrapper
- Standardize 'folder' to 'directory' in docstrings (#5712)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-05-14 18:58:10 +01:00
committed by GitHub
Unverified
parent 3256550c55
commit 7432105ebe
4 changed files with 290 additions and 44 deletions
+177 -6
View File
@@ -3518,7 +3518,6 @@ class TestSkillsProviderFactories:
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
args_desc = run_tool.parameters()["properties"]["args"]["description"]
assert "without leading dashes" in args_desc
assert "script implementation or configured runner" in args_desc
async def test_require_script_approval_sets_approval_mode(self) -> None:
@@ -4744,12 +4743,16 @@ class TestCreateScriptElement:
def test_name_only(self) -> None:
s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' <script name="run.py"/>'
assert 'name="run.py"' in elem
assert "<parameters_schema>" in elem
assert '"type": "array"' in elem
def test_with_description(self) -> None:
s = FileSkillScript(name="run.py", description="Execute script.", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert elem == ' <script name="run.py" description="Execute script."/>'
assert 'name="run.py"' in elem
assert 'description="Execute script."' in elem
assert "<parameters_schema>" in elem
def test_xml_escapes_name(self) -> None:
s = FileSkillScript(name='script"special', full_path=f"{_ABS}/test/scripts/s.py")
@@ -4776,10 +4779,12 @@ class TestCreateScriptElement:
assert "query" in elem
assert "&quot;" not in elem
def test_no_parameters_for_file_script(self) -> None:
def test_file_script_includes_array_parameters(self) -> None:
s = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
elem = _create_script_element(s)
assert "<parameters_schema>" not in elem
assert "<parameters_schema>" in elem
assert '"type": "array"' in elem
assert '"type": "string"' in elem
# ---------------------------------------------------------------------------
@@ -4800,7 +4805,7 @@ class TestSkillScriptParametersSchema:
def test_none_for_file_based_script(self) -> None:
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/scripts/run.py")
assert script.parameters_schema is None
assert script.parameters_schema == {"type": "array", "items": {"type": "string"}}
def test_no_params_function_returns_none(self) -> None:
def noop() -> None:
@@ -5407,3 +5412,169 @@ class TestInlineSkillContentCaching:
second = skill.content
assert first is second # Same object (cached)
assert "<name>test-skill</name>" in first
# ---------------------------------------------------------------------------
# Tests: Array-style (list[str]) script arguments
# ---------------------------------------------------------------------------
class TestArrayStyleScriptArgs:
"""Tests for list[str] arguments on skill scripts (port of .NET PR #5475)."""
async def test_inline_script_rejects_list_args(self) -> None:
"""InlineSkillScript.run() raises TypeError when args is a list."""
script = InlineSkillScript(name="greet", function=lambda name="world": f"hello {name}")
skill = InlineSkill(frontmatter=SkillFrontmatter(name="s", description="d"), instructions="c")
with pytest.raises(TypeError, match="requires keyword arguments"):
await script.run(skill, args=["hello", "--name", "Alice"])
async def test_inline_script_error_message_mentions_script_name(self) -> None:
"""The TypeError message includes the script name for debugging."""
script = InlineSkillScript(name="my-script", function=lambda: None)
skill = InlineSkill(frontmatter=SkillFrontmatter(name="s", description="d"), instructions="c")
with pytest.raises(TypeError, match="my-script"):
await script.run(skill, args=["arg1"])
async def test_file_script_passes_list_to_runner(self) -> None:
"""FileSkillScript.run() passes list[str] args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill, args=["input.docx", "--output", "result.idx"])
assert result == "ok"
assert captured["args"] == ["input.docx", "--output", "result.idx"]
async def test_file_script_passes_dict_to_runner(self) -> None:
"""FileSkillScript.run() still passes dict args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill, args={"key": "val"})
assert result == "ok"
assert captured["args"] == {"key": "val"}
async def test_file_script_passes_none_to_runner(self) -> None:
"""FileSkillScript.run() passes None args through to the runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="d"), content="c", path=f"{_ABS}/test"
)
result = await script.run(skill)
assert result == "ok"
assert captured["args"] is None
def test_file_script_parameters_schema_returns_array(self) -> None:
"""FileSkillScript.parameters_schema returns the string-array JSON schema."""
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
assert script.parameters_schema == {"type": "array", "items": {"type": "string"}}
async def test_runner_protocol_accepts_list_args(self) -> None:
"""A runner accepting list[str] args satisfies the SkillScriptRunner protocol."""
captured: dict[str, Any] = {}
def my_runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "ok"
assert isinstance(my_runner, SkillScriptRunner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="s", description="d"), content="c", path=f"{_ABS}/test"
)
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
result = my_runner(skill, script, args=["--flag", "value"])
assert result == "ok"
assert captured["args"] == ["--flag", "value"]
async def test_tool_schema_accepts_array_args(self) -> None:
"""The run_skill_script tool schema accepts array-style args via oneOf."""
skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body")
skill.scripts.append(InlineSkillScript(name="s1", function=lambda: None))
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
args_schema = run_tool.parameters()["properties"]["args"]
assert "oneOf" in args_schema
types = [s.get("type") for s in args_schema["oneOf"]]
assert "object" in types
assert "array" in types
assert "null" in types
async def test_run_skill_script_with_list_args_via_provider(self) -> None:
"""End-to-end: list args flow through provider to file-based script runner."""
captured: dict[str, Any] = {}
def runner(skill: Any, script: Any, args: Any = None) -> str:
captured["args"] = args
return "list_result"
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py", runner=runner)
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="Body",
path=f"{_ABS}/test",
scripts=[script],
)
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="run.py", args=["input.docx", "--verbose"])
assert result == "list_result"
assert captured["args"] == ["input.docx", "--verbose"]
async def test_run_skill_script_inline_with_list_args_returns_error(self) -> None:
"""Inline script called with list args through provider returns error (TypeError caught)."""
skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body")
skill.scripts.append(InlineSkillScript(name="s1", function=lambda: "ok"))
provider = SkillsProvider([skill])
await _init_provider(provider)
run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script")
result = await run_tool.func(skill_name="my-skill", script_name="s1", args=["arg1"])
assert "Error" in result
assert "Failed to run" in result
def test_file_skill_content_includes_scripts_block(self) -> None:
"""FileSkill.content appends a <scripts> block when scripts are present."""
script = FileSkillScript(name="run.py", full_path=f"{_ABS}/test/run.py")
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="---\nname: my-skill\n---\nBody",
path=f"{_ABS}/test",
scripts=[script],
)
assert "<scripts>" in skill.content
assert 'name="run.py"' in skill.content
assert "<parameters_schema>" in skill.content
assert '"type": "array"' in skill.content
def test_file_skill_content_no_scripts_no_block(self) -> None:
"""FileSkill.content does not append a <scripts> block when no scripts."""
skill = FileSkill(
frontmatter=SkillFrontmatter(name="my-skill", description="test"),
content="---\nname: my-skill\n---\nBody",
path=f"{_ABS}/test",
)
assert "<scripts>" not in skill.content