Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/mkdocs/en/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ from trpc_agent_sdk.agents import LlmAgent
from trpc_agent_sdk.models import OpenAIModel
from trpc_agent_sdk.skills import SkillToolSet
from trpc_agent_sdk.skills import create_default_skill_repository
from trpc_agent_sdk.skills.tools import LinkSkillStager
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
from trpc_agent_sdk.code_executors import create_container_workspace_runtime
# Cube is an optional extra (`pip install 'trpc-agent-py[cube]'`); import lazily.
Expand All @@ -114,11 +115,12 @@ workspace_runtime = create_local_workspace_runtime()
# workspace_runtime = create_cube_workspace_runtime(executor)

# Create skill repository
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime)
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime, use_cached_repository=True)

# Create skill tool set with optional artifact save options
skill_tool_set = SkillToolSet(
repository=repository,
skill_stager=LinkSkillStager(),
# run_tool_kwargs is an optional tool parameter
run_tool_kwargs={
"save_as_artifacts": True, # Whether to save as artifact files
Expand All @@ -137,6 +139,8 @@ agent = LlmAgent(
)
```

*Note: Starting after version 1.1.10, skill loading and injection were optimized to support caching skill content and symlink-based staging in the local sandbox, avoiding full directory copies.*

**Prompt example**:

The `INSTRUCTION` should include complete skill usage workflow guidance:
Expand Down
6 changes: 5 additions & 1 deletion docs/mkdocs/zh/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ from trpc_agent_sdk.agents import LlmAgent
from trpc_agent_sdk.models import OpenAIModel
from trpc_agent_sdk.skills import SkillToolSet
from trpc_agent_sdk.skills import create_default_skill_repository
from trpc_agent_sdk.skills.tools import LinkSkillStager
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
from trpc_agent_sdk.code_executors import create_container_workspace_runtime
# Cube 是可选 extra(`pip install 'trpc-agent-py[cube]'`),按需引入。
Expand All @@ -114,11 +115,12 @@ workspace_runtime = create_local_workspace_runtime()
# workspace_runtime = create_cube_workspace_runtime(executor)

# 创建技能仓库
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime)
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime, use_cached_repository=True)

# 创建技能工具集,可配置工件保存选项
skill_tool_set = SkillToolSet(
repository=repository,
skill_stager=LinkSkillStager(),
# run_tool_kwargs 属于工具可选参数
run_tool_kwargs={
"save_as_artifacts": True, # 是否存储为制品文件
Expand All @@ -137,6 +139,8 @@ agent = LlmAgent(
)
```

*注意:在版本 1.1.10(不包含)之后,优化了 skill 的加载和注入机制,支持缓存 skill 内容和本地沙箱环境软连的方式来避免拷贝*

**提示词示例**:

在 `INSTRUCTION` 中应包含完整的技能使用工作流指导:
Expand Down
2 changes: 1 addition & 1 deletion examples/skills/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def create_agent():
"""Create a skill run agent to demonstrate the various capabilities of an LLM agent."""

# Create tools
skill_tool_set, skill_repository = create_skill_tool_set()
skill_tool_set, skill_repository = create_skill_tool_set(is_link_stager=True, use_cached_repository=True)

return LlmAgent(
name="skill_run_agent",
Expand Down
3 changes: 3 additions & 0 deletions examples/skills/agent/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
skill_run inputs/outputs fields to map files instead of shell commands
like cp or mv where possible.
When a task needs host files inside the workspace, call skill_load first so
shared work/inputs links are ready, then copy or map files into work/inputs/.
When using a skill, follow this workflow:
1. First call skill_load to load the skill documentation
2. Always call skill_list_docs immediately after skill_load to verify what documents have been loaded,
Expand Down
20 changes: 16 additions & 4 deletions examples/skills/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
from trpc_agent_sdk.skills import ENV_SKILLS_ROOT
from trpc_agent_sdk.skills import SkillToolSet
from trpc_agent_sdk.skills.tools import LinkSkillStager
from trpc_agent_sdk.skills.tools import CopySkillStager
from trpc_agent_sdk.skills import create_default_skill_repository


Expand All @@ -37,14 +39,24 @@ def _create_workspace_runtime(**kwargs: Any) -> BaseWorkspaceRuntime:
return create_local_workspace_runtime(**kwargs)


def create_skill_tool_set() -> SkillToolSet:
"""Create a new skill tool set."""
def create_skill_tool_set(is_link_stager: bool = True, use_cached_repository: bool = True) -> SkillToolSet:
"""Create a new skill tool set.

Args:
is_link_stager: Whether to use link stager.
use_cached_repository: Whether to use cached repository.
"""
tool_kwargs = {
"save_as_artifacts": True,
"omit_inline_content": False,
}
workspace_runtime_args = {}
workspace_runtime = _create_workspace_runtime(**workspace_runtime_args)
skill_paths = _get_skill_paths()
repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime)
return SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs), repository
# use_cached_repository: Whether to use cached repository.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有link的示例么?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

文档配套更新一下

repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime,
use_cached_repository=use_cached_repository)
skill_stager = LinkSkillStager() if is_link_stager else CopySkillStager()
# skill_stager: The stager to use for staging skills.
skill_toolset = SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs, skill_stager=skill_stager)
return skill_toolset, repository
53 changes: 43 additions & 10 deletions tests/code_executors/local/test_local_ws_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,43 @@ async def test_stage_directory(self):
assert (dest / "file.txt").read_text() == "content"
assert (dest / "sub" / "nested.txt").read_text() == "nested"

