# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors # # SPDX-License-Identifier: Apache-2.0 import json import sys import threading from collections.abc import AsyncGenerator, Sequence from contextlib import asynccontextmanager from dataclasses import asdict from pathlib import Path, PurePosixPath from unittest.mock import patch import pytest from dirty_equals import IsPartialDict from ag2 import Context, TextInput from ag2.events import ToolCallEvent, ToolErrorEvent from ag2.exceptions import SkillNotFoundError from ag2.tools import SkillsToolkit from ag2.tools.sandbox import ExecResult, Sandbox from ag2.tools.sandbox.adapter import ShellAdapter from ag2.tools.sandbox.local import LocalSandbox from ag2.tools.skills import LocalRuntime, MemoryRuntime, MemorySkill from ag2.tools.skills.runtime.local.loader import SkillLoader def _write_script_skill(base: Path, name: str, script_body: str | None = None) -> Path: """Write a skill with an optional ``scripts/go.sh`` and return *base*.""" skill_dir = base / name skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {name}\n---\n# {name}\n") if script_body is not None: (skill_dir / "scripts").mkdir() (skill_dir / "scripts" / "go.sh").write_text(script_body) return base def _symlink_or_skip(link: Path, target: Path) -> None: """Create ``link`` -> ``target``, skipping the test where symlinks are unsupported.""" try: link.symlink_to(target) except (OSError, NotImplementedError): # pragma: no cover - e.g. Windows without privilege pytest.skip("symlinks are not supported on this platform") @pytest.mark.skipif(sys.platform != "win32", reason="needs a real Windows interpreter lookup") @pytest.mark.parametrize("shebang", ["", "#!/usr/bin/env python3\n"]) @pytest.mark.asyncio async def test_local_python_script_on_windows(tmp_path: Path, context: Context, shebang: str) -> None: base = _write_script_skill(tmp_path, "python-skill") scripts = base / "python-skill" / "scripts" scripts.mkdir() (scripts / "hello.py").write_text(shebang + "import sys\nprint(sys.argv[1])\n", encoding="utf-8") runtime = LocalRuntime(dir=base) result = await runtime.execute("python-skill", "hello.py", context, args=["hello from Python"]) assert result == "hello from Python" @pytest.mark.asyncio async def test_local_python_script_uses_host_interpreter_on_windows(tmp_path: Path, context: Context) -> None: # The shebang names an interpreter that does not exist, so the script only # runs if the Windows branch bypasses it in favour of sys.executable. base = _write_script_skill(tmp_path, "python-skill") scripts = base / "python-skill" / "scripts" scripts.mkdir() (scripts / "hello.py").write_text("#!/nonexistent/python3\nprint('host python')\n", encoding="utf-8") runtime = LocalRuntime(dir=base) with patch("sys.platform", "win32"): result = await runtime.execute("python-skill", "hello.py", context) assert result == "host python" @pytest.mark.asyncio async def test_supplied_sandbox_never_receives_host_interpreter_on_windows(skill_tree: Path, context: Context) -> None: factory = _RemoteFactory() runtime = LocalRuntime(dir=skill_tree, sandbox=factory) with patch("sys.platform", "win32"): await runtime.execute("react-best-practices", "scaffold.py", context) assert factory.sandbox.execs == [["sh", "-c", "python3 ./scaffold.py"]] @pytest.mark.asyncio async def test_run_script_routes_to_last_runtime(tmp_path: Path, context: Context) -> None: # On a name clash, execution routes to the last runtime (project > global). glob = _write_script_skill(tmp_path / "global", "dup", "echo GLOBAL\n") proj = _write_script_skill(tmp_path / "project", "dup", "echo PROJECT\n") run_tool = SkillsToolkit(LocalRuntime(dir=glob), LocalRuntime(dir=proj)).run_skill_script() args = json.dumps({"name": "dup", "script": "go.sh"}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert not isinstance(result, ToolErrorEvent) assert "PROJECT" in result.result.parts[0].content assert "GLOBAL" not in result.result.parts[0].content @pytest.mark.asyncio async def test_run_script_falls_through_on_skill_not_found(tmp_path: Path, context: Context) -> None: # The last runtime does not own the skill (SkillNotFoundError), so the chain # falls through to the global runtime that does. glob = _write_script_skill(tmp_path / "global", "only-global", "echo HELLO\n") proj = tmp_path / "project" proj.mkdir() run_tool = SkillsToolkit(LocalRuntime(dir=glob), LocalRuntime(dir=proj)).run_skill_script() args = json.dumps({"name": "only-global", "script": "go.sh"}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert not isinstance(result, ToolErrorEvent) assert "HELLO" in result.result.parts[0].content @pytest.mark.asyncio async def test_run_skill_script_executes_nested_script(tmp_path: Path, context: Context) -> None: skills_root = tmp_path / "skills" skill_dir = skills_root / "nested-script" scripts_dir = skill_dir / "scripts" / "helpers" scripts_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: nested-script\ndescription: Runs a nested script\n---\n# Nested script\n" ) (scripts_dir / "report.py").write_text('print("NESTED SCRIPT RAN")\n') run_tool = SkillsToolkit(LocalRuntime(dir=skills_root)).run_skill_script() args = json.dumps({"name": "nested-script", "script": "helpers/report.py"}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert not isinstance(result, ToolErrorEvent) [output] = result.result.parts assert isinstance(output, TextInput) assert "NESTED SCRIPT RAN" in output.content @pytest.mark.asyncio async def test_run_script_routes_named_args_to_memory_runtime(tmp_path: Path, context: Context) -> None: skill = MemorySkill(name="calc", description="Double a number") @skill.script def double(value: int) -> str: return str(2 * value) run_tool = SkillsToolkit(MemoryRuntime(skill), LocalRuntime(dir=tmp_path)).run_skill_script() args = json.dumps({"name": "calc", "script": "double", "args": {"value": 2}}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert not isinstance(result, ToolErrorEvent) assert result.result.parts == [TextInput(content="4")] @pytest.mark.asyncio @pytest.mark.parametrize("script_args", [None, [], {}, {"value": 2}]) async def test_run_script_unknown_skill(tmp_path: Path, context: Context, script_args: object) -> None: # Empty catalogs leave the name unconstrained, so the call reaches routing. run_tool = SkillsToolkit(MemoryRuntime(), LocalRuntime(dir=tmp_path)).run_skill_script() args = json.dumps({"name": "missing", "script": "double", "args": script_args}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert isinstance(result, ToolErrorEvent) assert isinstance(result.error, SkillNotFoundError) assert "Skill 'missing' not found in any runtime" in str(result.error) @pytest.mark.asyncio @pytest.mark.parametrize("script_body", ["echo LOCAL\n", None]) @pytest.mark.parametrize("script_args", [{}, {"value": 2}]) async def test_local_script_rejects_named_args_without_falling_through( tmp_path: Path, context: Context, script_body: str | None, script_args: dict[str, int] ) -> None: local = _write_script_skill(tmp_path, "dup", script_body) skill = MemorySkill(name="dup", description="Fallback must not run") calls: list[int] = [] @skill.script(name="go.sh") def fallback(value: int = 0) -> str: calls.append(value) return "MEMORY" run_tool = SkillsToolkit(MemoryRuntime(skill), LocalRuntime(dir=local)).run_skill_script() args = json.dumps({"name": "dup", "script": "go.sh", "args": script_args}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert isinstance(result, ToolErrorEvent) assert isinstance(result.error, TypeError) assert "requires positional string arguments" in str(result.error) assert calls == [] @pytest.mark.asyncio async def test_missing_script_in_owning_runtime_does_not_fall_through(tmp_path: Path, context: Context) -> None: # The last runtime OWNS the skill but lacks the script: that is a genuine # error, not SkillNotFoundError, so the chain must NOT fall through and run # the global runtime's same-named script. glob = _write_script_skill(tmp_path / "global", "dup", "echo GLOBAL\n") proj = _write_script_skill(tmp_path / "project", "dup", script_body=None) # owns dup, no scripts run_tool = SkillsToolkit(LocalRuntime(dir=glob), LocalRuntime(dir=proj)).run_skill_script() args = json.dumps({"name": "dup", "script": "go.sh"}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert isinstance(result, ToolErrorEvent) assert isinstance(result.error, FileNotFoundError) @pytest.mark.asyncio async def test_tool_exposes_all_functions(skill_tree: Path, context: Context) -> None: tool = SkillsToolkit(skill_tree) schemas = await tool.schemas(context) names = {s.function.name for s in schemas} # type: ignore[union-attr] assert names == {"list_skills", "load_skill", "read_skill_resource", "run_skill_script"} @pytest.mark.asyncio async def test_run_skill_script_schema(skill_tree: Path, context: Context) -> None: run_tool = SkillsToolkit(LocalRuntime(dir=skill_tree)).run_skill_script() [schema] = await run_tool.schemas(context) assert asdict(schema) == { "type": "function", "defer_loading": False, "function": IsPartialDict({ "name": "run_skill_script", "parameters": IsPartialDict({ "properties": IsPartialDict({ "name": IsPartialDict({"type": "string"}), "script": IsPartialDict({"type": "string"}), }), "required": ["name", "script"], }), }), } @pytest.mark.asyncio async def test_name_param_constrained_to_discovered_skills(skill_tree: Path, context: Context) -> None: # The activation tools constrain `name` to a Literal enum of discovered # skills so the model cannot pass an unknown name (spec Step 4 Tip). toolkit = SkillsToolkit(LocalRuntime(dir=skill_tree)) [load_schema] = await toolkit.load_skill().schemas(context) [run_schema] = await toolkit.run_skill_script().schemas(context) expected_enum = sorted(["react-best-practices", "markdown-guide"]) load_name = asdict(load_schema)["function"]["parameters"]["properties"]["name"] run_name = asdict(run_schema)["function"]["parameters"]["properties"]["name"] assert sorted(load_name["enum"]) == expected_enum assert sorted(run_name["enum"]) == expected_enum @pytest.mark.asyncio async def test_name_param_falls_back_to_string_when_empty(tmp_path: Path, context: Context) -> None: # No skills → a Literal cannot be empty, so `name` stays a plain string. toolkit = SkillsToolkit(LocalRuntime(dir=tmp_path / "empty")) [load_schema] = await toolkit.load_skill().schemas(context) name_prop = asdict(load_schema)["function"]["parameters"]["properties"]["name"] assert name_prop["type"] == "string" assert "enum" not in name_prop @pytest.mark.asyncio async def test_load_skill_wraps_content_with_resources(skill_tree: Path, context: Context) -> None: # load_skill returns the body wrapped in identifying tags, the absolute # skill directory, and a non-eager listing of bundled resources (spec Step 4). load_tool = SkillsToolkit(LocalRuntime(dir=skill_tree)).load_skill() event = ToolCallEvent(name="load_skill", arguments=json.dumps({"name": "react-best-practices"})) result = await load_tool(event, context) assert not isinstance(result, ToolErrorEvent) content = result.result.parts[0].content assert '' in content assert "React Best Practices" in content assert f"Skill directory: {skill_tree / 'react-best-practices'}" in content assert "references/guide.md" in content # Scripts are not resources: scripts/ is excluded from the resource listing. assert "scripts/scaffold.py" not in content # Body only: the YAML frontmatter is stripped before wrapping (spec Step 4). assert "description: Best practices for React development" not in content assert "version: 1.2.0" not in content @pytest.mark.asyncio async def test_filesystem_reads_stay_off_the_event_loop(skill_tree: Path, context: Context) -> None: # read/read_resource are async, so their blocking filesystem work — which # includes a full tree scan on a cold discovery cache — must run in a thread. runtime = LocalRuntime(dir=skill_tree) threads: list[int] = [] original = SkillLoader.get_skill def record(self: SkillLoader, name: str) -> object: threads.append(threading.get_ident()) return original(self, name) with patch.object(SkillLoader, "get_skill", record): await runtime.read("react-best-practices", context) await runtime.read_resource("react-best-practices", "references/guide.md", context) assert threads and all(t != threading.get_ident() for t in threads) @pytest.mark.asyncio async def test_read_skill_resource_returns_content(skill_tree: Path, context: Context) -> None: # read_skill_resource reads a bundled file given a path relative to the # skill directory (the files listed in ). read_tool = SkillsToolkit(LocalRuntime(dir=skill_tree)).read_skill_resource() args = json.dumps({"name": "react-best-practices", "resource": "references/guide.md"}) result = await read_tool(ToolCallEvent(name="read_skill_resource", arguments=args), context) assert not isinstance(result, ToolErrorEvent) assert "Detailed React guidance." in result.result.parts[0].content @pytest.mark.asyncio async def test_read_skill_resource_rejects_path_traversal(skill_tree: Path, context: Context) -> None: # A resource path escaping the skill directory is rejected, not read. read_tool = SkillsToolkit(LocalRuntime(dir=skill_tree)).read_skill_resource() args = json.dumps({"name": "react-best-practices", "resource": "../markdown-guide/SKILL.md"}) result = await read_tool(ToolCallEvent(name="read_skill_resource", arguments=args), context) assert isinstance(result, ToolErrorEvent) @pytest.mark.asyncio async def test_read_skill_resource_rejects_symlinked_resource_escape(skill_tree: Path, context: Context) -> None: # A symlinked resource is *discovered* (Path.is_file follows the link, so it # lands in the descriptor list), so the membership check alone passes. The # resolved target escapes the skill directory, so the read must still be # rejected — otherwise a bundled symlink leaks arbitrary files. secret = skill_tree / "secret.txt" # outside any skill directory secret.write_text("TOP SECRET\n") _symlink_or_skip(skill_tree / "react-best-practices" / "leak.txt", secret) read_tool = SkillsToolkit(LocalRuntime(dir=skill_tree)).read_skill_resource() args = json.dumps({"name": "react-best-practices", "resource": "leak.txt"}) result = await read_tool(ToolCallEvent(name="read_skill_resource", arguments=args), context) assert isinstance(result, ToolErrorEvent) @pytest.mark.asyncio async def test_read_skill_resource_allows_in_bounds_symlink(skill_tree: Path, context: Context) -> None: # The guard rejects *escapes*, not symlinks per se: a symlink whose target # resolves back inside the skill directory is still served. _symlink_or_skip( skill_tree / "react-best-practices" / "alias.md", skill_tree / "react-best-practices" / "references" / "guide.md", ) read_tool = SkillsToolkit(LocalRuntime(dir=skill_tree)).read_skill_resource() args = json.dumps({"name": "react-best-practices", "resource": "alias.md"}) result = await read_tool(ToolCallEvent(name="read_skill_resource", arguments=args), context) assert not isinstance(result, ToolErrorEvent) assert "Detailed React guidance." in result.result.parts[0].content @pytest.mark.asyncio async def test_run_skill_script_rejects_symlinked_script_escape(tmp_path: Path, context: Context) -> None: # Mirror of the resource escape for execution: a symlinked script under # scripts/ is discovered and passes membership, but resolves outside the # scripts directory, so it must be rejected rather than executed. skills_root = _write_script_skill(tmp_path / "skills", "evil-skill", "echo SAFE\n") outside = tmp_path / "outside.sh" outside.write_text("echo PWNED\n") _symlink_or_skip(skills_root / "evil-skill" / "scripts" / "evil.sh", outside) run_tool = SkillsToolkit(LocalRuntime(dir=skills_root)).run_skill_script() args = json.dumps({"name": "evil-skill", "script": "evil.sh"}) result = await run_tool(ToolCallEvent(name="run_skill_script", arguments=args), context) assert isinstance(result, ToolErrorEvent) @pytest.mark.asyncio async def test_run_skill_script_executes(skill_tree: Path) -> None: scripts_dir = skill_tree / "react-best-practices" / "scripts" env = ShellAdapter(LocalSandbox(path=scripts_dir, cleanup=False)) # cwd is scripts_dir, so pass just the filename — same as tool.py does result = await env.run("python scaffold.py") assert "scaffold" in result class _FakeRemoteSandbox: def __init__(self) -> None: self.execs: list[Sequence[str]] = [] @property def workdir(self) -> PurePosixPath: return PurePosixPath("/workspace") @property def host_workdir(self) -> None: return None async def exec(self, argv: Sequence[str], *, env: object = None, timeout: object = None) -> ExecResult: self.execs.append(argv) return ExecResult(output="ran", exit_code=0) class _RemoteFactory: """A non-local SandboxFactory (no sync fast path) opened per command.""" def __init__(self) -> None: self.sandbox = _FakeRemoteSandbox() @asynccontextmanager async def open(self, context: object = None) -> AsyncGenerator[Sandbox]: yield self.sandbox @pytest.mark.asyncio async def test_run_skill_script_runs_in_event_loop_with_remote_backend(skill_tree: Path, context: Context) -> None: # Regression for finding #5: run_skill_script is async, so it drives a # remote backend with `await env.run(...)` inside the agent's own event # loop. The old sync path called env.run_sync(), which nests asyncio.run() # and raises "active event loop" for a non-local factory. factory = _RemoteFactory() runtime = LocalRuntime(dir=skill_tree, sandbox=factory) run_tool = SkillsToolkit(runtime).run_skill_script() event = ToolCallEvent( name="run_skill_script", arguments=json.dumps({"name": "react-best-practices", "script": "scaffold.py"}), ) result = await run_tool(event, context) assert not isinstance(result, ToolErrorEvent) assert factory.sandbox.execs # the command reached the backend via the async path @pytest.mark.asyncio async def test_local_runtime_uses_supplied_sandbox(tmp_path: Path) -> None: # A user-supplied Sandbox backend is honoured by shell(); commands run in # the sandbox's own workdir rather than the scripts_dir. sandbox_dir = tmp_path / "box" sandbox_dir.mkdir() (sandbox_dir / "marker.txt").write_text("present") runtime = LocalRuntime(dir=tmp_path / "skills", sandbox=LocalSandbox(path=sandbox_dir)) env = runtime.shell(tmp_path / "unused-scripts-dir") result = await env.run("cat marker.txt") assert "present" in result