From 7c4c24a10feec0cc0c0a92a26b42e9e1a7684241 Mon Sep 17 00:00:00 2001 From: SparshM8 <1.88700067e+08+SparshM8@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:20:08 +0000 Subject: [PATCH] fix(skills): make setup_skills idempotent and add keep_skills opt-out --- src/anthropic/lib/tools/_skills.py | 14 ++++++++++++++ src/anthropic/lib/tools/agent_toolset.py | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/src/anthropic/lib/tools/_skills.py b/src/anthropic/lib/tools/_skills.py index 0a1e9e537..8792ad7dc 100644 --- a/src/anthropic/lib/tools/_skills.py +++ b/src/anthropic/lib/tools/_skills.py @@ -274,9 +274,23 @@ async def download_session_skills( adest = anyio.Path(dest) if await adest.is_symlink(): await adest.unlink() + # If the directory already exists and contains the expected version, + # skip the download. This prevents race conditions and redundant + # work when multiple sessions share a workdir. + version_file = dest / ".version" + if await adest.is_dir() and await anyio.Path(version_file).exists(): + try: + if (await anyio.Path(version_file).read_text()).strip() == version_id: + log.info("skill skill_id=%s version=%s already exists at %s; skipping", skill.skill_id, version_id, dest) + downloaded.append(dest) + continue + except Exception: + pass + # ``shutil.rmtree`` is blocking; keep it off the event loop. await run_sync(partial(shutil.rmtree, dest, ignore_errors=True)) await _download_and_extract(client, skill.skill_id, version_id, dest) + await anyio.Path(version_file).write_text(version_id) downloaded.append(dest) log.info("downloaded skill skill_id=%s version=%s -> %s", skill.skill_id, version_id, dest) except Exception as e: diff --git a/src/anthropic/lib/tools/agent_toolset.py b/src/anthropic/lib/tools/agent_toolset.py index ccb662457..0d4d56272 100644 --- a/src/anthropic/lib/tools/agent_toolset.py +++ b/src/anthropic/lib/tools/agent_toolset.py @@ -274,6 +274,10 @@ class AgentToolContext: max_file_bytes: int | None | NotGiven = not_given max_image_base64_bytes: int | None | NotGiven = not_given max_pdf_bytes: int | None | NotGiven = not_given + # Whether to keep skill directories on disk after the context exits. + # When True, ``_cleanup_skills`` is a no-op, allowing the workdir to + # serve as a persistent skills cache across sessions. + keep_skills: bool = False # Resolved directories the write and edit tools refuse to modify. The # worker sets this to the roots of read-only memory stores so the agent # sees the error at write time instead of the change silently never @@ -316,6 +320,8 @@ async def _cleanup_skills(self) -> None: Only the directories this context created are removed — a pre-existing ``{workdir}/skills`` tree is left untouched. """ + if self.keep_skills: + return for skill_dir in self._skill_dirs: try: # ``shutil.rmtree`` is blocking; keep it off the event loop.