@pytest.mark.asyncio
async def test_stage_directory_link_mode(self):
src_dir = Path(self.tmpdir) / "source_link"
src_dir.mkdir()
(src_dir / "file.txt").write_text("content")
(src_dir / "sub").mkdir()
(src_dir / "sub" / "nested.txt").write_text("nested")

await self.fs.stage_directory(self.ws, str(src_dir), "dest_link", WorkspaceStageOptions(mode="link"))

dest = Path(self.tmpdir) / "dest_link"
assert dest.is_dir()
assert (dest / "file.txt").is_symlink()
assert (dest / "sub").is_symlink()
assert (dest / "file.txt").read_text() == "content"
assert (dest / "sub" / "nested.txt").read_text() == "nested"

@pytest.mark.asyncio
async def test_stage_directory_link_mode_keeps_root_mutable_for_stager_links(self):
src_dir = Path(self.tmpdir) / "source_link_root"
src_dir.mkdir()
(src_dir / "file.txt").write_text("content")

await self.fs.stage_directory(self.ws, str(src_dir), "dest_link_root", WorkspaceStageOptions(mode="link"))

dest = Path(self.tmpdir) / "dest_link_root"
(dest / "out").symlink_to("../out")
assert (dest / "out").is_symlink()
assert not (src_dir / "out").exists()

@pytest.mark.asyncio
async def test_stage_directory_read_only(self):
src_dir = Path(self.tmpdir) / "src_ro"
src_dir.mkdir()
(src_dir / "file.txt").write_text("readonly")

await self.fs.stage_directory(self.ws, str(src_dir), "dest_ro", WorkspaceStageOptions(read_only=True))
await self.fs.stage_directory(self.ws, str(src_dir), "dest_ro", WorkspaceStageOptions(read_only=True, mode="copy"))
dest_file = Path(self.tmpdir) / "dest_ro" / "file.txt"
mode = dest_file.stat().st_mode
assert not (mode & 0o222) # no write bits
Expand All @@ -259,7 +289,7 @@ async def test_stage_directory_read_only_via_fs_flag(self):
(src_dir / "file.txt").write_text("fs_readonly")

