Skip to content

Commit 8895a70

Browse files
committed
feature: 优化skill
- 本地沙箱环境注入文件支持软连接 - skill文件读取支持缓存
1 parent 32d7af9 commit 8895a70

11 files changed

Lines changed: 348 additions & 53 deletions

File tree

examples/skills/agent/tools.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
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 LinkCopySkillStager
1516
from trpc_agent_sdk.skills import create_default_skill_repository
1617

1718

@@ -46,5 +47,10 @@ def create_skill_tool_set() -> SkillToolSet:
4647
workspace_runtime_args = {}
4748
workspace_runtime = _create_workspace_runtime(**workspace_runtime_args)
4849
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
50+
# use_cached_repository: Whether to use cached repository.
51+
repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime,
52+
use_cached_repository=True)
53+
skill_stager = LinkCopySkillStager()
54+
# skill_stager: The stager to use for staging skills.
55+
skill_toolset = SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs, skill_stager=skill_stager)
56+
return skill_toolset, repository

tests/code_executors/local/test_local_ws_runtime.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,36 @@ 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"
@@ -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 == ""
308310

309311

310312
class TestWorkspaceCapabilities:

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

trpc_agent_sdk/code_executors/_types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ class WorkspaceStageOptions(BaseModel):
184184
allow_mount: bool = False
185185
""" whether to allow mount"""
186186

187+
mode: str = ""
188+
""" staging mode hint, e.g. 'copy' or 'link'. Empty means runtime default."""
189+
187190

188191
class WorkspaceCapabilities(BaseModel):
189192
"""

trpc_agent_sdk/code_executors/local/_local_ws_runtime.py

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@ class LocalWorkspaceFS(BaseWorkspaceFS):
152152
def __init__(self, read_only_staged_skill: bool = False):
153153
self.read_only_staged_skill = read_only_staged_skill
154154

155+
@staticmethod
156+
def _normalize_stage_mode(mode: str) -> str:
157+
mode = (mode or "copy").strip().lower()
158+
if mode not in {"copy", "link"}:
159+
raise ValueError(f"unsupported local workspace stage mode: {mode!r}")
160+
return mode
161+
155162
@override
156163
async def put_files(
157164
self,
@@ -189,11 +196,15 @@ async def stage_directory(
189196
to: Destination path relative to workspace
190197
opt: Staging options
191198
"""
192-
self._put_directory(ws, src, dst)
199+
mode = self._normalize_stage_mode(opt.mode)
200+
self._put_directory(ws, src, dst, mode=mode)
193201

194202
# Make tree read-only if requested
195203
ro = opt.read_only or self.read_only_staged_skill
196-
if ro:
204+
# Link mode uses symlinks to host files. chmod would follow symlinks on
205+
# many platforms and mutate the source skill tree, so leave protection
206+
# to the skill stager's symlink-aware chmod pass.
207+
if ro and mode != "link":
197208
if dst:
198209
dst = Path(ws.path) / Path(dst)
199210
else:
@@ -205,9 +216,10 @@ def _put_directory(
205216
ws: WorkspaceInfo,
206217
src: str,
207218
dst: str,
219+
mode: str = "copy",
208220
) -> None:
209221
"""
210-
Copy an entire directory from host into workspace.
222+
Put a host path into workspace using copy or link mode.
211223
212224
Args:
213225
ctx: Context for the operation
@@ -217,31 +229,44 @@ def _put_directory(
217229
"""
218230
src = os.path.abspath(src)
219231
dst = path_join(ws.path, dst)
220-
self._copy_directory(src, dst)
232+
src_path = Path(src)
233+
if mode == "link" and src_path.is_dir():
234+
self._link_directory(src, dst)
235+
elif mode == "link":
236+
make_symlink(ws.path, os.path.relpath(dst, ws.path), src)
237+
else:
238+
copy_path(src, dst)
221239

