diff --git a/veadk/skills/skills_plugin.py b/veadk/skills/skills_plugin.py new file mode 100644 index 00000000..8d709be3 --- /dev/null +++ b/veadk/skills/skills_plugin.py @@ -0,0 +1,101 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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 import BaseAgent, LlmAgent +from google.adk.agents.callback_context import CallbackContext +from google.adk.plugins import BasePlugin +from google.genai import types + +from veadk.tools.skills_tools.session_path import initialize_session_path +from veadk.tools.skills_tools.skills_toolset import SkillsToolset +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + + +class SkillsPlugin(BasePlugin): + """Convenience plugin for multi-agent apps to automatically register Skills tools. + + This plugin is purely a convenience wrapper that automatically adds the SkillsTool + and BashTool and related file tools to all LLM agents in an application. + It does not add any additional functionality beyond tool registration. + + For single-agent use cases or when you prefer explicit control, you can skip this plugin + and directly add both tools to your agent's tools list. + + Example: + # Without plugin (direct tool usage): + agent = Agent( + tools=[ + SkillsTool(skills_directory="./skills"), + BashTool(skills_directory="./skills"), + ReadFileTool(), + WriteFileTool(), + EditFileTool(), + ] + ) + + # 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"): + """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) + + async def before_agent_callback( + self, *, agent: BaseAgent, callback_context: CallbackContext + ) -> Optional[types.Content]: + """Initialize session path and add skills tools to agents if not already present. + + This hook fires before any tools are invoked, ensuring the session working + directory is set up with the skills symlink before any tool needs it. + """ + # Initialize session path FIRST (before tools run) + # This creates the working directory structure and skills symlink + session_id = callback_context.session.id + initialize_session_path(session_id, str(self.skills_directory)) + logger.debug(f"Initialized session path for session: {session_id}") + + add_skills_tool_to_agent(self.skills_directory, agent) + + +def add_skills_tool_to_agent(skills_directory: str | Path, agent: BaseAgent) -> None: + """Utility function to add Skills and Bash tools to a given agent. + + Args: + agent: The LlmAgent instance to which the tools will be added. + skills_directory: Path to directory containing skill folders. + """ + + if not isinstance(agent, LlmAgent): + return + + skills_directory = Path(skills_directory) + agent.tools.append(SkillsToolset(skills_directory)) + logger.debug(f"Added skills toolset to agent: {agent.name}") diff --git a/veadk/tools/skills_tools/__init__.py b/veadk/tools/skills_tools/__init__.py new file mode 100644 index 00000000..fe10d935 --- /dev/null +++ b/veadk/tools/skills_tools/__init__.py @@ -0,0 +1,32 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .bash_tool import bash_tool +from .file_tool import edit_file_tool, read_file_tool, write_file_tool +from .skills_tool import SkillsTool +from .skills_toolset import SkillsToolset +from .session_path import initialize_session_path, get_session_path, clear_session_cache + + +__all__ = [ + "bash_tool", + "edit_file_tool", + "read_file_tool", + "write_file_tool", + "SkillsTool", + "SkillsToolset", + "initialize_session_path", + "get_session_path", + "clear_session_cache", +] diff --git a/veadk/tools/skills_tools/bash_tool.py b/veadk/tools/skills_tools/bash_tool.py new file mode 100644 index 00000000..efe61490 --- /dev/null +++ b/veadk/tools/skills_tools/bash_tool.py @@ -0,0 +1,154 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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 asyncio +import os + +from google.adk.tools import ToolContext +from veadk.tools.skills_tools.session_path import get_session_path +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + + +async def bash_tool(command: str, description: str, tool_context: ToolContext): + """Execute bash commands in the skills environment with local shell. + + This tool uses the local bash shell to execute commands with: + - Filesystem restrictions (controlled read/write access) + - Network restrictions (controlled domain access) + - Process isolation at the OS level + + Use it for command-line operations like running scripts, installing packages, etc. + For file operations (read/write/edit), use the dedicated file tools instead. + + Execute bash commands in the skills environment with local shell. + Working Directory & Structure: + - Commands run in a temporary session directory: /tmp/veadk/{session_id}/ + - skills_directory -> All skills are available here (read-only). + - Your current working directory is added to PYTHONPATH. + + Python Imports (CRITICAL): + - To import from a skill, use the full path from the 'skills' root. + Example: from skills.skills_name.module import function + - If the skills name contains a dash '-', you need to use importlib to import it. + Example: + import importlib + skill_module = importlib.import_module('skills.skill-name.module') + + For file operations: + - Use read_file, write_file, and edit_file for interacting with the filesystem. + + Timeouts: + - pip install: 120s + - python scripts: 60s + - other commands: 30s + + Args: + command: Bash command to execute. Use && to chain commands. + description: Clear, concise description of what this command does (5-10 words) + tool_context: The context of the tool execution, including session info. + + Returns: + The output of the bash command or error message. + """ + + if not command: + return "Error: No command provided" + + try: + # Get session working directory (initialized by SkillsPlugin) + working_dir = get_session_path(session_id=tool_context.session.id) + logger.info(f"Session working directory: {working_dir}") + + # Determine timeout based on command + timeout = _get_command_timeout_seconds(command) + + # Prepare environment with PYTHONPATH including skills directory + # This allows imports like: from skills.slack_gif_creator.core import something + env = os.environ.copy() + # Add root for 'from skills...' and working_dir for local scripts + pythonpath_additions = [str(working_dir), "/"] + if "PYTHONPATH" in env: + pythonpath_additions.append(env["PYTHONPATH"]) + env["PYTHONPATH"] = ":".join(pythonpath_additions) + + # Check for BASH_VENV_PATH to use a specific virtual environment + provided = os.environ.get("BASH_VENV_PATH") + if provided and os.path.isdir(provided): + bash_venv_path = provided + bash_venv_bin = os.path.join(bash_venv_path, "bin") + logger.info(f"Using provided BASH_VENV_PATH: {bash_venv_path}") + # Prepend bash venv to PATH so its python and pip are used + env["PATH"] = f"{bash_venv_bin}:{env.get('PATH', '')}" + env["VIRTUAL_ENV"] = bash_venv_path + + # Execute with local bash shell + local_bash_command = f"{command}" + + process = await asyncio.create_subprocess_shell( + local_bash_command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=working_dir, + env=env, # Pass the modified environment + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), timeout=timeout + ) + except asyncio.TimeoutError: + process.kill() + await process.wait() + return f"Error: Command timed out after {timeout}s" + + stdout_str = stdout.decode("utf-8", errors="replace") if stdout else "" + stderr_str = stderr.decode("utf-8", errors="replace") if stderr else "" + + # Handle command failure + if process.returncode != 0: + error_msg = f"Command failed with exit code {process.returncode}" + if stderr_str: + error_msg += f":\n{stderr_str}" + elif stdout_str: + error_msg += f":\n{stdout_str}" + return error_msg + + # Return output + output = stdout_str + if stderr_str and "WARNING" not in stderr_str: + output += f"\n{stderr_str}" + + result = output.strip() if output.strip() else "Command completed successfully." + + logger.info(f"Executed bash command: {command}, description: {description}") + logger.info(f"Command result: {result}") + return result + except Exception as e: + error_msg = f"Error executing command '{command}': {e}" + logger.error(error_msg) + return error_msg + + +def _get_command_timeout_seconds(command: str) -> float: + """Determine appropriate timeout for command in seconds.""" + if "pip install" in command or "pip3 install" in command: + return 120.0 + elif "python " in command or "python3 " in command: + return 60.0 + else: + return 30.0 diff --git a/veadk/tools/skills_tools/file_tool.py b/veadk/tools/skills_tools/file_tool.py new file mode 100644 index 00000000..82583870 --- /dev/null +++ b/veadk/tools/skills_tools/file_tool.py @@ -0,0 +1,214 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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 google.adk.tools import ToolContext +from veadk.tools.skills_tools.session_path import get_session_path + +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + + +def read_file_tool(file_path: str, offset: int, limit: int, tool_context: ToolContext): + """Read files with line numbers for precise editing. + + Reads a file from the filesystem with line numbers. + Usage: + - Provide a path to the file (absolute or relative to your working directory) + - Returns content with line numbers (format: LINE_NUMBER|CONTENT) + - Optional offset and limit parameters for reading specific line ranges + - Lines longer than 2000 characters are truncated + - Always read a file before editing it + - You can read from skills/ directory, uploads/, outputs/, or any file in your session + + Args: + file_path: Path to the file to read (absolute or relative to working directory) + offset: Optional line number to start reading from (1-indexed) + limit: Optional number of lines to read + tool_context: Context of the tool execution + + Returns: + Content of the file with line numbers, or error message. + """ + if not file_path: + return "Error: No file path provided" + + # Resolve path relative to session working directory + working_dir = get_session_path(session_id=tool_context.session.id) + path = Path(file_path) + if not path.is_absolute(): + path = working_dir / path + path = path.resolve() + + if not path.exists(): + return f"Error: File not found: {file_path}" + + if not path.is_file(): + return f"Error: Path is not a file: {file_path}\nThis tool can only read files, not directories." + + try: + lines = path.read_text().splitlines() + except Exception as e: + return f"Error reading file {file_path}: {e}" + + # Handle offset and limit + start = (offset - 1) if offset and offset > 0 else 0 + end = (start + limit) if limit else len(lines) + + # Format with line numbers + result_lines = [] + for i, line in enumerate(lines[start:end], start=start + 1): + # Truncate long lines + if len(line) > 2000: + line = line[:2000] + "..." + result_lines.append(f"{i:6d}|{line}") + + if not result_lines: + return "File is empty." + + return "\n".join(result_lines) + + +def write_file_tool(file_path: str, content: str, tool_context: ToolContext): + """Write content to files (overwrites existing files). + + Writes content to a file on the filesystem. + Usage: + - Provide a path (absolute or relative to working directory) and content to write + - Overwrites existing files + - Creates parent directories if needed + - For existing files, read them first using read_file + - Prefer editing existing files over writing new ones + - You can write to your working directory, outputs/, or any writable location + - Note: skills/ directory is read-only + + Args: + file_path: Path to the file to write (absolute or relative to working directory) + content: Content to write to the file + tool_context: Context of the tool execution + + Returns: + Success message or error message. + """ + if not file_path: + return "Error: No file path provided" + + # Resolve path relative to session working directory + working_dir = get_session_path(session_id=tool_context.session.id) + path = Path(file_path) + if not path.is_absolute(): + path = working_dir / path + path = path.resolve() + + try: + # Create parent directories if needed + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + logger.info(f"Successfully wrote to {file_path}") + return f"Successfully wrote to {file_path}" + except Exception as e: + error_msg = f"Error writing file {file_path}: {e}" + logger.error(error_msg) + return error_msg + + +def edit_file_tool( + file_path: str, + old_string: str, + new_string: str, + replace_all: bool, + tool_context: ToolContext, +): + """Edit files by replacing exact string matches. + + Performs exact string replacements in files. + Usage: + - You must read the file first using read_file + - Provide path (absolute or relative to working directory) + - When editing, preserve exact indentation from the file content + - Do NOT include line number prefixes in old_string or new_string + - old_string must be unique unless replace_all=true + - Use replace_all to rename variables/strings throughout the file + - old_string and new_string must be different + - Note: skills/ directory is read-only + + Args: + file_path: Path to the file to edit (absolute or relative to working directory) + old_string: The exact text to replace (must exist in file) + new_string: The text to replace it with (must be different from old_string) + replace_all: Replace all occurrences (default: false, only replaces first occurrence) + tool_context: Context of the tool execution + + Returns: + Success message or error message. + """ + if not file_path: + return "Error: No file path provided" + + if old_string == new_string: + return "Error: old_string and new_string must be different" + + # Resolve path relative to session working directory + working_dir = get_session_path(session_id=tool_context.session.id) + path = Path(file_path) + if not path.is_absolute(): + path = working_dir / path + path = path.resolve() + + if not path.exists(): + return f"Error: File not found: {file_path}" + + if not path.is_file(): + return f"Error: Path is not a file: {file_path}" + + try: + content = path.read_text() + except Exception as e: + return f"Error reading file {file_path}: {e}" + + # Check if old_string exists + if old_string not in content: + return ( + f"Error: old_string not found in {file_path}.\n" + f"Make sure you've read the file first and are using the exact string." + ) + + # Count occurrences + count = content.count(old_string) + + if not replace_all and count > 1: + return ( + f"Error: old_string appears {count} times in {file_path}.\n" + f"Either provide more context to make it unique, or set " + f"replace_all=true to replace all occurrences." + ) + + # Perform replacement + if replace_all: + new_content = content.replace(old_string, new_string) + else: + new_content = content.replace(old_string, new_string, 1) + + try: + path.write_text(new_content) + logger.info(f"Successfully replaced {count} occurrence(s) in {file_path}") + return f"Successfully replaced {count} occurrence(s) in {file_path}" + except Exception as e: + error_msg = f"Error writing file {file_path}: {e}" + logger.error(error_msg) + return error_msg diff --git a/veadk/tools/skills_tools/session_path.py b/veadk/tools/skills_tools/session_path.py new file mode 100644 index 00000000..e2a10cae --- /dev/null +++ b/veadk/tools/skills_tools/session_path.py @@ -0,0 +1,122 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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. + +import tempfile +from pathlib import Path +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + +# Cache of initialized session paths to avoid re-creating symlinks +_session_path_cache: dict[str, Path] = {} + + +def initialize_session_path(session_id: str, skills_directory: str) -> Path: + """Initialize a session's working directory with skills symlink. + + This is called by SkillsPlugin.before_agent_callback() to ensure the session + is set up before any tools run. Creates the directory structure and symlink + to the skills directory. + + Directory structure: + /tmp/veadk/{session_id}/ + ├── skills/ -> symlink to skills_directory (read-only shared skills) + ├── uploads/ -> staged user files (temporary) + └── outputs/ -> generated files for return + + Args: + session_id: The unique ID of the current session. + skills_directory: Path to the shared skills directory. + + Returns: + The resolved path to the session's root directory. + """ + # Return cached path if already initialized + if session_id in _session_path_cache: + return _session_path_cache[session_id] + + # Initialize new session path + base_path = Path(tempfile.gettempdir()) / "veadk" + session_path = base_path / session_id + + # Create working directories + (session_path / "uploads").mkdir(parents=True, exist_ok=True) + (session_path / "outputs").mkdir(parents=True, exist_ok=True) + + # Create symlink to skills directory + skills_mount = Path(skills_directory) + skills_link = session_path / "skills" + if skills_mount.exists() and not skills_link.exists(): + try: + skills_link.symlink_to(skills_mount) + logger.debug(f"Created symlink: {skills_link} -> {skills_mount}") + except FileExistsError: + # Symlink already exists (race condition from concurrent session setup) + pass + except Exception as e: + # Log but don't fail - skills can still be accessed via absolute path + logger.warning( + f"Failed to create skills symlink for session {session_id}: {e}" + ) + + # Cache and return + resolved_path = session_path.resolve() + _session_path_cache[session_id] = resolved_path + return resolved_path + + +def get_session_path(session_id: str) -> Path: + """Get the working directory path for a session. + + This function retrieves the cached session path that was initialized by + SkillsPlugin. If the session hasn't been initialized (plugin not used), + it falls back to auto-initialization with default /skills directory. + + Tools should call this function to get their working directory. The session + must be initialized by SkillsPlugin before tools run, which happens automatically + via the before_agent_callback() hook. + + Args: + session_id: The unique ID of the current session. + + Returns: + The resolved path to the session's root directory. + + Note: + If session is not initialized, automatically initializes with /skills. + For custom skills directories, ensure SkillsPlugin is installed. + """ + # Return cached path if already initialized + if session_id in _session_path_cache: + return _session_path_cache[session_id] + + # Fallback: auto-initialize with default /skills + logger.warning( + f"Session {session_id} not initialized by SkillsPlugin. " + f"Auto-initializing with default /skills. " + f"Install SkillsPlugin for custom skills directories." + ) + return initialize_session_path(session_id, "/skills") + + +def clear_session_cache(session_id: str | None = None) -> None: + """Clear cached session path(s). + + Args: + session_id: Specific session to clear. If None, clears all cached sessions. + """ + if session_id: + _session_path_cache.pop(session_id, None) + else: + _session_path_cache.clear() diff --git a/veadk/tools/skills_tools/skills_tool.py b/veadk/tools/skills_tools/skills_tool.py new file mode 100644 index 00000000..2382bba0 --- /dev/null +++ b/veadk/tools/skills_tools/skills_tool.py @@ -0,0 +1,308 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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 Any, Dict + +import yaml +from google.adk.tools import BaseTool, ToolContext +from google.genai import types +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + + +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" + "- Avaliable skills listed in below\n" + "- If the invoked skills are not in the available skills, this tool will automatically download these skills from the remote object storage bucket." + "- Do not invoke a skill that is already loaded in the conversation\n" + "- After loading a skill, use the bash tool for execution\n" + "- If not specified, scripts are located in the skill-name/scripts subdirectory\n" + "\n\n" + ) + + # 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(): + # Try to download from TOS + logger.info( + f"Skill '{skill_name}' not found locally, attempting to download from TOS" + ) + + try: + from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + from veadk.integrations.ve_tos.ve_tos import VeTOS + import os + + access_key = os.getenv("VOLCENGINE_ACCESS_KEY") + secret_key = os.getenv("VOLCENGINE_SECRET_KEY") + session_token = "" + + if not (access_key and secret_key): + # Try to get from vefaas iam + cred = get_credential_from_vefaas_iam() + access_key = cred.access_key_id + secret_key = cred.secret_access_key + session_token = cred.session_token + + tos_skills_dir = os.getenv( + "TOS_SKILLS_DIR" + ) # e.g. tos://agentkit-skills/skills/ + + # Parse bucket and prefix from TOS_SKILLS_DIR + if not tos_skills_dir: + error_msg = ( + f"Error: TOS_SKILLS_DIR environment variable is not set. " + f"Cannot download skill '{skill_name}' from remote registry. " + f"Please set TOS_SKILLS_DIR" + ) + logger.error(error_msg) + return error_msg + + # Validate TOS_SKILLS_DIR format + if not tos_skills_dir.startswith("tos://"): + error_msg = ( + f"Error: TOS_SKILLS_DIR format is invalid: '{tos_skills_dir}'. " + f"Expected format: tos://bucket-name/path/to/skills/ " + f"Cannot download skill '{skill_name}'." + ) + logger.error(error_msg) + return error_msg + + # Parse bucket and prefix from TOS_SKILLS_DIR + # Remove "tos://" prefix and split by first "/" + path_without_protocol = tos_skills_dir[6:] # Remove "tos://" + + if "/" not in path_without_protocol: + # Only bucket name, no path + tos_bucket = path_without_protocol.rstrip("/") + tos_prefix = skill_name + else: + # Split bucket and path + first_slash_idx = path_without_protocol.index("/") + tos_bucket = path_without_protocol[:first_slash_idx] + base_path = path_without_protocol[first_slash_idx + 1 :].rstrip("/") + + # Combine base path with skill name + if base_path: + tos_prefix = f"{base_path}/{skill_name}" + else: + tos_prefix = skill_name + + logger.info( + f"Parsed TOS location - Bucket: {tos_bucket}, Prefix: {tos_prefix}" + ) + + # Initialize VeTOS client + tos_client = VeTOS( + ak=access_key, + sk=secret_key, + session_token=session_token, + bucket_name=tos_bucket, + ) + + # Download the skill directory from TOS + success = tos_client.download_directory( + bucket_name=tos_bucket, + prefix=tos_prefix, + local_dir=str(skill_dir), + ) + + if not success: + return ( + f"Error: Skill '{skill_name}' not found locally or in TOS registry " + f"({tos_bucket}/{tos_prefix}). Check the available skills list in the tool description." + ) + + logger.info(f"Successfully downloaded skill '{skill_name}' from TOS") + + except Exception as e: + logger.error(f"Failed to download skill '{skill_name}' from TOS: {e}") + return ( + f"Error: Skill '{skill_name}' not found locally and failed to download from TOS: {e}. " + f"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 + + logger.info(f"Loaded skill '{skill_name}' successfully.") + 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: {self.skills_directory}/{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/veadk/tools/skills_tools/skills_toolset.py b/veadk/tools/skills_tools/skills_toolset.py new file mode 100644 index 00000000..2944c9d9 --- /dev/null +++ b/veadk/tools/skills_tools/skills_toolset.py @@ -0,0 +1,90 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# 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 List, Optional + + +try: + from typing_extensions import override +except ImportError: + from typing import override + +from google.adk.agents.readonly_context import ReadonlyContext +from google.adk.tools import BaseTool, FunctionTool +from google.adk.tools.base_toolset import BaseToolset + +from veadk.tools.skills_tools import ( + SkillsTool, + read_file_tool, + write_file_tool, + edit_file_tool, + bash_tool, +) +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + + +class SkillsToolset(BaseToolset): + """Toolset that provides Skills functionality for domain expertise execution. + + This toolset provides skills access through specialized tools: + 1. SkillsTool - Discover and load skill instructions + 2. ReadFileTool - Read files with line numbers + 3. WriteFileTool - Write/create files + 4. EditFileTool - Edit files with precise replacements + 5. BashTool - Execute shell commands + + Skills provide specialized domain knowledge and scripts that the agent can use + to solve complex tasks. The toolset enables discovery of available skills, + file manipulation, and command execution. + + Note: For file upload/download, use the ArtifactsToolset separately. + """ + + def __init__(self, skills_directory: str | Path): + """Initialize the skills toolset. + + Args: + skills_directory: Path to directory containing skill folders. + """ + super().__init__() + self.skills_directory = Path(skills_directory) + + # Create skills tools + self.skills_tool = SkillsTool(self.skills_directory) + self.read_file_tool = FunctionTool(func=read_file_tool) + self.write_file_tool = FunctionTool(write_file_tool) + self.edit_file_tool = FunctionTool(edit_file_tool) + self.bash_tool = FunctionTool(bash_tool) + + @override + async def get_tools( + self, readonly_context: Optional[ReadonlyContext] = None + ) -> List[BaseTool]: + """Get all skills tools. + + Returns: + List containing all skills tools: skills, read, write, edit, and bash. + """ + return [ + self.skills_tool, + self.read_file_tool, + self.write_file_tool, + self.edit_file_tool, + self.bash_tool, + ]