fs_ro = LocalWorkspaceFS(read_only_staged_skill=True)
await fs_ro.stage_directory(self.ws, str(src_dir), "dest_fs_ro", WorkspaceStageOptions())
await fs_ro.stage_directory(self.ws, str(src_dir), "dest_fs_ro", WorkspaceStageOptions(mode="copy"))
dest_file = Path(self.tmpdir) / "dest_fs_ro" / "file.txt"
mode = dest_file.stat().st_mode
assert not (mode & 0o222)
Expand Down Expand Up @@ -462,25 +492,25 @@ async def test_fetch_bytes_budget_above_size(self):
assert data == b"hello world"
assert raw == 11

# --- _copy_directory ---
def test_copy_directory(self):
# --- _put_directory ---
def test_put_directory_copy_mode(self):
src = Path(self.tmpdir) / "copy_src"
src.mkdir()
(src / "a.txt").write_text("a")
(src / "sub").mkdir()
(src / "sub" / "b.txt").write_text("b")

dst = Path(self.tmpdir) / "copy_dst"
self.fs._copy_directory(str(src), str(dst))
self.fs._put_directory(self.ws, str(src), "copy_dst", mode="copy")

assert (dst / "a.txt").read_text() == "a"
assert (dst / "sub" / "b.txt").read_text() == "b"

def test_copy_directory_empty(self):
def test_put_directory_copy_mode_empty(self):
src = Path(self.tmpdir) / "empty_src"
src.mkdir()
dst = Path(self.tmpdir) / "empty_dst"
self.fs._copy_directory(str(src), str(dst))
self.fs._put_directory(self.ws, str(src), "empty_dst", mode="copy")
assert dst.exists()

# --- _make_tree_read_only ---
Expand Down Expand Up @@ -551,8 +581,9 @@ async def test_stage_inputs_host_copy(self):
specs = [WorkspaceInputSpec(src=f"host://{host_dir}", dst="work/inputs/data", mode="copy")]
await self.fs.stage_inputs(self.ws, specs)

copied = Path(self.tmpdir) / "work" / "inputs"
assert copied.exists()
copied = Path(self.tmpdir) / "work" / "inputs" / "data"
assert copied.is_dir()
assert (copied / "data.txt").read_text() == "host data"

@pytest.mark.asyncio
async def test_stage_inputs_host_link(self):
Expand All @@ -564,7 +595,9 @@ async def test_stage_inputs_host_link(self):
await self.fs.stage_inputs(self.ws, specs)

linked = Path(self.tmpdir) / "work" / "inputs" / "linked"
assert linked.is_symlink() or linked.exists()
assert linked.is_dir()
assert (linked / "link.txt").is_symlink()
assert (linked / "link.txt").read_text() == "link data"

@pytest.mark.asyncio
async def test_stage_inputs_workspace(self):
Expand Down
4 changes: 3 additions & 1 deletion tests/code_executors/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,17 +294,19 @@ class TestWorkspaceStageOptions:

def test_create_workspace_stage_options(self):
"""Test creating workspace stage options."""
options = WorkspaceStageOptions(read_only=True, allow_mount=True)
options = WorkspaceStageOptions(read_only=True, allow_mount=True, mode="link")

assert options.read_only is True
assert options.allow_mount is True
assert options.mode == "link"

def test_create_workspace_stage_options_defaults(self):
"""Test creating workspace stage options with defaults."""
options = WorkspaceStageOptions()

assert options.read_only is False
assert options.allow_mount is False
assert options.mode == "link"


class TestWorkspaceCapabilities:
Expand Down
1 change: 1 addition & 0 deletions tests/code_executors/utils/test_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ def test_creates_all_subdirs(self):
assert (Path(tmpdir) / name).is_dir()
assert name in paths
assert paths[name] == Path(tmpdir) / name
assert (Path(tmpdir) / DIR_WORK / "inputs").is_dir()

def test_creates_metadata_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
Expand Down
61 changes: 61 additions & 0 deletions tests/skills/test_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from trpc_agent_sdk.skills._repository import (
BASE_DIR_PLACEHOLDER,
BaseSkillRepository,
CachedFsSkillRepository,
FsSkillRepository,
create_default_skill_repository,
)
Expand Down Expand Up @@ -203,6 +204,65 @@ def test_summaries(self, tmp_path):
assert "a-skill" in names
assert "b-skill" in names