222-
def _copy_directory(
240+
def _link_directory(
223241
self,
224242
src: str,
225243
dst: str,
226244
) -> None:
227-
"""Copy directory recursively.
245+
"""Stage a directory by symlinking its direct children.
228246
229-
Args:
230-
src: Source directory path
231-
dst: Destination directory path
247+
The destination root is a real directory so later staging logic can add
248+
workspace-local links such as ``out`` / ``work`` without mutating the
249+
original skill directory. Direct child directories such as ``scripts``
250+
or ``references`` are linked as directories to avoid walking large
251+
skill trees.
232252
"""
233253
src_path = Path(src)
234254
dst_path = Path(dst)
255+
if dst_path.exists() or dst_path.is_symlink():
256+
if dst_path.is_symlink() or dst_path.is_file():
257+
dst_path.unlink()
258+
elif not dst_path.is_dir():
259+
raise ValueError(f"cannot stage into non-directory path: {dst}")
235260
dst_path.mkdir(parents=True, exist_ok=True)
236261

237-
for item in src_path.rglob("*"):
238-
rel_path = item.relative_to(src)
239-
target = dst_path / rel_path
240-
if item.is_dir():
241-
target.mkdir(parents=True, exist_ok=True)
242-
else:
243-
target.parent.mkdir(parents=True, exist_ok=True)
244-
shutil.copy2(item, target)
262+
for item in src_path.iterdir():
263+
target = dst_path / item.name
264+
if target.exists() or target.is_symlink():
265+
if target.is_dir() and not target.is_symlink():
266+
shutil.rmtree(target)
267+
else:
268+
target.unlink()
269+
target.symlink_to(item.resolve(strict=False), target_is_directory=item.is_dir())
245270

246271
def _make_tree_read_only(
247272
self,
@@ -401,31 +426,20 @@ async def stage_inputs(
401426
# Handle host inputs
402427
host_path = spec.src[len("host://"):]
403428
resolved = host_path
404-
if mode == "link":
405-
make_symlink(ws.path, dst.as_posix(), host_path)
406-
else:
407-
self._put_directory(ws, host_path, dst.parent.as_posix())
429+
self._put_directory(ws, host_path, dst.as_posix(), mode=mode)
408430
elif spec.src.startswith("workspace://"):
409431
# Handle workspace inputs
410432
rel = spec.src[len("workspace://"):]
411433
src = path_join(ws.path, rel)
412434
resolved = rel
413-
414-
if mode == "link":
415-
make_symlink(ws.path, dst.as_posix(), src)
416-
else:
417-
copy_path(src, path_join(ws.path, dst.as_posix()))
435+
self._put_directory(ws, src, dst.as_posix(), mode=mode)
418436
elif spec.src.startswith("skill://"):
419437
# Handle skill inputs
420438
rest = spec.src[len("skill://"):]
421439
src_base = Path(ws.path) / DIR_SKILLS
422440
src = path_join(src_base.as_posix(), rest)
423441
resolved = src
424-
425-
if mode == "link":
426-
make_symlink(ws.path, dst.as_posix(), src)
427-
else:
428-
copy_path(src, path_join(ws.path, dst.as_posix()))
442+
self._put_directory(ws, src, dst.as_posix(), mode=mode)
429443
else:
430444
raise ValueError(f"unsupported input: {spec.src}")
431445

trpc_agent_sdk/skills/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from ._dynamic_toolset import DynamicSkillToolSet
5757
from ._registry import SkillRegistry
5858
from ._repository import BaseSkillRepository
59+
from ._repository import CachedFsSkillRepository
5960
from ._repository import FsSkillRepository
6061
from ._repository import VisibilityFilter
6162
from ._repository import create_default_skill_repository
@@ -135,6 +136,7 @@
135136
"DynamicSkillToolSet",
136137
"SkillRegistry",
137138
"BaseSkillRepository",
139+
"CachedFsSkillRepository",
138140
"FsSkillRepository",
139141
"VisibilityFilter",
140142
"create_default_skill_repository",

0 commit comments

Comments
 (0)