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,
+ ]