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