Skip to content

Commit c40b281

Browse files
authored
feature: 优化skill (#85)
- 本地沙箱环境注入文件支持软连接 - skill文件读取支持缓存
1 parent 2d3be9c commit c40b281

25 files changed

Lines changed: 565 additions & 79 deletions

File tree

docs/mkdocs/en/skill.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ from trpc_agent_sdk.agents import LlmAgent
100100
from trpc_agent_sdk.models import OpenAIModel
101101
from trpc_agent_sdk.skills import SkillToolSet
102102
from trpc_agent_sdk.skills import create_default_skill_repository
103+
from trpc_agent_sdk.skills.tools import LinkSkillStager
103104
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
104105
from trpc_agent_sdk.code_executors import create_container_workspace_runtime
105106
# Cube is an optional extra (`pip install 'trpc-agent-py[cube]'`); import lazily.
@@ -114,11 +115,12 @@ workspace_runtime = create_local_workspace_runtime()
114115
# workspace_runtime = create_cube_workspace_runtime(executor)
115116

116117
# Create skill repository
117-
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime)
118+
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime, use_cached_repository=True)
118119

119120
# Create skill tool set with optional artifact save options
120121
skill_tool_set = SkillToolSet(
121122
repository=repository,
123+
skill_stager=LinkSkillStager(),
122124
# run_tool_kwargs is an optional tool parameter
123125
run_tool_kwargs={
124126
"save_as_artifacts": True, # Whether to save as artifact files
@@ -137,6 +139,8 @@ agent = LlmAgent(
137139
)
138140
```
139141

142+
*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.*
143+
140144
**Prompt example**:
141145

142146
The `INSTRUCTION` should include complete skill usage workflow guidance:

docs/mkdocs/zh/skill.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ from trpc_agent_sdk.agents import LlmAgent
100100
from trpc_agent_sdk.models import OpenAIModel
101101
from trpc_agent_sdk.skills import SkillToolSet
102102
from trpc_agent_sdk.skills import create_default_skill_repository
103+
from trpc_agent_sdk.skills.tools import LinkSkillStager
103104
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
104105
from trpc_agent_sdk.code_executors import create_container_workspace_runtime
105106
# Cube 是可选 extra(`pip install 'trpc-agent-py[cube]'`),按需引入。
@@ -114,11 +115,12 @@ workspace_runtime = create_local_workspace_runtime()
114115
# workspace_runtime = create_cube_workspace_runtime(executor)
115116

116117
# 创建技能仓库
117-
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime)
118+
repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime, use_cached_repository=True)
118119

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

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

142146
`INSTRUCTION` 中应包含完整的技能使用工作流指导:

examples/skills/agent/agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def create_agent():
2525
"""Create a skill run agent to demonstrate the various capabilities of an LLM agent."""
2626

2727
# Create tools
28-
skill_tool_set, skill_repository = create_skill_tool_set()
28+
skill_tool_set, skill_repository = create_skill_tool_set(is_link_stager=True, use_cached_repository=True)
2929

3030
return LlmAgent(
3131
name="skill_run_agent",

examples/skills/agent/prompts.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
skill_run inputs/outputs fields to map files instead of shell commands
2424
like cp or mv where possible.
2525
26+
When a task needs host files inside the workspace, call skill_load first so
27+
shared work/inputs links are ready, then copy or map files into work/inputs/.
28+
2629
When using a skill, follow this workflow:
2730
1. First call skill_load to load the skill documentation
2831
2. Always call skill_list_docs immediately after skill_load to verify what documents have been loaded,

examples/skills/agent/tools.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
1313
from trpc_agent_sdk.skills import ENV_SKILLS_ROOT
1414
from trpc_agent_sdk.skills import SkillToolSet
15+
from trpc_agent_sdk.skills.tools import LinkSkillStager
16+
from trpc_agent_sdk.skills.tools import CopySkillStager
1517
from trpc_agent_sdk.skills import create_default_skill_repository
1618

1719

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

3941

40-
def create_skill_tool_set() -> SkillToolSet:
41-
"""Create a new skill tool set."""
42+
def create_skill_tool_set(is_link_stager: bool = True, use_cached_repository: bool = True) -> SkillToolSet:
43+
"""Create a new skill tool set.
44+
45+
Args:
46+
is_link_stager: Whether to use link stager.
47+
use_cached_repository: Whether to use cached repository.
48+
"""
4249
tool_kwargs = {
4350
"save_as_artifacts": True,
4451
"omit_inline_content": False,
4552
}
4653
workspace_runtime_args = {}
4754
workspace_runtime = _create_workspace_runtime(**workspace_runtime_args)
4855
skill_paths = _get_skill_paths()
49-
repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime)
50-
return SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs), repository
56+
# use_cached_repository: Whether to use cached repository.
57+
repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime,
58+
use_cached_repository=use_cached_repository)
59+
skill_stager = LinkSkillStager() if is_link_stager else CopySkillStager()
60+
# skill_stager: The stager to use for staging skills.
61+
skill_toolset = SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs, skill_stager=skill_stager)
62+
return skill_toolset, repository

tests/code_executors/local/test_local_ws_runtime.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,43 @@ async def test_stage_directory(self):
241241
assert (dest / "file.txt").read_text() == "content"
242242
assert (dest / "sub" / "nested.txt").read_text() == "nested"
243243

244+
@pytest.mark.asyncio
245+
async def test_stage_directory_link_mode(self):
246+
src_dir = Path(self.tmpdir) / "source_link"
247+
src_dir.mkdir()
248+
(src_dir / "file.txt").write_text("content")
249+
(src_dir / "sub").mkdir()
250+
(src_dir / "sub" / "nested.txt").write_text("nested")
251+
252+
await self.fs.stage_directory(self.ws, str(src_dir), "dest_link", WorkspaceStageOptions(mode="link"))
253+
254+
dest = Path(self.tmpdir) / "dest_link"
255+
assert dest.is_dir()
256+
assert (dest / "file.txt").is_symlink()
257+
assert (dest / "sub").is_symlink()
258+
assert (dest / "file.txt").read_text() == "content"
259+
assert (dest / "sub" / "nested.txt").read_text() == "nested"
260+
261+
@pytest.mark.asyncio
262+
async def test_stage_directory_link_mode_keeps_root_mutable_for_stager_links(self):
263+
src_dir = Path(self.tmpdir) / "source_link_root"
264+
src_dir.mkdir()
265+
(src_dir / "file.txt").write_text("content")
266+
267+
await self.fs.stage_directory(self.ws, str(src_dir), "dest_link_root", WorkspaceStageOptions(mode="link"))
268+
269+
dest = Path(self.tmpdir) / "dest_link_root"
270+
(dest / "out").symlink_to("../out")
271+
assert (dest / "out").is_symlink()
272+
assert not (src_dir / "out").exists()
273+
244274
@pytest.mark.asyncio
245275
async def test_stage_directory_read_only(self):
246276
src_dir = Path(self.tmpdir) / "src_ro"
247277
src_dir.mkdir()
248278
(src_dir / "file.txt").write_text("readonly")
249279

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

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

465-
# --- _copy_directory ---
466-
def test_copy_directory(self):
495+
# --- _put_directory ---
496+
def test_put_directory_copy_mode(self):
467497
src = Path(self.tmpdir) / "copy_src"
468498
src.mkdir()
469499
(src / "a.txt").write_text("a")
470500
(src / "sub").mkdir()
471501
(src / "sub" / "b.txt").write_text("b")
472502

473503
dst = Path(self.tmpdir) / "copy_dst"
474-
self.fs._copy_directory(str(src), str(dst))
504+
self.fs._put_directory(self.ws, str(src), "copy_dst", mode="copy")
475505

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

479-
def test_copy_directory_empty(self):
509+
def test_put_directory_copy_mode_empty(self):
480510
src = Path(self.tmpdir) / "empty_src"
481511
src.mkdir()
482512
dst = Path(self.tmpdir) / "empty_dst"
483-
self.fs._copy_directory(str(src), str(dst))
513+
self.fs._put_directory(self.ws, str(src), "empty_dst", mode="copy")
484514
assert dst.exists()
485515

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

554-
copied = Path(self.tmpdir) / "work" / "inputs"
555-
assert copied.exists()
584+
copied = Path(self.tmpdir) / "work" / "inputs" / "data"
585+
assert copied.is_dir()
586+
assert (copied / "data.txt").read_text() == "host data"
556587

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

566597
linked = Path(self.tmpdir) / "work" / "inputs" / "linked"
567-
assert linked.is_symlink() or linked.exists()
598+
assert linked.is_dir()
599+
assert (linked / "link.txt").is_symlink()
600+
assert (linked / "link.txt").read_text() == "link data"
568601

569602
@pytest.mark.asyncio
570603
async def test_stage_inputs_workspace(self):

tests/code_executors/test_types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,17 +294,19 @@ class TestWorkspaceStageOptions:
294294

295295
def test_create_workspace_stage_options(self):
296296
"""Test creating workspace stage options."""
297-
options = WorkspaceStageOptions(read_only=True, allow_mount=True)
297+
options = WorkspaceStageOptions(read_only=True, allow_mount=True, mode="link")
298298

299299
assert options.read_only is True
300300
assert options.allow_mount is True
301+
assert options.mode == "link"
301302

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

306307
assert options.read_only is False
307308
assert options.allow_mount is False
309+
assert options.mode == "link"
308310

309311

310312
class TestWorkspaceCapabilities:

tests/code_executors/utils/test_meta.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def test_creates_all_subdirs(self):
184184
assert (Path(tmpdir) / name).is_dir()
185185
assert name in paths
186186
assert paths[name] == Path(tmpdir) / name
187+
assert (Path(tmpdir) / DIR_WORK / "inputs").is_dir()
187188

188189
def test_creates_metadata_file(self):
189190
with tempfile.TemporaryDirectory() as tmpdir:

tests/skills/test_repository.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from trpc_agent_sdk.skills._repository import (
2727
BASE_DIR_PLACEHOLDER,
2828
BaseSkillRepository,
29+
CachedFsSkillRepository,
2930
FsSkillRepository,
3031
create_default_skill_repository,
3132
)
@@ -203,6 +204,65 @@ def test_summaries(self, tmp_path):
203204
assert "a-skill" in names
204205
assert "b-skill" in names
205206

207+
def test_base_repository_reads_body_for_summaries(self, tmp_path):
208+
_create_skill_dir(tmp_path, "summary-baseline", "Summary", body="# Large Body\n")
209+
repo = FsSkillRepository(str(tmp_path))
210+
original_read_text = Path.read_text
211+
212+
with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text:
213+
summaries = repo.summaries()
214+
215+
assert [summary.name for summary in summaries] == ["summary-baseline"]
216+
assert mock_read_text.call_count == 1
217+
218+
def test_cached_repository_summaries_do_not_read_skill_body(self, tmp_path):
219+
_create_skill_dir(tmp_path, "summary-only", "Summary", body="# Large Body\n")
220+
221+
with patch.object(Path, "read_text", side_effect=AssertionError("body should not be read")):
222+
repo = CachedFsSkillRepository(str(tmp_path))
223+
summaries = repo.summaries()
224+
225+
assert [summary.name for summary in summaries] == ["summary-only"]
226+
assert summaries[0].description == "Summary"
227+
228+
def test_base_repository_does_not_reuse_skill_body(self, tmp_path):
229+
_create_skill_dir(tmp_path, "uncached-skill", "Uncached", body="# Uncached Body\n")
230+
repo = FsSkillRepository(str(tmp_path))
231+
original_read_text = Path.read_text
232+
233+
with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text:
234+
repo.get("uncached-skill")
235+
repo.get("uncached-skill")
236+
237+
assert mock_read_text.call_count == 2
238+
239+
def test_cached_repository_get_reuses_cached_skill_body(self, tmp_path):
240+
_create_skill_dir(tmp_path, "cached-skill", "Cached", body="# Cached Body\n")
241+
repo = CachedFsSkillRepository(str(tmp_path))
242+
original_read_text = Path.read_text
243+
244+
with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text:
245+
first = repo.get("cached-skill")
246+
second = repo.get("cached-skill")
247+
248+
assert first.body == second.body
249+
assert mock_read_text.call_count == 1
250+
251+
def test_cached_repository_get_refreshes_cached_body_when_skill_file_changes(self, tmp_path):
252+
skill_dir = _create_skill_dir(tmp_path, "mutable-skill", "Before", body="# Before\n")
253+
skill_file = skill_dir / "SKILL.md"
254+
repo = CachedFsSkillRepository(str(tmp_path))
255+
first = repo.get("mutable-skill")
256+
257+
skill_file.write_text("---\nname: mutable-skill\ndescription: After\n---\n# After changed body\n",
258+
encoding="utf-8")
259+
260+
second = repo.get("mutable-skill")
261+
262+
assert "Before" in first.body
263+
assert "After changed body" in second.body
264+
assert second.summary.description == "After"
265+
206266
def test_refresh(self, tmp_path):
207267
repo = FsSkillRepository(str(tmp_path))
208268
assert repo.skill_list() == []
@@ -311,6 +371,7 @@ class TestCreateDefaultSkillRepository:
311371
def test_creates_repository(self, tmp_path):
312372
_create_skill_dir(tmp_path, "test")
313373
repo = create_default_skill_repository(str(tmp_path))
374+
assert isinstance(repo, CachedFsSkillRepository)
314375
assert isinstance(repo, FsSkillRepository)
315376
assert "test" in repo.skill_list()
316377

tests/skills/test_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def test_creates_subdirectories(self, tmp_path):
130130
assert len(paths) == 4
131131
for p in paths.values():
132132
assert p.exists()
133+
assert (tmp_path / "work" / "inputs").is_dir()
133134

134135
def test_creates_metadata_file(self, tmp_path):
135136
from trpc_agent_sdk.code_executors import META_FILE_NAME

0 commit comments

Comments
 (0)