diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py
index 5f4ef0615..61f3d0f1a 100644
--- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py
+++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py
@@ -14,11 +14,10 @@
from fastapi.responses import PlainTextResponse
from google.adk.agents import BaseAgent
from google.adk.apps import App
+from google.adk.artifacts import InMemoryArtifactService
from google.adk.plugins import BasePlugin
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
-from google.adk.artifacts import InMemoryArtifactService
-
from google.genai import types
from kagent.core.a2a import KAgentRequestContextBuilder, KAgentTaskStore
diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/__init__.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/__init__.py
new file mode 100644
index 000000000..f8cc4a093
--- /dev/null
+++ b/python/packages/kagent-adk/src/kagent/adk/artifacts/__init__.py
@@ -0,0 +1,13 @@
+from .artifacts_toolset import ArtifactsToolset
+from .return_artifacts_tool import ReturnArtifactsTool
+from .session_path import clear_session_cache, get_session_path, initialize_session_path
+from .stage_artifacts_tool import StageArtifactsTool
+
+__all__ = [
+ "ArtifactsToolset",
+ "ReturnArtifactsTool",
+ "StageArtifactsTool",
+ "get_session_path",
+ "initialize_session_path",
+ "clear_session_cache",
+]
diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/artifacts_toolset.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/artifacts_toolset.py
new file mode 100644
index 000000000..38cf3ecb2
--- /dev/null
+++ b/python/packages/kagent-adk/src/kagent/adk/artifacts/artifacts_toolset.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import logging
+from typing import List, Optional
+
+try:
+ from typing_extensions import override
+except ImportError:
+ from typing import override
+
+from google.adk.agents.readonly_context import ReadonlyContext
+from google.adk.tools import BaseTool
+from google.adk.tools.base_toolset import BaseToolset
+
+from .return_artifacts_tool import ReturnArtifactsTool
+from .stage_artifacts_tool import StageArtifactsTool
+
+logger = logging.getLogger("kagent_adk." + __name__)
+
+
+class ArtifactsToolset(BaseToolset):
+ """Toolset for managing artifact upload and download workflows.
+
+ This toolset provides tools for the complete artifact lifecycle:
+ 1. StageArtifactsTool - Download artifacts from artifact service to working directory
+ 2. ReturnArtifactsTool - Upload generated files from working directory to artifact service
+
+ Artifacts enable file-based interactions:
+ - Users upload files via frontend → stored as artifacts
+ - StageArtifactsTool copies them to working directory for processing
+ - Processing tools (bash, skills, etc.) work with files on disk
+ - ReturnArtifactsTool saves generated outputs back as artifacts
+ - Users download results via frontend
+
+ This toolset is independent of skills and can be used with any processing workflow.
+ """
+
+ def __init__(self):
+ """Initialize the artifacts toolset."""
+ super().__init__()
+
+ # Create artifact lifecycle tools
+ self.stage_artifacts_tool = StageArtifactsTool()
+ self.return_artifacts_tool = ReturnArtifactsTool()
+
+ @override
+ async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> List[BaseTool]:
+ """Get both artifact tools.
+
+ Returns:
+ List containing StageArtifactsTool and ReturnArtifactsTool.
+ """
+ return [
+ self.stage_artifacts_tool,
+ self.return_artifacts_tool,
+ ]
diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/return_artifacts_tool.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/return_artifacts_tool.py
new file mode 100644
index 000000000..24dadbd4f
--- /dev/null
+++ b/python/packages/kagent-adk/src/kagent/adk/artifacts/return_artifacts_tool.py
@@ -0,0 +1,160 @@
+"""Tool for returning generated files from working directory to artifact service."""
+
+from __future__ import annotations
+
+import logging
+import mimetypes
+from pathlib import Path
+from typing import Any, Dict, List
+
+from google.adk.tools import BaseTool, ToolContext
+from google.genai import types
+from typing_extensions import override
+
+from .session_path import get_session_path
+from .stage_artifacts_tool import MAX_ARTIFACT_SIZE_BYTES
+
+logger = logging.getLogger("kagent_adk." + __name__)
+
+
+class ReturnArtifactsTool(BaseTool):
+ """Save generated files from working directory to artifact service for user download.
+
+ This tool enables users to download outputs generated during processing.
+ Files are saved to the artifact service where they can be retrieved by the frontend.
+ """
+
+ def __init__(self):
+ super().__init__(
+ name="return_artifacts",
+ description=(
+ "Save generated files from the working directory to the artifact service, "
+ "making them available for user download.\n\n"
+ "WORKFLOW:\n"
+ "1. Generate output files in the 'outputs/' directory\n"
+ "2. Use this tool to save those files to the artifact service\n"
+ "3. Users can then download the files via the frontend\n\n"
+ "USAGE EXAMPLE:\n"
+ "- bash('python scripts/analyze.py > outputs/report.txt')\n"
+ "- return_artifacts(file_paths=['outputs/report.txt'])\n"
+ " Returns: 'Saved 1 file(s): report.txt (v0, 15.2 KB)'\n\n"
+ "PARAMETERS:\n"
+ "- file_paths: List of relative paths from working directory (required)\n"
+ "- artifact_names: Optional custom names for artifacts (default: use filename)\n\n"
+ "BEST PRACTICES:\n"
+ "- Generate outputs in 'outputs/' directory for clarity\n"
+ "- Use descriptive filenames (they become artifact names)\n"
+ "- Return all outputs at once for efficiency"
+ ),
+ )
+
+ def _get_declaration(self) -> types.FunctionDeclaration | None:
+ return types.FunctionDeclaration(
+ name=self.name,
+ description=self.description,
+ parameters=types.Schema(
+ type=types.Type.OBJECT,
+ properties={
+ "file_paths": types.Schema(
+ type=types.Type.ARRAY,
+ description=(
+ "List of relative file paths from the working directory to save as artifacts. "
+ "Example: ['outputs/report.pdf', 'outputs/data.csv']. "
+ "Files must exist in the working directory and be within size limits."
+ ),
+ items=types.Schema(type=types.Type.STRING),
+ ),
+ "artifact_names": types.Schema(
+ type=types.Type.ARRAY,
+ description=(
+ "Optional custom names for the artifacts. "
+ "If not provided, the filename will be used. "
+ "Must match the length of file_paths if provided."
+ ),
+ items=types.Schema(type=types.Type.STRING),
+ ),
+ },
+ required=["file_paths"],
+ ),
+ )
+
+ @override
+ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str:
+ file_paths: List[str] = args.get("file_paths", [])
+ artifact_names: List[str] = args.get("artifact_names", [])
+
+ if not file_paths:
+ return "Error: No file paths provided."
+
+ if artifact_names and len(artifact_names) != len(file_paths):
+ return "Error: artifact_names length must match file_paths length."
+
+ if not tool_context._invocation_context.artifact_service:
+ return "Error: Artifact service is not available in this context."
+
+ try:
+ working_dir = get_session_path(session_id=tool_context.session.id)
+
+ saved_artifacts = []
+ for idx, rel_path in enumerate(file_paths):
+ file_path = (working_dir / rel_path).resolve()
+
+ # Security: Ensure file is within working directory
+ if not file_path.is_relative_to(working_dir):
+ logger.warning(f"Skipping file outside working directory: {rel_path}")
+ continue
+
+ # Check file exists
+ if not file_path.exists():
+ logger.warning(f"File not found: {rel_path}")
+ continue
+
+ # Check file size
+ file_size = file_path.stat().st_size
+ if file_size > MAX_ARTIFACT_SIZE_BYTES:
+ size_mb = file_size / (1024 * 1024)
+ logger.warning(f"File too large: {rel_path} ({size_mb:.1f} MB)")
+ continue
+
+ # Determine artifact name
+ artifact_name = artifact_names[idx] if artifact_names else file_path.name
+
+ # Read file data and detect MIME type
+ file_data = file_path.read_bytes()
+ mime_type = self._detect_mime_type(file_path)
+
+ # Create artifact Part
+ artifact_part = types.Part.from_bytes(data=file_data, mime_type=mime_type)
+
+ # Save to artifact service
+ version = await tool_context.save_artifact(
+ filename=artifact_name,
+ artifact=artifact_part,
+ )
+
+ size_kb = file_size / 1024
+ saved_artifacts.append(f"{artifact_name} (v{version}, {size_kb:.1f} KB)")
+ logger.info(f"Saved artifact: {artifact_name} v{version} ({size_kb:.1f} KB)")
+
+ if not saved_artifacts:
+ return "No valid files were saved as artifacts."
+
+ return f"Saved {len(saved_artifacts)} file(s) for download:\n" + "\n".join(
+ f" • {artifact}" for artifact in saved_artifacts
+ )
+
+ except Exception as e:
+ logger.error("Error returning artifacts: %s", e, exc_info=True)
+ return f"An error occurred while returning artifacts: {e}"
+
+ def _detect_mime_type(self, file_path: Path) -> str:
+ """Detect MIME type from file extension.
+
+ Args:
+ file_path: Path to the file
+
+ Returns:
+ MIME type string, defaults to 'application/octet-stream' if unknown
+ """
+ mime_type, _ = mimetypes.guess_type(str(file_path))
+ return mime_type or "application/octet-stream"
diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/session_path.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/session_path.py
new file mode 100644
index 000000000..55e0263ce
--- /dev/null
+++ b/python/packages/kagent-adk/src/kagent/adk/artifacts/session_path.py
@@ -0,0 +1,106 @@
+import logging
+import tempfile
+from pathlib import Path
+
+logger = logging.getLogger("kagent_adk." + __name__)
+
+# Cache of initialized session paths to avoid re-creating symlinks
+_session_path_cache: dict[str, Path] = {}
+
+
+def initialize_session_path(session_id: str, skills_directory: str) -> Path:
+ """Initialize a session's working directory with skills symlink.
+
+ This is called by SkillsPlugin.before_agent_callback() to ensure the session
+ is set up before any tools run. Creates the directory structure and symlink
+ to the skills directory.
+
+ Directory structure:
+ /tmp/kagent/{session_id}/
+ ├── skills/ -> symlink to skills_directory (read-only shared skills)
+ ├── uploads/ -> staged user files (temporary)
+ └── outputs/ -> generated files for return
+
+ Args:
+ session_id: The unique ID of the current session.
+ skills_directory: Path to the shared skills directory.
+
+ Returns:
+ The resolved path to the session's root directory.
+ """
+ # Return cached path if already initialized
+ if session_id in _session_path_cache:
+ return _session_path_cache[session_id]
+
+ # Initialize new session path
+ base_path = Path(tempfile.gettempdir()) / "kagent"
+ session_path = base_path / session_id
+
+ # Create working directories
+ (session_path / "uploads").mkdir(parents=True, exist_ok=True)
+ (session_path / "outputs").mkdir(parents=True, exist_ok=True)
+
+ # Create symlink to skills directory
+ skills_mount = Path(skills_directory)
+ skills_link = session_path / "skills"
+ if skills_mount.exists() and not skills_link.exists():
+ try:
+ skills_link.symlink_to(skills_mount)
+ logger.debug(f"Created symlink: {skills_link} -> {skills_mount}")
+ except FileExistsError:
+ # Symlink already exists (race condition from concurrent session setup)
+ pass
+ except Exception as e:
+ # Log but don't fail - skills can still be accessed via absolute path
+ logger.warning(f"Failed to create skills symlink for session {session_id}: {e}")
+
+ # Cache and return
+ resolved_path = session_path.resolve()
+ _session_path_cache[session_id] = resolved_path
+ return resolved_path
+
+
+def get_session_path(session_id: str) -> Path:
+ """Get the working directory path for a session.
+
+ This function retrieves the cached session path that was initialized by
+ SkillsPlugin. If the session hasn't been initialized (plugin not used),
+ it falls back to auto-initialization with default /skills directory.
+
+ Tools should call this function to get their working directory. The session
+ must be initialized by SkillsPlugin before tools run, which happens automatically
+ via the before_agent_callback() hook.
+
+ Args:
+ session_id: The unique ID of the current session.
+
+ Returns:
+ The resolved path to the session's root directory.
+
+ Note:
+ If session is not initialized, automatically initializes with /skills.
+ For custom skills directories, ensure SkillsPlugin is installed.
+ """
+ # Return cached path if already initialized
+ if session_id in _session_path_cache:
+ return _session_path_cache[session_id]
+
+ # Fallback: auto-initialize with default /skills
+ logger.warning(
+ f"Session {session_id} not initialized by SkillsPlugin. "
+ f"Auto-initializing with default /skills. "
+ f"Install SkillsPlugin for custom skills directories."
+ )
+ return initialize_session_path(session_id, "/skills")
+
+
+def clear_session_cache(session_id: str | None = None) -> None:
+ """Clear cached session path(s).
+
+ Args:
+ session_id: Specific session to clear. If None, clears all cached sessions.
+ """
+ if session_id:
+ _session_path_cache.pop(session_id, None)
+ else:
+ _session_path_cache.clear()
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/stage_artifacts_tool.py
similarity index 59%
rename from python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py
rename to python/packages/kagent-adk/src/kagent/adk/artifacts/stage_artifacts_tool.py
index 6007bbff9..de46dbd08 100644
--- a/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py
+++ b/python/packages/kagent-adk/src/kagent/adk/artifacts/stage_artifacts_tool.py
@@ -1,93 +1,58 @@
from __future__ import annotations
import logging
-import os
-import tempfile
+import mimetypes
from pathlib import Path
from typing import Any, List
-from typing_extensions import override
-
from google.adk.tools import BaseTool, ToolContext
from google.genai import types
+from typing_extensions import override
-logger = logging.getLogger("kagent_adk." + __name__)
-
-
-def get_session_staging_path(session_id: str, app_name: str, skills_directory: Path) -> Path:
- """Creates (if needed) and returns the path to a session's staging directory.
-
- This function provides a consistent, isolated filesystem environment for each
- session. It creates a root directory for the session and populates it with
- an 'uploads' folder and a symlink to the static 'skills' directory.
-
- Args:
- session_id: The unique ID of the current session.
- app_name: The name of the application, used for namespacing.
- skills_directory: The path to the static skills directory.
-
- Returns:
- The resolved path to the session's root staging directory.
- """
- base_path = Path(tempfile.gettempdir()) / "adk_sessions" / app_name
- session_path = base_path / session_id
-
- # Create the session and uploads directories
- (session_path / "uploads").mkdir(parents=True, exist_ok=True)
+from .session_path import get_session_path
- # Symlink the static skills directory into the session directory
- if skills_directory and skills_directory.exists():
- skills_symlink = session_path / "skills"
- if not skills_symlink.exists():
- try:
- os.symlink(
- skills_directory.resolve(),
- skills_symlink,
- target_is_directory=True,
- )
- except OSError as e:
- logger.error(f"Failed to create skills symlink: {e}")
+logger = logging.getLogger("kagent_adk." + __name__)
- return session_path.resolve()
+# Maximum file size for staging (100 MB)
+MAX_ARTIFACT_SIZE_BYTES = 100 * 1024 * 1024
class StageArtifactsTool(BaseTool):
"""A tool to stage artifacts from the artifact service to the local filesystem.
- This tool bridges the gap between the artifact store and the skills system,
- enabling skills to work with user-uploaded files through a two-phase workflow:
- 1. Stage: Copy artifacts from artifact store to local 'uploads/' directory
- 2. Execute: Use the staged files in bash commands with skills
+ This tool enables working with user-uploaded files by staging them from the
+ artifact store to a local working directory where they can be accessed by
+ scripts, commands, and other tools.
- This is essential for the skills workflow where user-uploaded files must be
- accessible to skill scripts and commands.
+ Workflow:
+ 1. Stage: Copy artifacts from artifact store to local 'uploads/' directory
+ 2. Access: Use the staged files in commands, scripts, or other processing
"""
- def __init__(self, skills_directory: Path):
+ def __init__(self):
super().__init__(
name="stage_artifacts",
description=(
"Stage artifacts from the artifact store to a local filesystem path, "
- "making them available for use with skills and the bash tool.\n\n"
+ "making them available for processing by tools and scripts.\n\n"
"WORKFLOW:\n"
- "1. When a user uploads a file, it's stored as an artifact (e.g., 'artifact_xyz')\n"
+ "1. When a user uploads a file, it's stored as an artifact with a name\n"
"2. Use this tool to copy the artifact to your local 'uploads/' directory\n"
- "3. Then reference the staged file path in bash commands\n\n"
+ "3. Then reference the staged file path in commands or scripts\n\n"
"USAGE EXAMPLE:\n"
- "- stage_artifacts(artifact_names=['artifact_xyz'])\n"
- " Returns: 'Successfully staged 1 artifact(s) to: uploads/artifact_xyz'\n"
- "- Use the returned path in bash: bash('python skills/data-analysis/scripts/process.py uploads/artifact_xyz')\n\n"
+ "- stage_artifacts(artifact_names=['data.csv'])\n"
+ " Returns: 'Successfully staged 1 file(s): uploads/data.csv (1.2 MB)'\n"
+ "- Then use: bash('python scripts/process.py uploads/data.csv')\n\n"
"PARAMETERS:\n"
"- artifact_names: List of artifact names to stage (required)\n"
"- destination_path: Target directory within session (default: 'uploads/')\n\n"
"BEST PRACTICES:\n"
- "- Always stage artifacts before using them in skills\n"
+ "- Always stage artifacts before using them\n"
"- Use default 'uploads/' destination for consistency\n"
"- Stage all artifacts at the start of your workflow\n"
"- Check returned paths to confirm successful staging"
),
)
- self._skills_directory = skills_directory
def _get_declaration(self) -> types.FunctionDeclaration | None:
return types.FunctionDeclaration(
@@ -100,7 +65,7 @@ def _get_declaration(self) -> types.FunctionDeclaration | None:
type=types.Type.ARRAY,
description=(
"List of artifact names to stage. These are artifact identifiers "
- "provided by the system when files are uploaded (e.g., 'artifact_abc123'). "
+ "provided by the system when files are uploaded. "
"The tool will copy each artifact from the artifact store to the destination directory."
),
items=types.Schema(type=types.Type.STRING),
@@ -129,11 +94,7 @@ async def run_async(self, *, args: dict[str, Any], tool_context: ToolContext) ->
return "Error: Artifact service is not available in this context."
try:
- staging_root = get_session_staging_path(
- session_id=tool_context.session.id,
- app_name=tool_context._invocation_context.app_name,
- skills_directory=self._skills_directory,
- )
+ staging_root = get_session_path(session_id=tool_context.session.id)
destination_dir = (staging_root / destination_path_str).resolve()
# Security: Ensure the destination is within the staging path
@@ -142,23 +103,68 @@ async def run_async(self, *, args: dict[str, Any], tool_context: ToolContext) ->
destination_dir.mkdir(parents=True, exist_ok=True)
- output_paths = []
+ staged_files = []
for name in artifact_names:
artifact = await tool_context.load_artifact(name)
if artifact is None or artifact.inline_data is None:
logger.warning('Artifact "%s" not found or has no data, skipping', name)
continue
- output_file = destination_dir / name
+ # Check file size
+ data_size = len(artifact.inline_data.data)
+ if data_size > MAX_ARTIFACT_SIZE_BYTES:
+ size_mb = data_size / (1024 * 1024)
+ logger.warning(f'Artifact "{name}" exceeds size limit: {size_mb:.1f} MB')
+ continue
+
+ # Use artifact name as filename (frontend should provide meaningful names)
+ # If name has no extension, try to infer from MIME type
+ filename = self._ensure_proper_extension(name, artifact.inline_data.mime_type)
+ output_file = destination_dir / filename
+
+ # Write file to disk
output_file.write_bytes(artifact.inline_data.data)
+
relative_path = output_file.relative_to(staging_root)
- output_paths.append(str(relative_path))
+ size_kb = data_size / 1024
+ staged_files.append(f"{relative_path} ({size_kb:.1f} KB)")
+
+ logger.info(f"Staged artifact: {name} -> {relative_path} ({size_kb:.1f} KB)")
- if not output_paths:
+ if not staged_files:
return "No valid artifacts were staged."
- return f"Successfully staged {len(output_paths)} artifact(s) to: {', '.join(output_paths)}"
+ return f"Successfully staged {len(staged_files)} file(s):\n" + "\n".join(
+ f" • {file}" for file in staged_files
+ )
except Exception as e:
logger.error("Error staging artifacts: %s", e, exc_info=True)
return f"An error occurred while staging artifacts: {e}"
+
+ def _ensure_proper_extension(self, filename: str, mime_type: str) -> str:
+ """Ensure filename has proper extension based on MIME type.
+
+ If filename already has an extension, keep it.
+ If not, add extension based on MIME type.
+
+ Args:
+ filename: Original filename from artifact
+ mime_type: MIME type of the file
+
+ Returns:
+ Filename with proper extension
+ """
+ if not filename or not mime_type:
+ return filename
+
+ # If filename already has an extension, use it
+ if Path(filename).suffix:
+ return filename
+
+ # Try to infer extension from MIME type
+ extension = mimetypes.guess_extension(mime_type)
+ if extension:
+ return f"{filename}{extension}"
+
+ return filename
diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py
index 015e2a6dd..e686bed9c 100644
--- a/python/packages/kagent-adk/src/kagent/adk/cli.py
+++ b/python/packages/kagent-adk/src/kagent/adk/cli.py
@@ -13,7 +13,7 @@
from .skill_fetcher import fetch_skill
from . import AgentConfig, KAgentApp
-from .skills.skills_plugin import add_skills_tool_to_agent
+from .skills.skills_plugin import SkillsPlugin
logger = logging.getLogger(__name__)
logging.getLogger("google_adk.google.adk.tools.base_authenticated_tool").setLevel(logging.ERROR)
@@ -41,9 +41,11 @@ def static(
skills_directory = os.getenv("KAGENT_SKILLS_FOLDER", None)
if skills_directory:
logger.info(f"Adding skills from directory: {skills_directory}")
- add_skills_tool_to_agent(skills_directory, root_agent)
+ plugins = [SkillsPlugin(skills_directory=skills_directory)]
- kagent_app = KAgentApp(root_agent, agent_card, app_cfg.url, app_cfg.app_name)
+ kagent_app = KAgentApp(
+ root_agent, agent_card, app_cfg.url, app_cfg.app_name, plugins=plugins if skills_directory else None
+ )
server = kagent_app.build()
configure_tracing(server)
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/README.md b/python/packages/kagent-adk/src/kagent/adk/skills/README.md
index 10698544f..d997d9efb 100644
--- a/python/packages/kagent-adk/src/kagent/adk/skills/README.md
+++ b/python/packages/kagent-adk/src/kagent/adk/skills/README.md
@@ -1,217 +1,217 @@
# ADK Skills
-Filesystem-based skills with progressive disclosure and two-tool architecture.
+Filesystem-based skills with progressive disclosure and two-tool architecture for domain expertise.
---
-## Overview
-
-Skills enable agents to specialize in domain expertise without bloating the main context. The **two-tool pattern** separates concerns:
-
-- **SkillsTool** - Loads skill instructions
-- **BashTool** - Executes commands
-- **Semantic clarity** leads to better LLM reasoning
-
-### Skill Structure
-
-```text
-skills/
-├── data-analysis/
-│ ├── SKILL.md # Metadata + instructions (YAML frontmatter)
-│ └── scripts/
-│ └── analyze.py
-└── pdf-processing/
- ├── SKILL.md
- └── scripts/
-```
+## Quick Start
-**SKILL.md:**
+### Recommended: Plugin-Based (Multi-Agent Apps)
-```markdown
----
-name: data-analysis
-description: Analyze CSV/Excel files
----
-
-# Data Analysis
+```python
+from kagent.adk.skills import SkillsPlugin
-...instructions...
+# Plugin automatically initializes sessions and registers all skills tools
+app = App(
+ root_agent=agent,
+ plugins=[SkillsPlugin(skills_directory="./skills")]
+)
```
----
+**Benefits:**
-## Quick Start
+- ✅ Session paths initialized before any tool runs
+- ✅ Automatic tool registration on all agents
+- ✅ Handles custom skills directories correctly
+- ✅ No tool call order dependencies
-**Two-Tool Pattern (Recommended):**
+### Alternative: Direct Tool Usage
```python
-from kagent.adk.skills import SkillsTool, BashTool, StageArtifactsTool
+from kagent.adk.skills import SkillsTool
+from kagent.adk.tools import BashTool, ReadFileTool, WriteFileTool, EditFileTool
agent = Agent(
tools=[
SkillsTool(skills_directory="./skills"),
BashTool(skills_directory="./skills"),
- StageArtifactsTool(skills_directory="./skills"),
+ ReadFileTool(),
+ WriteFileTool(),
+ EditFileTool(),
]
)
```
-**With Plugin (Multi-Agent Apps):**
+**Note:** Without SkillsPlugin, sessions auto-initialize with `/skills` directory. For custom skills paths, use the plugin.
-```python
-from kagent.adk.skills import SkillsPlugin
+---
-app = App(root_agent=agent, plugins=[SkillsPlugin(skills_directory="./skills")])
-```
+## Session Initialization
-**Legacy Single-Tool (Backward Compat):**
+Skills uses a **plugin-based initialization pattern** to ensure session working directories are set up before any tools run.
-```python
-from kagent.adk.skills import SkillsShellTool
+### How It Works
-agent = Agent(tools=[SkillsShellTool(skills_directory="./skills")])
+```text
+App Starts
+ ↓
+SkillsPlugin initialized with skills_directory
+ ↓
+First Agent Turn
+ ↓
+before_agent_callback() hook fires
+ ↓
+Session path initialized with skills symlink
+ ↓
+Tools registered on agent
+ ↓
+Tools execute (session already initialized)
```
----
-
-## How It Works
+**Key Points:**
-### Two-Tool Workflow
+- `SkillsPlugin.before_agent_callback()` fires **before any tool invocation**
+- Creates `/tmp/kagent/{session_id}/` with `skills/` symlink
+- All tools use `get_session_path(session_id)` which returns cached path
+- **No tool call order dependencies** - session always ready
-```mermaid
-sequenceDiagram
- participant A as Agent
- participant S as SkillsTool
- participant B as BashTool
-
- A->>S: skills(command='data-analysis')
- S-->>A: Full SKILL.md + base path
- A->>B: bash("cd skills/data-analysis && python scripts/analyze.py file.csv")
- B-->>A: Results
-```
-
-**Three Phases:**
+**Without Plugin:**
-1. **Discovery** - Agent sees available skills in tool description
-2. **Loading** - Invoke skill with `command='skill-name'` → returns full SKILL.md
-3. **Execution** - Use BashTool with instructions from SKILL.md
+- Tools auto-initialize session with default `/skills` on first call
+- Works fine if skills are at `/skills` location
+- For custom paths, use SkillsPlugin
---
## Architecture
-```mermaid
-graph LR
- Agent[Agent] -->|Load
skill details| SkillsTool["SkillsTool
(Discovery)"]
- Agent -->|Execute
commands| BashTool["BashTool
(Execution)"]
- SkillsTool -->|Embedded in
description| Skills["Available
Skills List"]
+### Skill Structure
+
+```text
+skills/
+├── data-analysis/
+│ ├── SKILL.md # Metadata (YAML frontmatter) + instructions
+│ └── scripts/ # Python scripts, configs, etc.
+│ └── analyze.py
```
-| Tool | Purpose | Input | Output |
-| ---------------------- | ------------------- | ---------------------- | ------------------------- |
-| **SkillsTool** | Load skill metadata | `command='skill-name'` | Full SKILL.md + base path |
-| **BashTool** | Execute safely | Command string | Script output |
-| **StageArtifactsTool** | Stage uploads | Artifact names | File paths in `uploads/` |
+**SKILL.md Example:**
+```markdown
+---
+name: data-analysis
+description: Analyze CSV/Excel files
---
-## File Handling
-
-User uploads → Artifact → Stage → Execute:
-
-```python
-# 1. Stage uploaded file
-stage_artifacts(artifact_names=["artifact_123"])
+# Data Analysis
-# 2. Use in skill script
-bash("cd skills/data-analysis && python scripts/analyze.py uploads/artifact_123")
+...instructions for the agent...
```
----
+### Tool Workflow
-## Security
+**Three Phases:**
-**SkillsTool:**
+1. **Discovery** - Agent sees available skills in SkillsTool description
+2. **Loading** - Agent calls `skills(command='data-analysis')` → gets full SKILL.md
+3. **Execution** - Agent uses BashTool + file tools to run scripts per instructions
-- ✅ Read-only (no execution)
-- ✅ Validates skill existence
-- ✅ Caches results
+| Tool | Purpose | Example |
+| -------------- | ---------------------------- | ----------------------------------------------------- |
+| **SkillsTool** | Load skill instructions | `skills(command='data-analysis')` |
+| **BashTool** | Execute commands | `bash("cd skills/data-analysis && python script.py")` |
+| **ReadFile** | Read files with line numbers | `read_file("skills/data-analysis/config.json")` |
+| **WriteFile** | Create/overwrite files | `write_file("outputs/report.pdf", data)` |
+| **EditFile** | Precise string replacements | `edit_file("script.py", old="x", new="y")` |
-**BashTool:**
+### Working Directory Structure
-- ✅ Whitelisted commands only (`ls`, `cat`, `python`, `pip`, etc.)
-- ✅ No destructive ops (`rm`, `mv`, `chmod` blocked)
-- ✅ Directory restrictions (no `..`)
-- ✅ 30-second timeout
-- ✅ Subprocess isolation
+Each session gets an isolated working directory with symlinked skills:
----
+```text
+/tmp/kagent/{session_id}/
+├── skills/ → symlink to /skills (read-only, shared across sessions)
+├── uploads/ → staged user files (writable)
+├── outputs/ → generated files for download (writable)
+└── *.py → temporary scripts (writable)
+```
-## Components
+**Path Resolution:**
-| File | Purpose |
-| ------------------------- | ---------------------------- |
-| `skills_invoke_tool.py` | Discovery & loading |
-| `bash_tool.py` | Command execution |
-| `stage_artifacts_tool.py` | File staging |
-| `skills_plugin.py` | Auto-registration (optional) |
-| `skills_shell_tool.py` | Legacy all-in-one |
+- Relative paths resolve from working directory: `skills/data-analysis/script.py`
+- Absolute paths work too: `/tmp/kagent/{session_id}/outputs/report.pdf`
+- Skills symlink enables natural relative references while maintaining security
---
-## Examples
+## Artifact Handling
-### Example 1: Data Analysis
+User uploads and downloads are managed through artifact tools:
```python
-# Agent loads skill
-agent.invoke(tools=[
- SkillsTool(skills_directory="./skills"),
- BashTool(skills_directory="./skills"),
-], prompt="Analyze this CSV file")
-
-# Agent flow:
-# 1. Calls: skills(command='data-analysis')
-# 2. Gets: Full SKILL.md with instructions
-# 3. Calls: bash("cd skills/data-analysis && python scripts/analyze.py file.csv")
-# 4. Returns: Analysis results
-```
+# 1. Stage uploaded file from artifact service
+stage_artifacts(artifact_names=["sales_data.csv"])
+# → Writes to: uploads/sales_data.csv
-### Example 2: Multi-Agent App
+# 2. Agent processes file
+bash("python skills/data-analysis/scripts/analyze.py uploads/sales_data.csv")
+# → Script writes: outputs/report.pdf
-```python
-# Register skills on all agents
-app = App(
- root_agent=agent,
- plugins=[SkillsPlugin(skills_directory="./skills")]
-)
+# 3. Return generated file
+return_artifacts(file_paths=["outputs/report.pdf"])
+# → Saves to artifact service for user download
```
+**Flow:** User Upload → Artifact Service → `uploads/` → Processing → `outputs/` → Artifact Service → User Download
+
---
-## Comparison with Claude
+## Security
+
+**Read-only skills directory:**
-ADK follows Claude's two-tool pattern exactly:
+- Skills at `/skills` are read-only (enforced by sandbox)
+- Symlink at `skills/` inherits read-only permissions
+- Agents cannot modify skill code or instructions
-| Aspect | Claude | ADK |
-| -------------- | ------------------- | ---------------------- |
-| Discovery tool | Skills tool | SkillsTool ✅ |
-| Execution tool | Bash tool | BashTool ✅ |
-| Parameter | `command` | `command` ✅ |
-| Pattern | Two-tool separation | Two-tool separation ✅ |
+**File tools:**
+
+- Path traversal protection (no `..`)
+- Session isolation (each session has separate working directory)
+- File size limits (100 MB max)
+
+**Bash tool:**
+
+- Sandboxed execution via Anthropic Sandbox Runtime
+- Command timeouts (30s default, 120s for pip install)
+- Working directory restrictions
---
-## What Changed
+## Example Agent Flow
+
+```python
+# User asks: "Analyze my sales data"
-**Before:** Single `SkillsShellTool` (all-in-one)
-**Now:** Two-tool architecture (discovery + execution)
+# 1. Agent discovers available skills
+# → SkillsTool description lists: data-analysis, pdf-processing, etc.
-| Feature | Before | After |
-| ---------------------- | --------- | ----------------- |
-| Semantic clarity | Mixed | Separated ✅ |
-| LLM reasoning | Implicit | Explicit ✅ |
-| Progressive disclosure | Guideline | Enforced ✅ |
-| Industry alignment | Custom | Claude pattern ✅ |
+# 2. Agent loads skill instructions
+agent: skills(command='data-analysis')
+# → Returns full SKILL.md with detailed instructions
-All previous code still works (backward compatible via `SkillsShellTool`).
+# 3. Agent stages uploaded file
+agent: stage_artifacts(artifact_names=["sales_data.csv"])
+# → File available at: uploads/sales_data.csv
+
+# 4. Agent reads skill script to understand it
+agent: read_file("skills/data-analysis/scripts/analyze.py")
+
+# 5. Agent executes analysis
+agent: bash("cd skills/data-analysis && python scripts/analyze.py ../../uploads/sales_data.csv")
+# → Script generates: outputs/analysis_report.pdf
+
+# 6. Agent returns result
+agent: return_artifacts(file_paths=["outputs/analysis_report.pdf"])
+# → User can download report.pdf
+```
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py b/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py
index 34fddb092..aa84d7e74 100644
--- a/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py
+++ b/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py
@@ -1,29 +1,10 @@
-# Copyright 2025 Google LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-from .bash_tool import BashTool
-from .skill_system_prompt import generate_shell_skills_system_prompt
from .skill_tool import SkillsTool
from .skills_plugin import SkillsPlugin
from .skills_toolset import SkillsToolset
-from .stage_artifacts_tool import StageArtifactsTool
__all__ = [
- "BashTool",
"SkillsTool",
"SkillsPlugin",
"SkillsToolset",
- "StageArtifactsTool",
"generate_shell_skills_system_prompt",
]
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/bash_tool.py b/python/packages/kagent-adk/src/kagent/adk/skills/bash_tool.py
deleted file mode 100644
index 6abed21a0..000000000
--- a/python/packages/kagent-adk/src/kagent/adk/skills/bash_tool.py
+++ /dev/null
@@ -1,244 +0,0 @@
-"""Simplified bash tool for executing shell commands in skills context."""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import os
-import shlex
-from pathlib import Path
-from typing import Any, Dict, List, Set, Union
-
-from google.adk.tools import BaseTool, ToolContext
-from google.genai import types
-
-from .stage_artifacts_tool import get_session_staging_path
-
-logger = logging.getLogger("kagent_adk." + __name__)
-
-
-class BashTool(BaseTool):
- """Execute bash commands safely in the skills environment.
-
- This tool is for terminal operations and script execution. Use it after loading
- skill instructions with the skills tool.
- """
-
- DANGEROUS_COMMANDS: Set[str] = {
- "rm",
- "rmdir",
- "mv",
- "cp",
- "chmod",
- "chown",
- "sudo",
- "su",
- "kill",
- "reboot",
- "shutdown",
- "dd",
- "mount",
- "umount",
- "alias",
- "export",
- "source",
- ".",
- "eval",
- "exec",
- }
-
- def __init__(self, skills_directory: str | Path):
- super().__init__(
- name="bash",
- description=(
- "Execute bash commands in the skills environment.\n\n"
- "Use this tool to:\n"
- "- Execute Python scripts from files (e.g., 'python scripts/script.py')\n"
- "- Install dependencies (e.g., 'pip install -r requirements.txt')\n"
- "- Navigate and inspect files (e.g., 'ls', 'cat file.txt')\n"
- "- Run shell commands with relative or absolute paths\n\n"
- "Important:\n"
- "- Always load skill instructions first using the skills tool\n"
- "- Execute scripts from within their skill directory using 'cd skills/SKILL_NAME && ...'\n"
- "- For Python code execution: ALWAYS write code to a file first, then run it with 'python file.py'\n"
- "- Never use 'python -c \"code\"' - write to file first instead\n"
- "- Quote paths with spaces (e.g., 'cd \"path with spaces\"')\n"
- "- pip install commands may take longer (120s timeout)\n"
- "- Python scripts have 60s timeout, other commands 30s\n\n"
- "Security:\n"
- "- Only whitelisted commands allowed (ls, cat, python, pip, etc.)\n"
- "- No destructive operations (rm, mv, chown, etc. blocked)\n"
- "- The sandbox environment provides additional isolation"
- ),
- )
- self.skills_directory = Path(skills_directory).resolve()
- if not self.skills_directory.exists():
- raise ValueError(f"Skills directory does not exist: {self.skills_directory}")
-
- def _get_declaration(self) -> types.FunctionDeclaration:
- return types.FunctionDeclaration(
- name=self.name,
- description=self.description,
- parameters=types.Schema(
- type=types.Type.OBJECT,
- properties={
- "command": types.Schema(
- type=types.Type.STRING,
- description="Bash command to execute. Use && to chain commands.",
- ),
- "description": types.Schema(
- type=types.Type.STRING,
- description="Clear, concise description of what this command does (5-10 words)",
- ),
- },
- required=["command"],
- ),
- )
-
- async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str:
- """Execute a bash command safely."""
- command = args.get("command", "").strip()
- description = args.get("description", "")
-
- if not command:
- return "Error: No command provided"
-
- if description:
- logger.info(f"Executing: {description}")
-
- try:
- parsed_commands = self._parse_and_validate_command(command)
- result = await self._execute_command_safely(parsed_commands, tool_context)
- logger.info(f"Executed bash command: {command}")
- return result
- except Exception as e:
- error_msg = f"Error executing command '{command}': {e}"
- logger.error(error_msg)
- return error_msg
-
- def _parse_and_validate_command(self, command: str) -> List[List[str]]:
- """Parse and validate command for security."""
- if "&&" in command:
- parts = [part.strip() for part in command.split("&&")]
- else:
- parts = [command]
-
- parsed_parts = []
- for part in parts:
- parsed_part = shlex.split(part)
- validation_error = self._validate_command_part(parsed_part)
- if validation_error:
- raise ValueError(validation_error)
- parsed_parts.append(parsed_part)
- return parsed_parts
-
- def _validate_command_part(self, command_parts: List[str]) -> Union[str, None]:
- """Validate a single command part for security."""
- if not command_parts:
- return "Empty command"
-
- base_command = command_parts[0]
-
- if base_command in self.DANGEROUS_COMMANDS:
- return f"Command '{base_command}' is not allowed for security reasons."
-
- return None
-
- async def _execute_command_safely(self, parsed_commands: List[List[str]], tool_context: ToolContext) -> str:
- """Execute parsed commands in the sandboxed environment."""
- staging_root = get_session_staging_path(
- session_id=tool_context.session.id,
- app_name=tool_context._invocation_context.app_name,
- skills_directory=self.skills_directory,
- )
- original_cwd = os.getcwd()
- output_parts = []
-
- try:
- os.chdir(staging_root)
-
- for i, command_parts in enumerate(parsed_commands):
- if i > 0:
- output_parts.append(f"\n--- Command {i + 1} ---")
-
- if command_parts[0] == "cd":
- if len(command_parts) > 1:
- target_path = command_parts[1]
- try:
- # Resolve the path relative to current directory
- target_abs = (Path(os.getcwd()) / target_path).resolve()
- os.chdir(target_abs)
- current_cwd = os.getcwd()
- output_parts.append(f"Changed directory to {target_path}")
- logger.info(f"Changed to {target_path}. Current cwd: {current_cwd}")
- except (OSError, RuntimeError) as e:
- output_parts.append(f"Error changing directory: {e}")
- logger.error(f"Failed to cd to {target_path}: {e}")
- continue
-
- # Determine timeout based on command type
- timeout = self._get_command_timeout(command_parts)
- current_cwd = os.getcwd()
-
- try:
- process = await asyncio.create_subprocess_exec(
- *command_parts,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- cwd=current_cwd,
- )
- try:
- stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
- except asyncio.TimeoutError:
- process.kill()
- await process.wait()
- error_msg = f"Command '{' '.join(command_parts)}' timed out after {timeout}s"
- output_parts.append(f"Error: {error_msg}")
- logger.error(error_msg)
- break
-
- stdout_str = stdout.decode("utf-8", errors="replace") if stdout else ""
- stderr_str = stderr.decode("utf-8", errors="replace") if stderr else ""
-
- if process.returncode != 0:
- output = stderr_str or stdout_str
- error_output = f"Command failed with exit code {process.returncode}:\n{output}"
- output_parts.append(error_output)
- # Don't break on pip errors, continue to allow retry
- if command_parts[0] not in ("pip", "pip3"):
- break
- else:
- # Combine stdout and stderr for complete output
- combined_output = stdout_str
- if stderr_str and "WARNING" not in stderr_str:
- combined_output += f"\n{stderr_str}"
- output_parts.append(
- combined_output.strip() if combined_output.strip() else "Command completed successfully."
- )
- except Exception as e:
- error_msg = f"Error executing '{' '.join(command_parts)}': {str(e)}"
- output_parts.append(error_msg)
- logger.error(error_msg)
- break
-
- return "\n".join(output_parts)
-
- except Exception as e:
- return f"Error executing command: {e}"
- finally:
- os.chdir(original_cwd)
-
- def _get_command_timeout(self, command_parts: List[str]) -> int:
- """Determine appropriate timeout for command type."""
- if not command_parts:
- return 30
-
- base_command = command_parts[0]
-
- # Extended timeouts for package management operations
- if base_command in ("pip", "pip3"):
- return 120 # 2 minutes for pip operations
- elif base_command in ("python", "python3"):
- return 60 # 1 minute for python scripts
- else:
- return 30 # 30 seconds for other commands
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skill_system_prompt.py b/python/packages/kagent-adk/src/kagent/adk/skills/skill_system_prompt.py
deleted file mode 100644
index ae8ab6c7c..000000000
--- a/python/packages/kagent-adk/src/kagent/adk/skills/skill_system_prompt.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""Optional comprehensive system prompt for skills-focused agents.
-
-This module provides an enhanced, verbose system prompt for agents that are
-heavily focused on skills usage. It is NOT required for basic skills functionality,
-as the SkillsShellTool already includes sufficient guidance in its description.
-
-Use this when:
-- You want extremely detailed procedural guidance for the agent
-- The agent's primary purpose is to work with skills
-- You want to emphasize specific workflows or best practices
-
-For most use cases, simply adding SkillsShellTool to your agent is sufficient.
-The tool's description already includes all necessary guidance for skills usage.
-
-Example usage:
- # Basic usage (recommended for most cases):
- agent = Agent(
- tools=[SkillsShellTool(skills_directory="./skills")]
- )
-
- # Enhanced usage (for skills-focused agents):
- agent = Agent(
- instruction=generate_shell_skills_system_prompt("./skills"),
- tools=[SkillsShellTool(skills_directory="./skills")]
- )
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Optional
-
-from google.adk.agents.readonly_context import ReadonlyContext
-
-
-def generate_shell_skills_system_prompt(
- skills_directory: str | Path, readonly_context: Optional[ReadonlyContext] = None
-) -> str:
- """Generate a comprehensive, verbose system prompt for shell-based skills usage.
-
- This function provides an enhanced system prompt with detailed procedural guidance
- for agents that heavily focus on skills usage. It supplements the guidance already
- present in the SkillsShellTool's description.
-
- Note: This is optional. The SkillsShellTool already includes sufficient guidance
- in its description for most use cases.
-
- Args:
- skills_directory: Path to directory containing skill folders (currently unused,
- kept for API compatibility)
- readonly_context: Optional context (currently unused, kept for API compatibility)
-
- Returns:
- A comprehensive system prompt string with detailed skills usage guidance.
- """
- prompt = """# Skills System - Two-Tool Architecture
-
-You have access to specialized skills through two complementary tools: the `skills` tool and the `bash` tool.
-
-## Overview
-
-Skills provide specialized domain expertise through instructions, scripts, and reference materials. You access them using a two-phase approach:
-1. **Discovery & Loading**: Use the `skills` tool to invoke a skill and load its instructions
-2. **Execution**: Use the `bash` tool to execute commands based on the skill's guidance
-
-## Workflow for User-Uploaded Files
-
-When a user uploads a file, it is saved as an artifact. To use it with skills, follow this two-step process:
-
-1. **Stage the Artifact:** Use the `stage_artifacts` tool to copy the file from the artifact store to your local `uploads/` directory. The system will tell you the artifact name (e.g., `artifact_...`).
- ```
- stage_artifacts(artifact_names=["artifact_..."])
- ```
-2. **Use the Staged File:** After staging, the tool will return the new path (e.g., `uploads/artifact_...`). You can now use this path in your `bash` commands.
- ```
- bash("python skills/data-analysis/scripts/data_quality_check.py uploads/artifact_...")
- ```
-
-## Using the Skills Tool
-
-The `skills` tool discovers and loads skill instructions:
-
-### Discovery
-Available skills are listed in the tool's description under ``. Review these to find relevant capabilities.
-
-### Loading a Skill
-Invoke a skill by name to load its full SKILL.md instructions:
-- `skills(command="data-analysis")` - Load data analysis skill
-- `skills(command="pdf-processing")` - Load PDF processing skill
-
-When you invoke a skill, you'll see: `The "skill-name" skill is loading` followed by the skill's complete instructions.
-
-## Using the Bash Tool
-
-The `bash` tool executes commands in a sandboxed environment. Use it after loading a skill's instructions:
-
-### Common Commands
-- `bash("cd skills/SKILL_NAME && python scripts/SCRIPT.py arg1")` - Execute a skill's script
-- `bash("pip install -r skills/SKILL_NAME/requirements.txt")` - Install dependencies
-- `bash("ls skills/SKILL_NAME")` - List skill files
-- `bash("cat skills/SKILL_NAME/reference.md")` - Read additional documentation
-
-### Command Chaining
-Chain multiple commands with `&&`:
-```
-bash("cd skills/data-analysis && pip install -r requirements.txt && python scripts/analyze.py data.csv")
-```
-
-## Progressive Disclosure Strategy
-
-1. **Review Available Skills**: Check the `` section in the skills tool description to find relevant capabilities
-2. **Invoke Relevant Skill**: Use `skills(command="skill-name")` to load full instructions
-3. **Follow Instructions**: Read the loaded SKILL.md carefully
-4. **Execute with Bash**: Use `bash` tool to run commands, install dependencies, and execute scripts as instructed
-
-## Best Practices
-
-### 1. **Dependency Management**
-- **Before using a script**, check for a `requirements.txt` file
-- Install dependencies with: `bash("pip install -r skills/SKILL_NAME/requirements.txt")`
-
-### 2. **Efficient Workflow**
-- Only invoke skills when needed for the task
-- Don't invoke a skill that's already loaded in the conversation
-- Read skill instructions carefully before executing
-
-### 3. **Script Usage**
-- **Always** execute scripts from within their skill directory: `bash("cd skills/SKILL_NAME && python scripts/SCRIPT.py")`
-- Check script documentation in the SKILL.md before running
-- Quote paths with spaces: `bash("cd \"path with spaces\" && python script.py")`
-
-### 4. **Error Handling**
-- If a bash command fails, read the error message carefully
-- Check that dependencies are installed
-- Verify file paths are correct
-- Ensure you're in the correct directory
-
-## Security and Safety
-
-Both tools are sandboxed for safety:
-
-**Skills Tool:**
-- Read-only access to skill files
-- No execution capability
-- Only loads documented skills
-
-**Bash Tool:**
-- **Safe Commands Only**: Only whitelisted commands like `ls`, `cat`, `grep`, `pip`, and `python` are allowed
-- **No Destructive Changes**: Commands like `rm`, `mv`, or `chmod` are blocked
-- **Directory Restrictions**: You cannot access files outside of the skills directory
-- **Timeout Protection**: Commands limited to 30 seconds
-
-## Example Workflow
-
-User asks: "Analyze this CSV file"
-
-1. **Review Skills**: Check `` in skills tool → See "data-analysis" skill
-2. **Invoke Skill**: `skills(command="data-analysis")` → Receive full instructions
-3. **Stage File**: `stage_artifacts(artifact_names=["artifact_123"])` → File at `uploads/artifact_123`
-4. **Install Deps**: `bash("pip install -r skills/data-analysis/requirements.txt")` → Dependencies installed
-5. **Execute Script**: `bash("cd skills/data-analysis && python scripts/analyze.py uploads/artifact_123")` → Get results
-6. **Present Results**: Share analysis with user
-
-Remember: Skills are your specialized knowledge repositories. Use the skills tool to discover and load them, then use the bash tool to execute their instructions."""
- return prompt
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py b/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py
index 95684ce74..56852e7ed 100644
--- a/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py
+++ b/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py
@@ -71,6 +71,7 @@ def _generate_description_with_skills(self) -> str:
"- Only use skills listed in below\n"
"- Do not invoke a skill that is already loaded in the conversation\n"
"- After loading a skill, use the bash tool for execution\n"
+ "- If not specified, scripts are located in the skill-name/scripts subdirectory\n"
"\n\n"
)
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py b/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py
index 64680cadd..d14a07f03 100644
--- a/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py
+++ b/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py
@@ -1,17 +1,3 @@
-# Copyright 2025 Google LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
from __future__ import annotations
import logging
@@ -23,7 +9,8 @@
from google.adk.plugins import BasePlugin
from google.genai import types
-from .bash_tool import BashTool
+from ..artifacts import initialize_session_path
+from ..tools import BashTool, EditFileTool, ReadFileTool, WriteFileTool
from .skill_tool import SkillsTool
logger = logging.getLogger("kagent_adk." + __name__)
@@ -33,8 +20,8 @@ class SkillsPlugin(BasePlugin):
"""Convenience plugin for multi-agent apps to automatically register Skills tools.
This plugin is purely a convenience wrapper that automatically adds the SkillsTool
- and BashTool to all LLM agents in an application. It does not add any additional
- functionality beyond tool registration.
+ and BashTool and related file tools to all LLM agents in an application.
+ It does not add any additional functionality beyond tool registration.
For single-agent use cases or when you prefer explicit control, you can skip this plugin
and directly add both tools to your agent's tools list.
@@ -45,6 +32,9 @@ class SkillsPlugin(BasePlugin):
tools=[
SkillsTool(skills_directory="./skills"),
BashTool(skills_directory="./skills"),
+ ReadFileTool(),
+ WriteFileTool(),
+ EditFileTool(),
]
)
@@ -68,7 +58,16 @@ def __init__(self, skills_directory: str | Path, name: str = "skills_plugin"):
async def before_agent_callback(
self, *, agent: BaseAgent, callback_context: CallbackContext
) -> Optional[types.Content]:
- """Add skills tools to agents if not already present."""
+ """Initialize session path and add skills tools to agents if not already present.
+
+ This hook fires before any tools are invoked, ensuring the session working
+ directory is set up with the skills symlink before any tool needs it.
+ """
+ # Initialize session path FIRST (before tools run)
+ # This creates the working directory structure and skills symlink
+ session_id = callback_context.session.id
+ initialize_session_path(session_id, str(self.skills_directory))
+ logger.debug(f"Initialized session path for session: {session_id}")
add_skills_tool_to_agent(self.skills_directory, agent)
@@ -96,3 +95,15 @@ def add_skills_tool_to_agent(skills_directory: str | Path, agent: BaseAgent) ->
if "bash" not in existing_tool_names:
agent.tools.append(BashTool(skills_directory))
logger.debug(f"Added bash tool to agent: {agent.name}")
+
+ if "read_file" not in existing_tool_names:
+ agent.tools.append(ReadFileTool())
+ logger.debug(f"Added read file tool to agent: {agent.name}")
+
+ if "write_file" not in existing_tool_names:
+ agent.tools.append(WriteFileTool())
+ logger.debug(f"Added write file tool to agent: {agent.name}")
+
+ if "edit_file" not in existing_tool_names:
+ agent.tools.append(EditFileTool())
+ logger.debug(f"Added edit file tool to agent: {agent.name}")
diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skills_toolset.py b/python/packages/kagent-adk/src/kagent/adk/skills/skills_toolset.py
index 822868419..5653b20dd 100644
--- a/python/packages/kagent-adk/src/kagent/adk/skills/skills_toolset.py
+++ b/python/packages/kagent-adk/src/kagent/adk/skills/skills_toolset.py
@@ -13,22 +13,27 @@
from google.adk.tools import BaseTool
from google.adk.tools.base_toolset import BaseToolset
-from .bash_tool import BashTool
+from ..tools import BashTool, EditFileTool, ReadFileTool, WriteFileTool
from .skill_tool import SkillsTool
logger = logging.getLogger("kagent_adk." + __name__)
class SkillsToolset(BaseToolset):
- """Toolset that provides Skills functionality through two focused tools.
+ """Toolset that provides Skills functionality for domain expertise execution.
- This toolset provides skills access through two complementary tools following
- progressive disclosure:
+ This toolset provides skills access through specialized tools:
1. SkillsTool - Discover and load skill instructions
- 2. BashTool - Execute commands based on skill guidance
+ 2. ReadFileTool - Read files with line numbers
+ 3. WriteFileTool - Write/create files
+ 4. EditFileTool - Edit files with precise replacements
+ 5. BashTool - Execute shell commands
- This separation provides clear semantic distinction between skill discovery
- (what can I do?) and skill execution (how do I do it?).
+ Skills provide specialized domain knowledge and scripts that the agent can use
+ to solve complex tasks. The toolset enables discovery of available skills,
+ file manipulation, and command execution.
+
+ Note: For file upload/download, use the ArtifactsToolset separately.
"""
def __init__(self, skills_directory: str | Path):
@@ -40,15 +45,24 @@ def __init__(self, skills_directory: str | Path):
super().__init__()
self.skills_directory = Path(skills_directory)
- # Create the two tools for skills operations
- self.skills_invoke_tool = SkillsTool(skills_directory)
+ # Create skills tools
+ self.skills_tool = SkillsTool(skills_directory)
+ self.read_file_tool = ReadFileTool()
+ self.write_file_tool = WriteFileTool()
+ self.edit_file_tool = EditFileTool()
self.bash_tool = BashTool(skills_directory)
@override
async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> List[BaseTool]:
- """Get both skills tools.
+ """Get all skills tools.
Returns:
- List containing SkillsTool and BashTool.
+ List containing all skills tools: skills, read, write, edit, and bash.
"""
- return [self.skills_invoke_tool, self.bash_tool]
+ return [
+ self.skills_tool,
+ self.read_file_tool,
+ self.write_file_tool,
+ self.edit_file_tool,
+ self.bash_tool,
+ ]
diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py b/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py
new file mode 100644
index 000000000..e844c4b7d
--- /dev/null
+++ b/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py
@@ -0,0 +1,9 @@
+from .bash_tool import BashTool
+from .file_tools import EditFileTool, ReadFileTool, WriteFileTool
+
+__all__ = [
+ "BashTool",
+ "EditFileTool",
+ "ReadFileTool",
+ "WriteFileTool",
+]
diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/bash_tool.py b/python/packages/kagent-adk/src/kagent/adk/tools/bash_tool.py
new file mode 100644
index 000000000..1b6ca0f21
--- /dev/null
+++ b/python/packages/kagent-adk/src/kagent/adk/tools/bash_tool.py
@@ -0,0 +1,176 @@
+"""Simplified bash tool for executing shell commands in skills context."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+from pathlib import Path
+from typing import Any, Dict
+
+from google.adk.tools import BaseTool, ToolContext
+from google.genai import types
+
+from ..artifacts import get_session_path
+
+logger = logging.getLogger("kagent_adk." + __name__)
+
+
+class BashTool(BaseTool):
+ """Execute bash commands safely in the skills environment.
+
+ This tool uses the Anthropic Sandbox Runtime (srt) to execute commands with:
+ - Filesystem restrictions (controlled read/write access)
+ - Network restrictions (controlled domain access)
+ - Process isolation at the OS level
+
+ Use it for command-line operations like running scripts, installing packages, etc.
+ For file operations (read/write/edit), use the dedicated file tools instead.
+ """
+
+ def __init__(self, skills_directory: str | Path):
+ super().__init__(
+ name="bash",
+ description=(
+ "Execute bash commands in the skills environment with sandbox protection.\n\n"
+ "Working Directory & Structure:\n"
+ "- Commands run in a temporary session directory: /tmp/kagent/{session_id}/\n"
+ "- /skills -> All skills are available here (read-only).\n"
+ "- Your current working directory is added to PYTHONPATH.\n\n"
+ "Python Imports (CRITICAL):\n"
+ "- To import from a skill, use the full path from the 'skills' root.\n"
+ " Example: from skills.skills_name.module import function\n\n"
+ "- If the skills name contains a dash '-', you need to use importlib to import it.\n"
+ " Example:\n"
+ " import importlib\n"
+ " skill_module = importlib.import_module('skills.skill-name.module')\n\n"
+ "For file operations:\n"
+ "- Use read_file, write_file, and edit_file for interacting with the filesystem.\n\n"
+ "Timeouts:\n"
+ "- pip install: 120s\n"
+ "- python scripts: 60s\n"
+ "- other commands: 30s\n"
+ ),
+ )
+ self.skills_directory = Path(skills_directory).resolve()
+ if not self.skills_directory.exists():
+ raise ValueError(f"Skills directory does not exist: {self.skills_directory}")
+
+ def _get_declaration(self) -> types.FunctionDeclaration:
+ return types.FunctionDeclaration(
+ name=self.name,
+ description=self.description,
+ parameters=types.Schema(
+ type=types.Type.OBJECT,
+ properties={
+ "command": types.Schema(
+ type=types.Type.STRING,
+ description="Bash command to execute. Use && to chain commands.",
+ ),
+ "description": types.Schema(
+ type=types.Type.STRING,
+ description="Clear, concise description of what this command does (5-10 words)",
+ ),
+ },
+ required=["command"],
+ ),
+ )
+
+ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str:
+ """Execute a bash command safely using the Anthropic Sandbox Runtime."""
+ command = args.get("command", "").strip()
+ description = args.get("description", "")
+
+ if not command:
+ return "Error: No command provided"
+
+ try:
+ result = await self._execute_command_with_srt(command, tool_context)
+ logger.info(f"Executed bash command: {command}, description: {description}")
+ return result
+ except Exception as e:
+ error_msg = f"Error executing command '{command}': {e}"
+ logger.error(error_msg)
+ return error_msg
+
+ async def _execute_command_with_srt(self, command: str, tool_context: ToolContext) -> str:
+ """Execute a bash command safely using the Anthropic Sandbox Runtime.
+
+ The srt (Sandbox Runtime) wraps the command in a secure sandbox that enforces
+ filesystem and network restrictions at the OS level.
+
+ The working directory is a temporary session path, which contains:
+ - uploads/: staged user files
+ - outputs/: location for generated files
+ The /skills directory is available at the root and on the PYTHONPATH.
+ """
+ # Get session working directory (initialized by SkillsPlugin)
+ working_dir = get_session_path(session_id=tool_context.session.id)
+
+ # Determine timeout based on command
+ timeout = self._get_command_timeout_seconds(command)
+
+ # Prepare environment with PYTHONPATH including skills directory
+ # This allows imports like: from skills.slack_gif_creator.core import something
+ env = os.environ.copy()
+ # Add root for 'from skills...' and working_dir for local scripts
+ pythonpath_additions = [str(working_dir), "/"]
+ if "PYTHONPATH" in env:
+ pythonpath_additions.append(env["PYTHONPATH"])
+ env["PYTHONPATH"] = ":".join(pythonpath_additions)
+
+ # Execute with sandbox runtime
+ sandboxed_command = f'srt "{command}"'
+
+ try:
+ process = await asyncio.create_subprocess_shell(
+ sandboxed_command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=working_dir,
+ env=env, # Pass the modified environment
+ )
+
+ try:
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ process.kill()
+ await process.wait()
+ return f"Error: Command timed out after {timeout}s"
+
+ stdout_str = stdout.decode("utf-8", errors="replace") if stdout else ""
+ stderr_str = stderr.decode("utf-8", errors="replace") if stderr else ""
+
+ # Handle command failure
+ if process.returncode != 0:
+ error_msg = f"Command failed with exit code {process.returncode}"
+ if stderr_str:
+ error_msg += f":\n{stderr_str}"
+ elif stdout_str:
+ error_msg += f":\n{stdout_str}"
+ return error_msg
+
+ # Return output
+ output = stdout_str
+ if stderr_str and "WARNING" not in stderr_str:
+ output += f"\n{stderr_str}"
+
+ return output.strip() if output.strip() else "Command completed successfully."
+
+ except Exception as e:
+ logger.error(f"Error executing command: {e}")
+ return f"Error: {e}"
+
+ def _get_command_timeout_seconds(self, command: str) -> float:
+ """Determine appropriate timeout for command in seconds.
+
+ Based on the command string, determine the timeout. srt timeout is in milliseconds,
+ so we return seconds for asyncio compatibility.
+ """
+ # Check for keywords in the command to determine timeout
+ if "pip install" in command or "pip3 install" in command:
+ return 120.0 # 2 minutes for package installations
+ elif "python " in command or "python3 " in command:
+ return 60.0 # 1 minute for python scripts
+ else:
+ return 30.0 # 30 seconds for other commands
diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py b/python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py
new file mode 100644
index 000000000..a6675e9e3
--- /dev/null
+++ b/python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py
@@ -0,0 +1,283 @@
+"""File operation tools for agent skills.
+
+This module provides Read, Write, and Edit tools that agents can use to work with
+files on the filesystem within the sandbox environment.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any, Dict
+
+from google.adk.tools import BaseTool, ToolContext
+from google.genai import types
+
+from ..artifacts import get_session_path
+
+logger = logging.getLogger("kagent_adk." + __name__)
+
+
+class ReadFileTool(BaseTool):
+ """Read files with line numbers for precise editing."""
+
+ def __init__(self):
+ super().__init__(
+ name="read_file",
+ description=(
+ "Reads a file from the filesystem with line numbers.\n\n"
+ "Usage:\n"
+ "- Provide a path to the file (absolute or relative to your working directory)\n"
+ "- Returns content with line numbers (format: LINE_NUMBER|CONTENT)\n"
+ "- Optional offset and limit parameters for reading specific line ranges\n"
+ "- Lines longer than 2000 characters are truncated\n"
+ "- Always read a file before editing it\n"
+ "- You can read from skills/ directory, uploads/, outputs/, or any file in your session\n"
+ ),
+ )
+
+ def _get_declaration(self) -> types.FunctionDeclaration:
+ return types.FunctionDeclaration(
+ name=self.name,
+ description=self.description,
+ parameters=types.Schema(
+ type=types.Type.OBJECT,
+ properties={
+ "file_path": types.Schema(
+ type=types.Type.STRING,
+ description="Path to the file to read (absolute or relative to working directory)",
+ ),
+ "offset": types.Schema(
+ type=types.Type.INTEGER,
+ description="Optional line number to start reading from (1-indexed)",
+ ),
+ "limit": types.Schema(
+ type=types.Type.INTEGER,
+ description="Optional number of lines to read",
+ ),
+ },
+ required=["file_path"],
+ ),
+ )
+
+ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str:
+ """Read a file with line numbers."""
+ file_path = args.get("file_path", "").strip()
+ offset = args.get("offset")
+ limit = args.get("limit")
+
+ if not file_path:
+ return "Error: No file path provided"
+
+ # Resolve path relative to session working directory
+ working_dir = get_session_path(session_id=tool_context.session.id)
+ path = Path(file_path)
+ if not path.is_absolute():
+ path = working_dir / path
+ path = path.resolve()
+
+ if not path.exists():
+ return f"Error: File not found: {file_path}"
+
+ if not path.is_file():
+ return f"Error: Path is not a file: {file_path}\nThis tool can only read files, not directories."
+
+ try:
+ lines = path.read_text().splitlines()
+ except Exception as e:
+ return f"Error reading file {file_path}: {e}"
+
+ # Handle offset and limit
+ start = (offset - 1) if offset and offset > 0 else 0
+ end = (start + limit) if limit else len(lines)
+
+ # Format with line numbers
+ result_lines = []
+ for i, line in enumerate(lines[start:end], start=start + 1):
+ # Truncate long lines
+ if len(line) > 2000:
+ line = line[:2000] + "..."
+ result_lines.append(f"{i:6d}|{line}")
+
+ if not result_lines:
+ return "File is empty."
+
+ return "\n".join(result_lines)
+
+
+class WriteFileTool(BaseTool):
+ """Write content to files (overwrites existing files)."""
+
+ def __init__(self):
+ super().__init__(
+ name="write_file",
+ description=(
+ "Writes content to a file on the filesystem.\n\n"
+ "Usage:\n"
+ "- Provide a path (absolute or relative to working directory) and content to write\n"
+ "- Overwrites existing files\n"
+ "- Creates parent directories if needed\n"
+ "- For existing files, read them first using read_file\n"
+ "- Prefer editing existing files over writing new ones\n"
+ "- You can write to your working directory, outputs/, or any writable location\n"
+ "- Note: skills/ directory is read-only\n"
+ ),
+ )
+
+ def _get_declaration(self) -> types.FunctionDeclaration:
+ return types.FunctionDeclaration(
+ name=self.name,
+ description=self.description,
+ parameters=types.Schema(
+ type=types.Type.OBJECT,
+ properties={
+ "file_path": types.Schema(
+ type=types.Type.STRING,
+ description="Path to the file to write (absolute or relative to working directory)",
+ ),
+ "content": types.Schema(
+ type=types.Type.STRING,
+ description="Content to write to the file",
+ ),
+ },
+ required=["file_path", "content"],
+ ),
+ )
+
+ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str:
+ """Write content to a file."""
+ file_path = args.get("file_path", "").strip()
+ content = args.get("content", "")
+
+ if not file_path:
+ return "Error: No file path provided"
+
+ # Resolve path relative to session working directory
+ working_dir = get_session_path(session_id=tool_context.session.id)
+ path = Path(file_path)
+ if not path.is_absolute():
+ path = working_dir / path
+ path = path.resolve()
+
+ try:
+ # Create parent directories if needed
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ logger.info(f"Successfully wrote to {file_path}")
+ return f"Successfully wrote to {file_path}"
+ except Exception as e:
+ error_msg = f"Error writing file {file_path}: {e}"
+ logger.error(error_msg)
+ return error_msg
+
+
+class EditFileTool(BaseTool):
+ """Edit files by replacing exact string matches."""
+
+ def __init__(self):
+ super().__init__(
+ name="edit_file",
+ description=(
+ "Performs exact string replacements in files.\n\n"
+ "Usage:\n"
+ "- You must read the file first using read_file\n"
+ "- Provide path (absolute or relative to working directory)\n"
+ "- When editing, preserve exact indentation from the file content\n"
+ "- Do NOT include line number prefixes in old_string or new_string\n"
+ "- old_string must be unique unless replace_all=true\n"
+ "- Use replace_all to rename variables/strings throughout the file\n"
+ "- old_string and new_string must be different\n"
+ "- Note: skills/ directory is read-only\n"
+ ),
+ )
+
+ def _get_declaration(self) -> types.FunctionDeclaration:
+ return types.FunctionDeclaration(
+ name=self.name,
+ description=self.description,
+ parameters=types.Schema(
+ type=types.Type.OBJECT,
+ properties={
+ "file_path": types.Schema(
+ type=types.Type.STRING,
+ description="Path to the file to edit (absolute or relative to working directory)",
+ ),
+ "old_string": types.Schema(
+ type=types.Type.STRING,
+ description="The exact text to replace (must exist in file)",
+ ),
+ "new_string": types.Schema(
+ type=types.Type.STRING,
+ description="The text to replace it with (must be different from old_string)",
+ ),
+ "replace_all": types.Schema(
+ type=types.Type.BOOLEAN,
+ description="Replace all occurrences (default: false, only replaces first occurrence)",
+ ),
+ },
+ required=["file_path", "old_string", "new_string"],
+ ),
+ )
+
+ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str:
+ """Edit a file by replacing old_string with new_string."""
+ file_path = args.get("file_path", "").strip()
+ old_string = args.get("old_string", "")
+ new_string = args.get("new_string", "")
+ replace_all = args.get("replace_all", False)
+
+ if not file_path:
+ return "Error: No file path provided"
+
+ if old_string == new_string:
+ return "Error: old_string and new_string must be different"
+
+ # Resolve path relative to session working directory
+ working_dir = get_session_path(session_id=tool_context.session.id)
+ path = Path(file_path)
+ if not path.is_absolute():
+ path = working_dir / path
+ path = path.resolve()
+
+ if not path.exists():
+ return f"Error: File not found: {file_path}"
+
+ if not path.is_file():
+ return f"Error: Path is not a file: {file_path}"
+
+ try:
+ content = path.read_text()
+ except Exception as e:
+ return f"Error reading file {file_path}: {e}"
+
+ # Check if old_string exists
+ if old_string not in content:
+ return (
+ f"Error: old_string not found in {file_path}.\n"
+ f"Make sure you've read the file first and are using the exact string."
+ )
+
+ # Count occurrences
+ count = content.count(old_string)
+
+ if not replace_all and count > 1:
+ return (
+ f"Error: old_string appears {count} times in {file_path}.\n"
+ f"Either provide more context to make it unique, or set "
+ f"replace_all=true to replace all occurrences."
+ )
+
+ # Perform replacement
+ if replace_all:
+ new_content = content.replace(old_string, new_string)
+ else:
+ new_content = content.replace(old_string, new_string, 1)
+
+ try:
+ path.write_text(new_content)
+ logger.info(f"Successfully replaced {count} occurrence(s) in {file_path}")
+ return f"Successfully replaced {count} occurrence(s) in {file_path}"
+ except Exception as e:
+ error_msg = f"Error writing file {file_path}: {e}"
+ logger.error(error_msg)
+ return error_msg