From 9aea3142176471eb4890aedf2d73ee07898b15da Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Tue, 28 Oct 2025 00:34:13 -0400 Subject: [PATCH 1/2] agent skills initial commit Signed-off-by: Jet Chiang --- .../kagent-adk/src/kagent/adk/_a2a.py | 13 +- .../src/kagent/adk/skills/README.md | 89 ++++++++ .../src/kagent/adk/skills/__init__.py | 31 +++ .../kagent-adk/src/kagent/adk/skills/skill.py | 36 +++ .../kagent/adk/skills/skill_system_prompt.py | 96 ++++++++ .../src/kagent/adk/skills/skills_plugin.py | 122 ++++++++++ .../kagent/adk/skills/skills_shell_tool.py | 209 ++++++++++++++++++ .../src/kagent/adk/skills/skills_toolset.py | 66 ++++++ .../kagent/adk/skills/stage_artifacts_tool.py | 144 ++++++++++++ 9 files changed, 802 insertions(+), 4 deletions(-) create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/README.md create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/__init__.py create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skill.py create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skill_system_prompt.py create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skills_toolset.py create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index 22525cabd..e9900f0b5 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -2,7 +2,7 @@ import faulthandler import logging import os -from typing import Callable +from typing import Callable, List import httpx from a2a.server.apps import A2AFastAPIApplication @@ -14,8 +14,11 @@ from fastapi.responses import PlainTextResponse from google.adk.agents import BaseAgent from google.adk.apps import App +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 @@ -64,11 +67,13 @@ def __init__( agent_card: AgentCard, kagent_url: str, app_name: str, + plugins: List[BasePlugin] = None, ): self.root_agent = root_agent self.kagent_url = kagent_url self.app_name = app_name self.agent_card = agent_card + self.plugins = plugins if plugins is not None else [] def build(self) -> FastAPI: token_service = KAgentTokenService(self.app_name) @@ -77,17 +82,17 @@ def build(self) -> FastAPI: ) session_service = KAgentSessionService(http_client) - plugins = [] if sts_well_known_uri: sts_integration = ADKSTSIntegration(sts_well_known_uri) - plugins.append(ADKTokenPropagationPlugin(sts_integration)) + self.plugins.append(ADKTokenPropagationPlugin(sts_integration)) - adk_app = App(name=self.app_name, root_agent=self.root_agent, plugins=plugins) + adk_app = App(name=self.app_name, root_agent=self.root_agent, plugins=self.plugins) def create_runner() -> Runner: return Runner( app=adk_app, session_service=session_service, + artifact_service=InMemoryArtifactService(), ) agent_executor = A2aAgentExecutor( diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/README.md b/python/packages/kagent-adk/src/kagent/adk/skills/README.md new file mode 100644 index 000000000..a182ca71b --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/README.md @@ -0,0 +1,89 @@ +# ADK Skills: Architecture and Design + +This document outlines the architecture for the filesystem-based Skills feature in ADK. + +--- + +## 1. Core Philosophy + +The design is centered around a **shell-based interaction model**. This empowers the agent with a flexible, universal interface (a secure shell) to interact with a structured filesystem of skills. This model is favored over a programmatic "managed" model because it offers superior flexibility and extensibility, better aligning with the goal of creating autonomous, capable agents. + +The agent is taught a "Progressive Disclosure" pattern: it first discovers what skills are available, then investigates the instructions for a relevant skill, and only then executes its scripts or uses its detailed knowledge. + +--- + +## 2. Current Implementation + +The implementation consists of three main components that work together to provide the skills functionality. + +### a. Skill Directory Structure + +Skills are organized in a simple, conventional filesystem structure. Each skill is a self-contained directory that must contain a `SKILL.md` file and can optionally include scripts and a `requirements.txt`. + +``` +skills/ +├── data-analysis/ +│ ├── SKILL.md # Metadata (frontmatter) and instructions (markdown) +│ ├── requirements.txt # (Optional) Python dependencies for this skill +│ └── scripts/ +│ └── data_quality_check.py +└── pdf-processing/ + └── SKILL.md +``` + +### b. The `SkillsPlugin` + +This plugin is the primary entry point for enabling skills in an ADK application. It automates the integration process and has two main responsibilities: + +1. **Inject the Tool:** It adds an instance of the `SkillsShellTool` to the agent's toolset. +2. **Inject the "Level 1" Prompt:** On startup, it scans the `skills/` directory, parses the YAML frontmatter (`name`, `description`) from all `SKILL.md` files, and prepends a concise summary of available skills to the agent's system prompt. + +### c. The `SkillsShellTool` + +This is the core execution engine. It is a single, secure tool that provides a `shell(command: str)` function. + +- **Functionality:** It allows the agent to run basic shell commands like `ls`, `cat`, `head`, `grep`, `find`, `python`, and `pip`. +- **Security:** It is a hardened, sandboxed shell, not a full `bash` equivalent. It enforces a command whitelist, prevents directory traversal (`..`), and blocks access outside of its root directory to ensure safe execution. + +--- + +## 3. Architectural Challenge & Proposed Solution: File Handling + +### a. The Problem: Disconnected Contexts + +A critical challenge arises when a user uploads a file (e.g., a CSV for analysis). The file's content is passed to the LLM via its API context, but this file **does not exist on the filesystem** that the `SkillsShellTool` operates on. + +This creates a disconnect. The agent is aware of the file's content but cannot use any of its file-based tools (like a Python script) on it directly. Forcing the agent to manually recreate the file using `echo` is inefficient, error-prone, and fails for binary files. + +### b. The Solution: Runner-Managed Staging Area + +The ADK Runner itself must be responsible for bridging this context gap. This makes the process seamless for the agent. + +**Proposed Workflow:** + +1. **File Ingestion:** The ADK Runner receives a message containing a user-uploaded file. +2. **Save to Staging Area:** The Runner saves the file's bytes to a temporary, session-specific directory. For example: `.../adk_sessions//uploads/my_data.csv`. +3. **Augment the Prompt:** The Runner modifies the prompt sent to the LLM, adding a message that informs the agent of the file's existence and its path within the tool environment (e.g., "The user uploaded `my_data.csv`, which is available at `uploads/my_data.csv`"). +4. **Seamless Tool Use:** The agent, now aware of the file's location on its virtual filesystem, can use its `shell` tool to run scripts on that path directly and naturally. + +**Diagram of the Proposed Flow:** + +```mermaid +sequenceDiagram + participant User + participant ADK Runner + participant Agent (LLM) + participant Shell Tool + + User->>ADK Runner: Run command with file bytes + Note over ADK Runner: 1. Intercepts file bytes + ADK Runner->>ADK Runner: 2. Saves bytes to
`/staging/uploads/file.csv` + Note over ADK Runner: 3. Augments prompt with
"File is at 'uploads/file.csv'" + ADK Runner->>Agent (LLM): 4. Executes with augmented prompt + Agent (LLM)->>ADK Runner: I need to run a script on the file + ADK Runner->>Shell Tool: `shell("... skills/script.py uploads/file.csv")` + Shell Tool-->>ADK Runner: Script output + ADK Runner-->>Agent (LLM): Tool result + Agent (LLM)-->>ADK Runner: Final Answer + ADK Runner-->>User: Displays final answer +``` diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py b/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py new file mode 100644 index 000000000..df438c577 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py @@ -0,0 +1,31 @@ +# 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 .skill import Skill +from .skill_system_prompt import ( + generate_shell_skills_system_prompt, +) +from .skills_plugin import SkillsPlugin +from .skills_shell_tool import SkillsShellTool +from .skills_toolset import SkillsToolset +from .stage_artifacts_tool import StageArtifactsTool + +__all__ = [ + "Skill", + "SkillsPlugin", + "SkillsShellTool", + "SkillsToolset", + "generate_shell_skills_system_prompt", + "StageArtifactsTool", +] diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skill.py b/python/packages/kagent-adk/src/kagent/adk/skills/skill.py new file mode 100644 index 000000000..9f810bdeb --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skill.py @@ -0,0 +1,36 @@ +# 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 + +from typing import Optional + +from pydantic import BaseModel + + +class Skill(BaseModel): + """Represents the metadata for a skill. + + This is a simple data container used during the initial skill discovery + phase to hold the information parsed from a skill's SKILL.md frontmatter. + """ + + name: str + """The unique name/identifier of the skill.""" + + description: str + """A description of what the skill does and when to use it.""" + + license: Optional[str] = None + """Optional license information for the skill.""" 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 new file mode 100644 index 000000000..6858bb2c7 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skill_system_prompt.py @@ -0,0 +1,96 @@ +# 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 + +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 system prompt for shell-based skills usage.""" + prompt = """# Skills System - Shell Access + +You have access to specialized skills through shell commands and can process user-uploaded files. + +## Workflow for User-Uploaded Files + +When a user uploads a file, it is saved as an artifact. To use it with your `shell` tool, you MUST 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 `shell` commands. + ``` + shell("python skills/data-analysis/scripts/data_quality_check.py uploads/artifact_...") + ``` + +## Shell Tool Usage + +You have a `shell` tool that executes commands in a sandboxed environment. Use standard shell commands to interact with skills: + +### Discovery Commands +- `ls skills/`: List available skills. +- `head skills/SKILL_NAME/SKILL.md`: Preview a skill's instructions. +- `grep -i KEYWORD skills/*/SKILL.md`: Find skills by keyword. + +### Content Loading Commands +- `cat skills/SKILL_NAME/SKILL.md`: Load a skill's full instructions. +- `cat skills/SKILL_NAME/reference.md`: Load additional reference files. + +### Script and File Discovery +- `find skills/SKILL_NAME -type f`: List all files in a skill. +- `find skills/SKILL_NAME -name '*.py'`: Find Python scripts. + +### Script Execution Commands +- `cd skills/SKILL_NAME && python scripts/SCRIPT_NAME.py arg1`: Execute a script with arguments. + +## Progressive Disclosure Strategy + +1. **Discovery**: Use `ls` and `grep` to find relevant skills based on the user's request. +2. **Investigation**: Use `head` and `cat` to read the `SKILL.md` of a relevant skill to understand its capabilities and instructions. +3. **Execution**: Follow the instructions, which may involve checking dependencies, reading more files, or running scripts. + +## Best Practices + +### 1. **Dependency Management** +- **Before using a script**, check for a `requirements.txt` file: `ls skills/SKILL_NAME/requirements.txt` +- If it exists, **install the dependencies first**: `shell("pip install -r skills/SKILL_NAME/requirements.txt")` + +### 2. **Efficient Discovery** +- Always start by exploring the available skills to understand your capabilities. +- Read skill descriptions before loading full content. + +### 3. **Script Usage** +- **Always** execute scripts from within their skill directory to ensure they can access related files: `cd skills/SKILL_NAME && python scripts/SCRIPT.py` +- Check a script's documentation with `head` before running it. + +### 4. **Error Handling** +- If a command fails, read the error message and try to fix the problem. +- Verify file existence with `ls` before trying to `cat` them. + +## Security and Safety + +The `shell` tool is sandboxed for safety: +- **Safe Commands Only**: Only whitelisted commands like `ls`, `cat`, `grep`, `pip`, and `python` are allowed. +- **No Destructive Changes**: Commands like `rm`, `mv`, or `git` are blocked. +- **Directory Restrictions**: You cannot access files outside of the skills directory. + +Remember: Skills are your specialized knowledge repositories. Use them wisely to provide expert-level assistance.""" + return prompt 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 new file mode 100644 index 000000000..f5dadcd39 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py @@ -0,0 +1,122 @@ +# 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 +from pathlib import Path +from typing import Dict, List, Optional + +from google.genai import types +import yaml + +from google.adk.agents.base_agent import BaseAgent +from google.adk.agents.callback_context import CallbackContext +from google.adk.plugins.base_plugin import BasePlugin +from .skills_shell_tool import SkillsShellTool + +logger = logging.getLogger("google_adk." + __name__) + + +class SkillsPlugin(BasePlugin): + """Plugin that provides shell-based Skills functionality. + + This plugin provides global skills access by: + 1. Adding a shell tool for skills operations to all LLM agents. + 2. Injecting a "Level 1" discovery prompt (names and descriptions of + available skills) into the agent's instructions. + """ + + def __init__(self, skills_directory: str | Path, name: str = "skills_plugin"): + """Initialize the skills plugin. + + Args: + skills_directory: Path to directory containing skill folders. + name: Name of the plugin instance. + """ + super().__init__(name) + self.skills_directory = Path(skills_directory) + self.shell_tool = SkillsShellTool(skills_directory) + self._skill_metadata = self._parse_skill_metadata() + + def _parse_skill_metadata(self) -> List[Dict[str, str]]: + """Parse the YAML frontmatter of all SKILL.md files.""" + metadata_list = [] + if not self.skills_directory.exists(): + logger.warning(f"Skills directory not found: {self.skills_directory}") + return metadata_list + + for skill_dir in self.skills_directory.iterdir(): + skill_file = skill_dir / "SKILL.md" + if not (skill_dir.is_dir() and skill_file.exists()): + continue + + try: + with open(skill_file, "r", encoding="utf-8") as f: + content = f.read() + + if not content.startswith("---"): + continue + + parts = content.split("---", 2) + if len(parts) < 3: + continue + + metadata = yaml.safe_load(parts[1]) + if isinstance(metadata, dict) and "name" in metadata and "description" in metadata: + metadata_list.append({ + "name": metadata["name"], + "description": metadata["description"] + }) + except Exception as e: + logger.error(f"Failed to parse metadata for skill in {skill_dir}: {e}") + return metadata_list + + async def before_agent_callback( + self, *, agent: BaseAgent, callback_context: CallbackContext + ) -> Optional[types.Content]: + """Add shell tool and skills context to agents.""" + from ..agents.llm_agent import LlmAgent + if not isinstance(agent, LlmAgent): + return None + + # 1. Add shell tool if not already present + if "shell" not in {getattr(t, "name", None) for t in agent.tools}: + agent.tools.append(self.shell_tool) + logger.debug(f"Added shell tool to agent: {agent.name}") + + # 2. Add skills context to agent instruction + if hasattr(agent, "instruction") and isinstance(agent.instruction, str): + if "## Available Skills" not in agent.instruction: + skills_context = self._generate_skills_instruction() + agent.instruction += skills_context + logger.debug(f"Enhanced agent instruction for agent: {agent.name}") + + callback_context.state["skills_available"] = True + return None + + def _generate_skills_instruction(self) -> str: + """Generate the concise 'Level 1' skills instruction.""" + if not self._skill_metadata: + return "" + + lines = ["## Available Skills", + "You have access to the following specialized skills. Use the `shell` tool to interact with them.", + "---"] + for metadata in self._skill_metadata: + lines.append(f"- **{metadata['name']}**: {metadata['description']}") + lines.append("---") + lines.append("To use a skill, read its instructions with `shell(\"cat skills//SKILL.md\")`.") + + return "\n".join(lines) diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py b/python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py new file mode 100644 index 000000000..dc8118df3 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py @@ -0,0 +1,209 @@ +# 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 +import os +import shlex +import subprocess +from pathlib import Path +from typing import Any, Dict, List, Set, Union + +from google.genai import types + +from google.adk.tools.base_tool import BaseTool +from .stage_artifacts_tool import get_session_staging_path +from google.adk.tools.tool_context import ToolContext +# from google.adk.code_executors import UnsafeLocalCodeExecutor + +logger = logging.getLogger("google_adk." + __name__) + + +class SkillsShellTool(BaseTool): + """Generic shell tool for skills operations with security constraints.""" + + SAFE_COMMANDS: Set[str] = { + "ls", + "cat", + "head", + "tail", + "find", + "grep", + "wc", + "sort", + "uniq", + "python", + "python3", + "pip", + "cd", + "pwd", + "echo", + "which", + "file", + } + + 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="shell", + description=( + "Execute sandboxed shell commands for skills operations, file access, and script execution. " + "Use standard commands like 'ls skills/', 'cat skills/SKILL_NAME/SKILL.md', " + "or 'cd skills/SKILL_NAME && python scripts/script.py'" + ), + ) + 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="Shell command to execute. Must be a safe, whitelisted command.", + ) + }, + required=["command"], + ), + ) + + async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: + command = args.get("command", "").strip() + if not command: + return "Error: No command provided" + + try: + parsed_commands = self._parse_and_validate_command(command) + result = await self._execute_command_safely(parsed_commands, tool_context) + logger.info(f"Executed shell 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]]: + 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]: + 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." + + if base_command not in self.SAFE_COMMANDS: + return f"Command '{base_command}' is not in the allowed list." + + for arg in command_parts[1:]: + if ".." in arg: + return "Directory traversal using '..' is not allowed." + + if arg.startswith("/"): + return "Absolute paths are not allowed." + + return None + + async def _execute_command_safely(self, parsed_commands: List[List[str]], tool_context: ToolContext) -> str: + 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: + new_dir = (Path(os.getcwd()) / command_parts[1]).resolve() + if staging_root not in new_dir.parents and new_dir != staging_root: + raise ValueError("Cannot cd outside of the sandboxed directory.") + os.chdir(new_dir) + output_parts.append(f"Changed directory to {new_dir.relative_to(staging_root)}") + continue + + result = subprocess.run( + command_parts, + capture_output=True, + text=True, + timeout=30, + cwd=os.getcwd(), + ) + + if result.returncode != 0: + output = result.stderr or result.stdout + error_output = f"Command failed with exit code {result.returncode}:\n{output}" + output_parts.append(error_output) + break + else: + output = result.stdout or f"Warning: {result.stderr}" + output_parts.append(output.strip() if output.strip() else "Command completed successfully.") + + return "\n".join(output_parts) + + except subprocess.TimeoutExpired: + return "Command execution timed out (30s limit exceeded)" + except Exception as e: + return f"Error executing command: {e}" + finally: + os.chdir(original_cwd) 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 new file mode 100644 index 000000000..c462e702f --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skills_toolset.py @@ -0,0 +1,66 @@ +# 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 +from pathlib import Path +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.base_tool import BaseTool +from google.adk.tools.base_toolset import BaseToolset +from .skills_shell_tool import SkillsShellTool + +logger = logging.getLogger("google_adk." + __name__) + + +class SkillsToolset(BaseToolset): + """Toolset that provides shell-based Skills functionality. + + This toolset provides skills access through a single shell tool that agents + can use for all skills operations including discovery, content loading, and + script execution using standard shell commands. + + This approach is much simpler than multiple specialized tools and provides + more flexibility for skills operations. + """ + + def __init__(self, skills_directory: str | Path): + """Initialize the skills toolset. + + Args: + skills_directory: Path to directory containing skill folders. + """ + super().__init__() + self.skills_directory = Path(skills_directory) + + # Create shell tool for skills operations + self.shell_tool = SkillsShellTool(skills_directory) + + @override + async def get_tools( + self, readonly_context: Optional[ReadonlyContext] = None + ) -> List[BaseTool]: + """Get the shell tool for skills operations. + + Returns: + List containing the skills shell tool. + """ + return [self.shell_tool] diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py b/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py new file mode 100644 index 000000000..4b7a2fb87 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py @@ -0,0 +1,144 @@ +# 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. + +"""A tool for staging artifacts from the artifact service to a local filesystem path.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, List +import os +import tempfile + +from google.genai import types +from typing_extensions import override + +from google.adk.tools.base_tool import BaseTool +from google.adk.tools.tool_context import ToolContext + +logger = logging.getLogger("google_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) + + # 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(): + os.symlink( + skills_directory.resolve(), + skills_symlink, + target_is_directory=True, + ) + + return session_path.resolve() + + +class StageArtifactsTool(BaseTool): + """A tool to stage artifacts from the artifact service to the local filesystem.""" + + def __init__(self, skills_directory: Path): + super().__init__( + name="stage_artifacts", + description=( + "Copies artifacts from the artifact store to a local filesystem path, " + "making them available for file-based tools like the shell." + ), + ) + self._skills_directory = skills_directory + + def _get_declaration(self) -> types.FunctionDeclaration | None: + return types.FunctionDeclaration( + name=self.name, + description=self.description, + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "artifact_names": types.Schema( + type=types.Type.ARRAY, + description="A list of artifact names to stage.", + items=types.Schema(type=types.Type.STRING), + ), + "destination_path": types.Schema( + type=types.Type.STRING, + description="The local directory path to save the files to. Defaults to 'uploads/'.", + default="uploads/", + ), + }, + required=["artifact_names"], + ), + ) + + @override + async def run_async(self, *, args: dict[str, Any], tool_context: ToolContext) -> str: + artifact_names: List[str] = args.get("artifact_names", []) + destination_path_str: str = args.get("destination_path", "uploads/") + + if not tool_context._invocation_context.artifact_service: + 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, + ) + destination_dir = (staging_root / destination_path_str).resolve() + + # Security: Ensure the destination is within the staging path + if staging_root not in destination_dir.parents and destination_dir != staging_root: + return f"Error: Invalid destination path '{destination_path_str}'." + + destination_dir.mkdir(parents=True, exist_ok=True) + + output_paths = [] + 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 + output_file.write_bytes(artifact.inline_data.data) + relative_path = output_file.relative_to(staging_root) + output_paths.append(str(relative_path)) + + if not output_paths: + return "No valid artifacts were staged." + + return f"Successfully staged {len(output_paths)} artifact(s) to: {', '.join(output_paths)}" + + except Exception as e: + logger.error("Error staging artifacts: %s", e, exc_info=True) + return f"An error occurred while staging artifacts: {e}" From 05472f06cddbcaa6c365452c4a0bc564c85e6ec0 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Wed, 29 Oct 2025 00:22:50 -0400 Subject: [PATCH 2/2] refactor two skills system, update prompts Signed-off-by: Jet Chiang --- .../src/kagent/adk/_agent_executor.py | 2 +- .../src/kagent/adk/skills/README.md | 238 +++++++++++++---- .../src/kagent/adk/skills/__init__.py | 14 +- .../src/kagent/adk/skills/bash_tool.py | 244 ++++++++++++++++++ .../kagent-adk/src/kagent/adk/skills/skill.py | 36 --- .../kagent/adk/skills/skill_system_prompt.py | 169 ++++++++---- .../src/kagent/adk/skills/skill_tool.py | 202 +++++++++++++++ .../src/kagent/adk/skills/skills_plugin.py | 120 ++++----- .../kagent/adk/skills/skills_shell_tool.py | 209 --------------- .../src/kagent/adk/skills/skills_toolset.py | 50 ++-- .../kagent/adk/skills/stage_artifacts_tool.py | 84 +++--- 11 files changed, 870 insertions(+), 498 deletions(-) create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/bash_tool.py delete mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skill.py create mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py delete mode 100644 python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index 8cf385bee..af1abe1bd 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -33,7 +33,7 @@ from .converters.event_converter import convert_event_to_a2a_events from .converters.request_converter import convert_a2a_request_to_adk_run_args -logger = logging.getLogger("google_adk." + __name__) +logger = logging.getLogger("kagent_adk." + __name__) class A2aAgentExecutorConfig(BaseModel): 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 a182ca71b..10698544f 100644 --- a/python/packages/kagent-adk/src/kagent/adk/skills/README.md +++ b/python/packages/kagent-adk/src/kagent/adk/skills/README.md @@ -1,89 +1,217 @@ -# ADK Skills: Architecture and Design +# ADK Skills -This document outlines the architecture for the filesystem-based Skills feature in ADK. +Filesystem-based skills with progressive disclosure and two-tool architecture. --- -## 1. Core Philosophy +## Overview -The design is centered around a **shell-based interaction model**. This empowers the agent with a flexible, universal interface (a secure shell) to interact with a structured filesystem of skills. This model is favored over a programmatic "managed" model because it offers superior flexibility and extensibility, better aligning with the goal of creating autonomous, capable agents. +Skills enable agents to specialize in domain expertise without bloating the main context. The **two-tool pattern** separates concerns: -The agent is taught a "Progressive Disclosure" pattern: it first discovers what skills are available, then investigates the instructions for a relevant skill, and only then executes its scripts or uses its detailed knowledge. +- **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/ +``` + +**SKILL.md:** + +```markdown --- +name: data-analysis +description: Analyze CSV/Excel files +--- + +# Data Analysis + +...instructions... +``` -## 2. Current Implementation +--- -The implementation consists of three main components that work together to provide the skills functionality. +## Quick Start -### a. Skill Directory Structure +**Two-Tool Pattern (Recommended):** -Skills are organized in a simple, conventional filesystem structure. Each skill is a self-contained directory that must contain a `SKILL.md` file and can optionally include scripts and a `requirements.txt`. +```python +from kagent.adk.skills import SkillsTool, BashTool, StageArtifactsTool +agent = Agent( + tools=[ + SkillsTool(skills_directory="./skills"), + BashTool(skills_directory="./skills"), + StageArtifactsTool(skills_directory="./skills"), + ] +) ``` -skills/ -├── data-analysis/ -│ ├── SKILL.md # Metadata (frontmatter) and instructions (markdown) -│ ├── requirements.txt # (Optional) Python dependencies for this skill -│ └── scripts/ -│ └── data_quality_check.py -└── pdf-processing/ - └── SKILL.md + +**With Plugin (Multi-Agent Apps):** + +```python +from kagent.adk.skills import SkillsPlugin + +app = App(root_agent=agent, plugins=[SkillsPlugin(skills_directory="./skills")]) +``` + +**Legacy Single-Tool (Backward Compat):** + +```python +from kagent.adk.skills import SkillsShellTool + +agent = Agent(tools=[SkillsShellTool(skills_directory="./skills")]) ``` -### b. The `SkillsPlugin` +--- -This plugin is the primary entry point for enabling skills in an ADK application. It automates the integration process and has two main responsibilities: +## How It Works -1. **Inject the Tool:** It adds an instance of the `SkillsShellTool` to the agent's toolset. -2. **Inject the "Level 1" Prompt:** On startup, it scans the `skills/` directory, parses the YAML frontmatter (`name`, `description`) from all `SKILL.md` files, and prepends a concise summary of available skills to the agent's system prompt. +### Two-Tool Workflow -### c. The `SkillsShellTool` +```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 +``` -This is the core execution engine. It is a single, secure tool that provides a `shell(command: str)` function. +**Three Phases:** -- **Functionality:** It allows the agent to run basic shell commands like `ls`, `cat`, `head`, `grep`, `find`, `python`, and `pip`. -- **Security:** It is a hardened, sandboxed shell, not a full `bash` equivalent. It enforces a command whitelist, prevents directory traversal (`..`), and blocks access outside of its root directory to ensure safe execution. +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 --- -## 3. Architectural Challenge & Proposed Solution: File Handling +## Architecture -### a. The Problem: Disconnected Contexts +```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"] +``` -A critical challenge arises when a user uploads a file (e.g., a CSV for analysis). The file's content is passed to the LLM via its API context, but this file **does not exist on the filesystem** that the `SkillsShellTool` operates on. +| 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/` | -This creates a disconnect. The agent is aware of the file's content but cannot use any of its file-based tools (like a Python script) on it directly. Forcing the agent to manually recreate the file using `echo` is inefficient, error-prone, and fails for binary files. +--- -### b. The Solution: Runner-Managed Staging Area +## File Handling -The ADK Runner itself must be responsible for bridging this context gap. This makes the process seamless for the agent. +User uploads → Artifact → Stage → Execute: -**Proposed Workflow:** +```python +# 1. Stage uploaded file +stage_artifacts(artifact_names=["artifact_123"]) -1. **File Ingestion:** The ADK Runner receives a message containing a user-uploaded file. -2. **Save to Staging Area:** The Runner saves the file's bytes to a temporary, session-specific directory. For example: `.../adk_sessions//uploads/my_data.csv`. -3. **Augment the Prompt:** The Runner modifies the prompt sent to the LLM, adding a message that informs the agent of the file's existence and its path within the tool environment (e.g., "The user uploaded `my_data.csv`, which is available at `uploads/my_data.csv`"). -4. **Seamless Tool Use:** The agent, now aware of the file's location on its virtual filesystem, can use its `shell` tool to run scripts on that path directly and naturally. +# 2. Use in skill script +bash("cd skills/data-analysis && python scripts/analyze.py uploads/artifact_123") +``` -**Diagram of the Proposed Flow:** +--- -```mermaid -sequenceDiagram - participant User - participant ADK Runner - participant Agent (LLM) - participant Shell Tool - - User->>ADK Runner: Run command with file bytes - Note over ADK Runner: 1. Intercepts file bytes - ADK Runner->>ADK Runner: 2. Saves bytes to
`/staging/uploads/file.csv` - Note over ADK Runner: 3. Augments prompt with
"File is at 'uploads/file.csv'" - ADK Runner->>Agent (LLM): 4. Executes with augmented prompt - Agent (LLM)->>ADK Runner: I need to run a script on the file - ADK Runner->>Shell Tool: `shell("... skills/script.py uploads/file.csv")` - Shell Tool-->>ADK Runner: Script output - ADK Runner-->>Agent (LLM): Tool result - Agent (LLM)-->>ADK Runner: Final Answer - ADK Runner-->>User: Displays final answer +## Security + +**SkillsTool:** + +- ✅ Read-only (no execution) +- ✅ Validates skill existence +- ✅ Caches results + +**BashTool:** + +- ✅ Whitelisted commands only (`ls`, `cat`, `python`, `pip`, etc.) +- ✅ No destructive ops (`rm`, `mv`, `chmod` blocked) +- ✅ Directory restrictions (no `..`) +- ✅ 30-second timeout +- ✅ Subprocess isolation + +--- + +## Components + +| 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 | + +--- + +## Examples + +### Example 1: Data Analysis + +```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 +``` + +### Example 2: Multi-Agent App + +```python +# Register skills on all agents +app = App( + root_agent=agent, + plugins=[SkillsPlugin(skills_directory="./skills")] +) ``` + +--- + +## Comparison with Claude + +ADK follows Claude's two-tool pattern exactly: + +| Aspect | Claude | ADK | +| -------------- | ------------------- | ---------------------- | +| Discovery tool | Skills tool | SkillsTool ✅ | +| Execution tool | Bash tool | BashTool ✅ | +| Parameter | `command` | `command` ✅ | +| Pattern | Two-tool separation | Two-tool separation ✅ | + +--- + +## What Changed + +**Before:** Single `SkillsShellTool` (all-in-one) +**Now:** Two-tool architecture (discovery + execution) + +| Feature | Before | After | +| ---------------------- | --------- | ----------------- | +| Semantic clarity | Mixed | Separated ✅ | +| LLM reasoning | Implicit | Explicit ✅ | +| Progressive disclosure | Guideline | Enforced ✅ | +| Industry alignment | Custom | Claude pattern ✅ | + +All previous code still works (backward compatible via `SkillsShellTool`). 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 df438c577..34fddb092 100644 --- a/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py +++ b/python/packages/kagent-adk/src/kagent/adk/skills/__init__.py @@ -12,20 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .skill import Skill -from .skill_system_prompt import ( - generate_shell_skills_system_prompt, -) +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_shell_tool import SkillsShellTool from .skills_toolset import SkillsToolset from .stage_artifacts_tool import StageArtifactsTool __all__ = [ - "Skill", + "BashTool", + "SkillsTool", "SkillsPlugin", - "SkillsShellTool", "SkillsToolset", - "generate_shell_skills_system_prompt", "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 new file mode 100644 index 000000000..6abed21a0 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/bash_tool.py @@ -0,0 +1,244 @@ +"""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.py b/python/packages/kagent-adk/src/kagent/adk/skills/skill.py deleted file mode 100644 index 9f810bdeb..000000000 --- a/python/packages/kagent-adk/src/kagent/adk/skills/skill.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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 - -from typing import Optional - -from pydantic import BaseModel - - -class Skill(BaseModel): - """Represents the metadata for a skill. - - This is a simple data container used during the initial skill discovery - phase to hold the information parsed from a skill's SKILL.md frontmatter. - """ - - name: str - """The unique name/identifier of the skill.""" - - description: str - """A description of what the skill does and when to use it.""" - - license: Optional[str] = None - """Optional license information for the skill.""" 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 index 6858bb2c7..ae8ab6c7c 100644 --- 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 @@ -1,16 +1,29 @@ -# 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. +"""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 @@ -23,74 +36,130 @@ def generate_shell_skills_system_prompt( skills_directory: str | Path, readonly_context: Optional[ReadonlyContext] = None ) -> str: - """Generate a comprehensive system prompt for shell-based skills usage.""" - prompt = """# Skills System - Shell Access + """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. -You have access to specialized skills through shell commands and can process user-uploaded files. +## 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 your `shell` tool, you MUST follow this two-step process: +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 `shell` commands. +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. ``` - shell("python skills/data-analysis/scripts/data_quality_check.py uploads/artifact_...") + bash("python skills/data-analysis/scripts/data_quality_check.py uploads/artifact_...") ``` -## Shell Tool Usage +## 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. -You have a `shell` tool that executes commands in a sandboxed environment. Use standard shell commands to interact with skills: +### 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 -### Discovery Commands -- `ls skills/`: List available skills. -- `head skills/SKILL_NAME/SKILL.md`: Preview a skill's instructions. -- `grep -i KEYWORD skills/*/SKILL.md`: Find skills by keyword. +When you invoke a skill, you'll see: `The "skill-name" skill is loading` followed by the skill's complete instructions. -### Content Loading Commands -- `cat skills/SKILL_NAME/SKILL.md`: Load a skill's full instructions. -- `cat skills/SKILL_NAME/reference.md`: Load additional reference files. +## Using the Bash Tool -### Script and File Discovery -- `find skills/SKILL_NAME -type f`: List all files in a skill. -- `find skills/SKILL_NAME -name '*.py'`: Find Python scripts. +The `bash` tool executes commands in a sandboxed environment. Use it after loading a skill's instructions: -### Script Execution Commands -- `cd skills/SKILL_NAME && python scripts/SCRIPT_NAME.py arg1`: Execute a script with arguments. +### 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. **Discovery**: Use `ls` and `grep` to find relevant skills based on the user's request. -2. **Investigation**: Use `head` and `cat` to read the `SKILL.md` of a relevant skill to understand its capabilities and instructions. -3. **Execution**: Follow the instructions, which may involve checking dependencies, reading more files, or running scripts. +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: `ls skills/SKILL_NAME/requirements.txt` -- If it exists, **install the dependencies first**: `shell("pip install -r skills/SKILL_NAME/requirements.txt")` +- **Before using a script**, check for a `requirements.txt` file +- Install dependencies with: `bash("pip install -r skills/SKILL_NAME/requirements.txt")` -### 2. **Efficient Discovery** -- Always start by exploring the available skills to understand your capabilities. -- Read skill descriptions before loading full content. +### 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 to ensure they can access related files: `cd skills/SKILL_NAME && python scripts/SCRIPT.py` -- Check a script's documentation with `head` before running it. +- **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 command fails, read the error message and try to fix the problem. -- Verify file existence with `ls` before trying to `cat` them. +- 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 -The `shell` tool is sandboxed for safety: -- **Safe Commands Only**: Only whitelisted commands like `ls`, `cat`, `grep`, `pip`, and `python` are allowed. -- **No Destructive Changes**: Commands like `rm`, `mv`, or `git` are blocked. -- **Directory Restrictions**: You cannot access files outside of the skills directory. +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 them wisely to provide expert-level assistance.""" +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 new file mode 100644 index 000000000..ae5f86d06 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py @@ -0,0 +1,202 @@ +"""Tool for discovering and loading skills.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml +from google.adk.tools import BaseTool, ToolContext +from google.genai import types +from pydantic import BaseModel + +logger = logging.getLogger("kagent_adk." + __name__) + + +class Skill(BaseModel): + """Represents the metadata for a skill. + + This is a simple data container used during the initial skill discovery + phase to hold the information parsed from a skill's SKILL.md frontmatter. + """ + + name: str + """The unique name/identifier of the skill.""" + + description: str + """A description of what the skill does and when to use it.""" + + license: Optional[str] = None + """Optional license information for the skill.""" + + +class SkillsTool(BaseTool): + """Discover and load skill instructions. + + This tool dynamically discovers available skills and embeds their metadata in the + tool description. Agent invokes a skill by name to load its full instructions. + """ + + def __init__(self, skills_directory: str | Path): + self.skills_directory = Path(skills_directory).resolve() + if not self.skills_directory.exists(): + raise ValueError(f"Skills directory does not exist: {self.skills_directory}") + + self._skill_cache: Dict[str, str] = {} + + # Generate description with available skills embedded + description = self._generate_description_with_skills() + + super().__init__( + name="skills", + description=description, + ) + + def _generate_description_with_skills(self) -> str: + """Generate tool description with available skills embedded.""" + base_description = ( + "Execute a skill within the main conversation\n\n" + "\n" + "When users ask you to perform tasks, check if any of the available skills below can help " + "complete the task more effectively. Skills provide specialized capabilities and domain knowledge.\n\n" + "How to use skills:\n" + "- Invoke skills using this tool with the skill name only (no arguments)\n" + "- When you invoke a skill, the skill's full SKILL.md will load with detailed instructions\n" + "- Follow the skill's instructions and use the bash tool to execute commands\n" + "- Examples:\n" + ' - command: "data-analysis" - invoke the data-analysis skill\n' + ' - command: "pdf-processing" - invoke the pdf-processing skill\n\n' + "Important:\n" + "- 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" + "\n\n" + ) + + # Discover and append available skills + skills_xml = self._discover_skills() + return base_description + skills_xml + + def _discover_skills(self) -> str: + """Discover available skills and format as XML.""" + if not self.skills_directory.exists(): + return "\n\n\n" + + skills_entries = [] + for skill_dir in sorted(self.skills_directory.iterdir()): + if not skill_dir.is_dir(): + continue + + skill_file = skill_dir / "SKILL.md" + if not skill_file.exists(): + continue + + try: + metadata = self._parse_skill_metadata(skill_file) + if metadata: + skill_xml = ( + "\n" + f"{metadata['name']}\n" + f"{metadata['description']}\n" + "" + ) + skills_entries.append(skill_xml) + except Exception as e: + logger.error(f"Failed to parse skill {skill_dir.name}: {e}") + + if not skills_entries: + return "\n\n\n" + + return "\n" + "\n".join(skills_entries) + "\n\n" + + 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='The skill name (no arguments). E.g., "data-analysis" or "pdf-processing"', + ), + }, + required=["command"], + ), + ) + + async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: + """Execute skill loading by name.""" + skill_name = args.get("command", "").strip() + + if not skill_name: + return "Error: No skill name provided" + + return self._invoke_skill(skill_name) + + def _invoke_skill(self, skill_name: str) -> str: + """Load and return the full content of a skill.""" + # Check cache first + if skill_name in self._skill_cache: + return self._skill_cache[skill_name] + + # Find skill directory + skill_dir = self.skills_directory / skill_name + if not skill_dir.exists() or not skill_dir.is_dir(): + return f"Error: Skill '{skill_name}' not found. Check the available skills list in the tool description." + + skill_file = skill_dir / "SKILL.md" + if not skill_file.exists(): + return f"Error: Skill '{skill_name}' has no SKILL.md file." + + try: + with open(skill_file, "r", encoding="utf-8") as f: + content = f.read() + + formatted_content = self._format_skill_content(skill_name, content) + + # Cache the formatted content + self._skill_cache[skill_name] = formatted_content + + return formatted_content + + except Exception as e: + logger.error(f"Failed to load skill {skill_name}: {e}") + return f"Error loading skill '{skill_name}': {e}" + + def _parse_skill_metadata(self, skill_file: Path) -> Dict[str, str] | None: + """Parse YAML frontmatter from a SKILL.md file.""" + try: + with open(skill_file, "r", encoding="utf-8") as f: + content = f.read() + + if not content.startswith("---"): + return None + + parts = content.split("---", 2) + if len(parts) < 3: + return None + + metadata = yaml.safe_load(parts[1]) + if isinstance(metadata, dict) and "name" in metadata and "description" in metadata: + return { + "name": metadata["name"], + "description": metadata["description"], + } + return None + except Exception as e: + logger.error(f"Failed to parse metadata from {skill_file}: {e}") + return None + + def _format_skill_content(self, skill_name: str, content: str) -> str: + """Format skill content for display to the agent.""" + header = ( + f'The "{skill_name}" skill is loading\n\n' + f"Base directory for this skill: skills/{skill_name}\n\n" + ) + footer = ( + "\n\n---\n" + "The skill has been loaded. Follow the instructions above and use the bash tool to execute commands." + ) + return header + content + footer 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 f5dadcd39..70479622d 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 @@ -16,26 +16,43 @@ import logging from pathlib import Path -from typing import Dict, List, Optional +from typing import Optional +from google.adk.agents import BaseAgent, LlmAgent +from google.adk.agents.callback_context import CallbackContext +from google.adk.plugins import BasePlugin from google.genai import types -import yaml -from google.adk.agents.base_agent import BaseAgent -from google.adk.agents.callback_context import CallbackContext -from google.adk.plugins.base_plugin import BasePlugin -from .skills_shell_tool import SkillsShellTool +from .bash_tool import BashTool +from .skill_tool import SkillsTool -logger = logging.getLogger("google_adk." + __name__) +logger = logging.getLogger("kagent_adk." + __name__) class SkillsPlugin(BasePlugin): - """Plugin that provides shell-based Skills functionality. - - This plugin provides global skills access by: - 1. Adding a shell tool for skills operations to all LLM agents. - 2. Injecting a "Level 1" discovery prompt (names and descriptions of - available skills) into the agent's instructions. + """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. + + 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. + + Example: + # Without plugin (direct tool usage): + agent = Agent( + tools=[ + SkillsTool(skills_directory="./skills"), + BashTool(skills_directory="./skills"), + ] + ) + + # With plugin (auto-registration for multi-agent apps): + app = App( + root_agent=agent, + plugins=[SkillsPlugin(skills_directory="./skills")] + ) """ def __init__(self, skills_directory: str | Path, name: str = "skills_plugin"): @@ -47,76 +64,27 @@ def __init__(self, skills_directory: str | Path, name: str = "skills_plugin"): """ super().__init__(name) self.skills_directory = Path(skills_directory) - self.shell_tool = SkillsShellTool(skills_directory) - self._skill_metadata = self._parse_skill_metadata() - - def _parse_skill_metadata(self) -> List[Dict[str, str]]: - """Parse the YAML frontmatter of all SKILL.md files.""" - metadata_list = [] - if not self.skills_directory.exists(): - logger.warning(f"Skills directory not found: {self.skills_directory}") - return metadata_list - - for skill_dir in self.skills_directory.iterdir(): - skill_file = skill_dir / "SKILL.md" - if not (skill_dir.is_dir() and skill_file.exists()): - continue - - try: - with open(skill_file, "r", encoding="utf-8") as f: - content = f.read() - - if not content.startswith("---"): - continue - - parts = content.split("---", 2) - if len(parts) < 3: - continue - - metadata = yaml.safe_load(parts[1]) - if isinstance(metadata, dict) and "name" in metadata and "description" in metadata: - metadata_list.append({ - "name": metadata["name"], - "description": metadata["description"] - }) - except Exception as e: - logger.error(f"Failed to parse metadata for skill in {skill_dir}: {e}") - return metadata_list + self.skills_invoke_tool = SkillsTool(skills_directory) + self.bash_tool = BashTool(skills_directory) async def before_agent_callback( self, *, agent: BaseAgent, callback_context: CallbackContext ) -> Optional[types.Content]: - """Add shell tool and skills context to agents.""" - from ..agents.llm_agent import LlmAgent + """Add skills tools to agents if not already present.""" + if not isinstance(agent, LlmAgent): return None - # 1. Add shell tool if not already present - if "shell" not in {getattr(t, "name", None) for t in agent.tools}: - agent.tools.append(self.shell_tool) - logger.debug(f"Added shell tool to agent: {agent.name}") + existing_tool_names = {getattr(t, "name", None) for t in agent.tools} - # 2. Add skills context to agent instruction - if hasattr(agent, "instruction") and isinstance(agent.instruction, str): - if "## Available Skills" not in agent.instruction: - skills_context = self._generate_skills_instruction() - agent.instruction += skills_context - logger.debug(f"Enhanced agent instruction for agent: {agent.name}") + # Add SkillsTool if not already present + if "skills" not in existing_tool_names: + agent.tools.append(self.skills_invoke_tool) + logger.debug(f"Added skills invoke tool to agent: {agent.name}") - callback_context.state["skills_available"] = True - return None - - def _generate_skills_instruction(self) -> str: - """Generate the concise 'Level 1' skills instruction.""" - if not self._skill_metadata: - return "" + # Add BashTool if not already present + if "bash" not in existing_tool_names: + agent.tools.append(self.bash_tool) + logger.debug(f"Added bash tool to agent: {agent.name}") - lines = ["## Available Skills", - "You have access to the following specialized skills. Use the `shell` tool to interact with them.", - "---"] - for metadata in self._skill_metadata: - lines.append(f"- **{metadata['name']}**: {metadata['description']}") - lines.append("---") - lines.append("To use a skill, read its instructions with `shell(\"cat skills//SKILL.md\")`.") - - return "\n".join(lines) + return None diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py b/python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py deleted file mode 100644 index dc8118df3..000000000 --- a/python/packages/kagent-adk/src/kagent/adk/skills/skills_shell_tool.py +++ /dev/null @@ -1,209 +0,0 @@ -# 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 -import os -import shlex -import subprocess -from pathlib import Path -from typing import Any, Dict, List, Set, Union - -from google.genai import types - -from google.adk.tools.base_tool import BaseTool -from .stage_artifacts_tool import get_session_staging_path -from google.adk.tools.tool_context import ToolContext -# from google.adk.code_executors import UnsafeLocalCodeExecutor - -logger = logging.getLogger("google_adk." + __name__) - - -class SkillsShellTool(BaseTool): - """Generic shell tool for skills operations with security constraints.""" - - SAFE_COMMANDS: Set[str] = { - "ls", - "cat", - "head", - "tail", - "find", - "grep", - "wc", - "sort", - "uniq", - "python", - "python3", - "pip", - "cd", - "pwd", - "echo", - "which", - "file", - } - - 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="shell", - description=( - "Execute sandboxed shell commands for skills operations, file access, and script execution. " - "Use standard commands like 'ls skills/', 'cat skills/SKILL_NAME/SKILL.md', " - "or 'cd skills/SKILL_NAME && python scripts/script.py'" - ), - ) - 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="Shell command to execute. Must be a safe, whitelisted command.", - ) - }, - required=["command"], - ), - ) - - async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: - command = args.get("command", "").strip() - if not command: - return "Error: No command provided" - - try: - parsed_commands = self._parse_and_validate_command(command) - result = await self._execute_command_safely(parsed_commands, tool_context) - logger.info(f"Executed shell 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]]: - 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]: - 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." - - if base_command not in self.SAFE_COMMANDS: - return f"Command '{base_command}' is not in the allowed list." - - for arg in command_parts[1:]: - if ".." in arg: - return "Directory traversal using '..' is not allowed." - - if arg.startswith("/"): - return "Absolute paths are not allowed." - - return None - - async def _execute_command_safely(self, parsed_commands: List[List[str]], tool_context: ToolContext) -> str: - 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: - new_dir = (Path(os.getcwd()) / command_parts[1]).resolve() - if staging_root not in new_dir.parents and new_dir != staging_root: - raise ValueError("Cannot cd outside of the sandboxed directory.") - os.chdir(new_dir) - output_parts.append(f"Changed directory to {new_dir.relative_to(staging_root)}") - continue - - result = subprocess.run( - command_parts, - capture_output=True, - text=True, - timeout=30, - cwd=os.getcwd(), - ) - - if result.returncode != 0: - output = result.stderr or result.stdout - error_output = f"Command failed with exit code {result.returncode}:\n{output}" - output_parts.append(error_output) - break - else: - output = result.stdout or f"Warning: {result.stderr}" - output_parts.append(output.strip() if output.strip() else "Command completed successfully.") - - return "\n".join(output_parts) - - except subprocess.TimeoutExpired: - return "Command execution timed out (30s limit exceeded)" - except Exception as e: - return f"Error executing command: {e}" - finally: - os.chdir(original_cwd) 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 c462e702f..822868419 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 @@ -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 @@ -24,22 +10,25 @@ from typing import override from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.tools.base_tool import BaseTool +from google.adk.tools import BaseTool from google.adk.tools.base_toolset import BaseToolset -from .skills_shell_tool import SkillsShellTool -logger = logging.getLogger("google_adk." + __name__) +from .bash_tool import BashTool +from .skill_tool import SkillsTool + +logger = logging.getLogger("kagent_adk." + __name__) class SkillsToolset(BaseToolset): - """Toolset that provides shell-based Skills functionality. + """Toolset that provides Skills functionality through two focused tools. - This toolset provides skills access through a single shell tool that agents - can use for all skills operations including discovery, content loading, and - script execution using standard shell commands. + This toolset provides skills access through two complementary tools following + progressive disclosure: + 1. SkillsTool - Discover and load skill instructions + 2. BashTool - Execute commands based on skill guidance - This approach is much simpler than multiple specialized tools and provides - more flexibility for skills operations. + This separation provides clear semantic distinction between skill discovery + (what can I do?) and skill execution (how do I do it?). """ def __init__(self, skills_directory: str | Path): @@ -51,16 +40,15 @@ def __init__(self, skills_directory: str | Path): super().__init__() self.skills_directory = Path(skills_directory) - # Create shell tool for skills operations - self.shell_tool = SkillsShellTool(skills_directory) + # Create the two tools for skills operations + self.skills_invoke_tool = SkillsTool(skills_directory) + self.bash_tool = BashTool(skills_directory) @override - async def get_tools( - self, readonly_context: Optional[ReadonlyContext] = None - ) -> List[BaseTool]: - """Get the shell tool for skills operations. + async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> List[BaseTool]: + """Get both skills tools. Returns: - List containing the skills shell tool. + List containing SkillsTool and BashTool. """ - return [self.shell_tool] + return [self.skills_invoke_tool, self.bash_tool] diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py b/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py index 4b7a2fb87..6007bbff9 100644 --- a/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py +++ b/python/packages/kagent-adk/src/kagent/adk/skills/stage_artifacts_tool.py @@ -1,34 +1,17 @@ -# 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. - -"""A tool for staging artifacts from the artifact service to a local filesystem path.""" - from __future__ import annotations import logging -from pathlib import Path -from typing import Any, List import os import tempfile +from pathlib import Path +from typing import Any, List -from google.genai import types from typing_extensions import override -from google.adk.tools.base_tool import BaseTool -from google.adk.tools.tool_context import ToolContext +from google.adk.tools import BaseTool, ToolContext +from google.genai import types -logger = logging.getLogger("google_adk." + __name__) +logger = logging.getLogger("kagent_adk." + __name__) def get_session_staging_path(session_id: str, app_name: str, skills_directory: Path) -> Path: @@ -56,24 +39,52 @@ def get_session_staging_path(session_id: str, app_name: str, skills_directory: P if skills_directory and skills_directory.exists(): skills_symlink = session_path / "skills" if not skills_symlink.exists(): - os.symlink( - skills_directory.resolve(), - skills_symlink, - target_is_directory=True, - ) + 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}") return session_path.resolve() class StageArtifactsTool(BaseTool): - """A tool to stage artifacts from the artifact service to the local filesystem.""" + """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 is essential for the skills workflow where user-uploaded files must be + accessible to skill scripts and commands. + """ def __init__(self, skills_directory: Path): super().__init__( name="stage_artifacts", description=( - "Copies artifacts from the artifact store to a local filesystem path, " - "making them available for file-based tools like the shell." + "Stage artifacts from the artifact store to a local filesystem path, " + "making them available for use with skills and the bash tool.\n\n" + "WORKFLOW:\n" + "1. When a user uploads a file, it's stored as an artifact (e.g., 'artifact_xyz')\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" + "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" + "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" + "- 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 @@ -87,12 +98,21 @@ def _get_declaration(self) -> types.FunctionDeclaration | None: properties={ "artifact_names": types.Schema( type=types.Type.ARRAY, - description="A list of artifact names to stage.", + description=( + "List of artifact names to stage. These are artifact identifiers " + "provided by the system when files are uploaded (e.g., 'artifact_abc123'). " + "The tool will copy each artifact from the artifact store to the destination directory." + ), items=types.Schema(type=types.Type.STRING), ), "destination_path": types.Schema( type=types.Type.STRING, - description="The local directory path to save the files to. Defaults to 'uploads/'.", + description=( + "Relative path within the session directory to save the files. " + "Default is 'uploads/' where user-uploaded files are conventionally stored. " + "Path must be within the session directory for security. " + "Useful for organizing different types of artifacts (e.g., 'uploads/input/', 'uploads/processed/')." + ), default="uploads/", ), },