def test_base_repository_reads_body_for_summaries(self, tmp_path):
_create_skill_dir(tmp_path, "summary-baseline", "Summary", body="# Large Body\n")
repo = FsSkillRepository(str(tmp_path))
original_read_text = Path.read_text

with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text:
summaries = repo.summaries()

assert [summary.name for summary in summaries] == ["summary-baseline"]
assert mock_read_text.call_count == 1

def test_cached_repository_summaries_do_not_read_skill_body(self, tmp_path):
_create_skill_dir(tmp_path, "summary-only", "Summary", body="# Large Body\n")

with patch.object(Path, "read_text", side_effect=AssertionError("body should not be read")):
repo = CachedFsSkillRepository(str(tmp_path))
summaries = repo.summaries()

assert [summary.name for summary in summaries] == ["summary-only"]
assert summaries[0].description == "Summary"

def test_base_repository_does_not_reuse_skill_body(self, tmp_path):
_create_skill_dir(tmp_path, "uncached-skill", "Uncached", body="# Uncached Body\n")
repo = FsSkillRepository(str(tmp_path))
original_read_text = Path.read_text

with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text:
repo.get("uncached-skill")
repo.get("uncached-skill")

assert mock_read_text.call_count == 2

def test_cached_repository_get_reuses_cached_skill_body(self, tmp_path):
_create_skill_dir(tmp_path, "cached-skill", "Cached", body="# Cached Body\n")
repo = CachedFsSkillRepository(str(tmp_path))
original_read_text = Path.read_text

with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text:
first = repo.get("cached-skill")
second = repo.get("cached-skill")

assert first.body == second.body
assert mock_read_text.call_count == 1

def test_cached_repository_get_refreshes_cached_body_when_skill_file_changes(self, tmp_path):
skill_dir = _create_skill_dir(tmp_path, "mutable-skill", "Before", body="# Before\n")
skill_file = skill_dir / "SKILL.md"
repo = CachedFsSkillRepository(str(tmp_path))
first = repo.get("mutable-skill")

skill_file.write_text("---\nname: mutable-skill\ndescription: After\n---\n# After changed body\n",
encoding="utf-8")

second = repo.get("mutable-skill")

assert "Before" in first.body
assert "After changed body" in second.body
assert second.summary.description == "After"

def test_refresh(self, tmp_path):
repo = FsSkillRepository(str(tmp_path))
assert repo.skill_list() == []
Expand Down Expand Up @@ -311,6 +371,7 @@ class TestCreateDefaultSkillRepository:
def test_creates_repository(self, tmp_path):
_create_skill_dir(tmp_path, "test")
repo = create_default_skill_repository(str(tmp_path))
assert isinstance(repo, CachedFsSkillRepository)
assert isinstance(repo, FsSkillRepository)
assert "test" in repo.skill_list()

Expand Down
1 change: 1 addition & 0 deletions tests/skills/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def test_creates_subdirectories(self, tmp_path):
assert len(paths) == 4
for p in paths.values():
assert p.exists()
assert (tmp_path / "work" / "inputs").is_dir()

def test_creates_metadata_file(self, tmp_path):
from trpc_agent_sdk.code_executors import META_FILE_NAME
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Copyright (C) 2026 Tencent. All rights reserved.
#
# tRPC-Agent-Python is licensed under Apache-2.0.
"""Unit tests for trpc_agent_sdk.skills.tools._copy_stager.
"""Unit tests for trpc_agent_sdk.skills.tools._file_stager.
Covers:
- normalize_workspace_skill_dir: valid paths, edge cases, rejected paths
Expand All @@ -18,7 +18,7 @@

import pytest

from trpc_agent_sdk.skills.tools._copy_stager import (
from trpc_agent_sdk.skills.tools._file_stager import (
CopySkillStager,
normalize_workspace_skill_dir,
_normalize_skill_stage_result,
Expand Down
Loading
Loading