diff --git a/veadk/agent.py b/veadk/agent.py
index 28066635..18dee834 100644
--- a/veadk/agent.py
+++ b/veadk/agent.py
@@ -15,7 +15,7 @@
from __future__ import annotations
import os
-from typing import Optional, Union
+from typing import Dict, Optional, Union
# If user didn't set LITELLM_LOCAL_MODEL_COST_MAP, set it to True
# to enable local model cost map.
@@ -297,26 +297,45 @@ def update_model(self, model_name: str):
def load_skills(self):
from pathlib import Path
+ from veadk.skills.skill import Skill
+ from veadk.skills.utils import (
+ load_skills_from_directory,
+ load_skills_from_cloud,
+ )
+ from veadk.tools.builtin_tools.playwright import playwright_tools
+ from veadk.tools.skills_tools import (
+ SkillsTool,
+ read_file_tool,
+ write_file_tool,
+ edit_file_tool,
+ bash_tool,
+ )
- from veadk.skills.utils import load_skills_from_directory
+ skills: Dict[str, Skill] = {}
- skills = []
- for skill in self.skills:
- path = Path(skill)
- if path.is_dir():
- skills.extend(load_skills_from_directory(path))
+ for item in self.skills:
+ path = Path(item)
+ if path.exists() and path.is_dir():
+ for skill in load_skills_from_directory(path):
+ skills[skill.name] = skill
else:
- logger.error(
- f"Skill {skill} is not a directory, skip. Loading skills from cloud is WIP."
- )
+ for skill in load_skills_from_cloud(item):
+ skills[skill.name] = skill
if skills:
self.instruction += "\nYou have the following skills:\n"
- for skill in skills:
+ for skill in skills.values():
self.instruction += (
f"- name: {skill.name}\n- description: {skill.description}\n\n"
)
+ self.tools.append(SkillsTool(skills))
+ self.tools.append(read_file_tool)
+ self.tools.append(write_file_tool)
+ self.tools.append(edit_file_tool)
+ self.tools.append(bash_tool)
+ self.tools.append(playwright_tools)
+
async def _run(
self,
runner,
diff --git a/veadk/runner.py b/veadk/runner.py
index 888a9a1f..7c901978 100644
--- a/veadk/runner.py
+++ b/veadk/runner.py
@@ -472,6 +472,11 @@ async def run(
)
logger.info(f"Run config: {run_config}")
+ if self.agent.skills:
+ from veadk.tools.skills_tools.session_path import initialize_session_path
+
+ initialize_session_path(session_id)
+
user_id = user_id or self.user_id
converted_messages: list = _convert_messages(
diff --git a/veadk/skills/skill.py b/veadk/skills/skill.py
index 8953c612..27184fa2 100644
--- a/veadk/skills/skill.py
+++ b/veadk/skills/skill.py
@@ -13,9 +13,12 @@
# limitations under the License.
from pydantic import BaseModel
+from typing import Optional
class Skill(BaseModel):
name: str
description: str
- path: str
+ path: str # local path or tos path
+ skill_space_id: Optional[str] = None
+ bucket_name: Optional[str] = None
diff --git a/veadk/skills/utils.py b/veadk/skills/utils.py
index b64d7d56..deb69569 100644
--- a/veadk/skills/utils.py
+++ b/veadk/skills/utils.py
@@ -12,12 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import json
from pathlib import Path
-
+import os
import frontmatter
from veadk.skills.skill import Skill
from veadk.utils.logger import get_logger
+from veadk.utils.volcengine_sign import ve_request
logger = get_logger(__name__)
@@ -25,6 +27,10 @@
def load_skill_from_directory(skill_directory: Path) -> Skill:
logger.info(f"Load skill from {skill_directory}")
skill_readme = skill_directory / "SKILL.md"
+ if not skill_readme.exists():
+ logger.error(f"Skill '{skill_directory}' has no SKILL.md file.")
+ raise ValueError(f"Skill '{skill_directory}' has no SKILL.md file")
+
skill = frontmatter.load(str(skill_readme))
skill_name = skill.get("name", "")
@@ -39,7 +45,7 @@ def load_skill_from_directory(skill_directory: Path) -> Skill:
)
logger.info(
- f"Successfully loaded skill from {skill_readme}, name={skill['name']}, description={skill['description']}"
+ f"Successfully loaded skill {skill_name} locally from {skill_readme}, name={skill_name}, description={skill_description}"
)
return Skill(
name=skill_name, # type: ignore
@@ -58,4 +64,76 @@ def load_skills_from_directory(skills_directory: Path) -> list[Skill]:
return skills
-def load_skills_from_cloud(space_name: str) -> list[Skill]: ...
+def load_skills_from_cloud(skill_space_ids: str) -> list[Skill]:
+ skill_space_ids_list = [x.strip() for x in skill_space_ids.split(",")]
+ logger.info(f"Load skills from skill spaces: {skill_space_ids_list}")
+
+ from veadk.auth.veauth.utils import get_credential_from_vefaas_iam
+
+ skills = []
+
+ for skill_space_id in skill_space_ids_list:
+ try:
+ service = os.getenv("AGENTKIT_TOOL_SERVICE_CODE", "agentkit")
+ region = os.getenv("AGENTKIT_TOOL_REGION", "cn-beijing")
+ host = os.getenv("AGENTKIT_SKILL_HOST", "open.volcengineapi.com")
+
+ 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
+
+ response = ve_request(
+ request_body={
+ "SkillSpaceId": skill_space_id,
+ "InnerTags": {"source": "sandbox"},
+ },
+ action="ListSkillsBySpaceId",
+ ak=access_key,
+ sk=secret_key,
+ service=service,
+ version="2025-10-30",
+ region=region,
+ host=host,
+ header={"X-Security-Token": session_token},
+ )
+
+ if isinstance(response, str):
+ response = json.loads(response)
+
+ list_skills_result = response.get("Result")
+ items = list_skills_result.get("Items")
+
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ skill_name = item.get("Name")
+ skill_description = item.get("Description")
+ tos_bucket = item.get("BucketName")
+ tos_path = item.get("TosPath")
+ if not skill_name:
+ continue
+
+ skill = Skill(
+ name=skill_name, # type: ignore
+ description=skill_description, # type: ignore
+ path=tos_path,
+ skill_space_id=skill_space_id,
+ bucket_name=tos_bucket,
+ )
+
+ skills.append(skill)
+
+ logger.info(
+ f"Successfully loaded skill {skill_name} from skill space={skill_space_id}, name={skill_name}, description={skill_description}"
+ )
+ except Exception as e:
+ logger.error(f"Failed to load skill from skill space: {e}")
+
+ return skills
diff --git a/veadk/tools/builtin_tools/execute_skills.py b/veadk/tools/builtin_tools/execute_skills.py
index 6cd5a377..95bb9142 100644
--- a/veadk/tools/builtin_tools/execute_skills.py
+++ b/veadk/tools/builtin_tools/execute_skills.py
@@ -71,7 +71,6 @@ def execute_skills(
workflow_prompt: str,
skills: Optional[List[str]] = None,
tool_context: ToolContext = None,
- timeout: int = 900,
) -> str:
"""execute skills in a code sandbox and return the output.
For C++ code, don't execute it directly, compile and execute via Python; write sources and object files to /tmp.
@@ -79,11 +78,11 @@ def execute_skills(
Args:
workflow_prompt (str): instruction of workflow
skills (Optional[List[str]]): The skills will be invoked
- timeout (int, optional): The timeout in seconds for the code execution, less than or equal to 900. Defaults to 900.
Returns:
str: The output of the code execution.
"""
+ timeout = 900 # The timeout in seconds for the code execution, less than or equal to 900. Defaults to 900. Hard-coded to prevent the Agent from adjusting this parameter.
tool_id = getenv("AGENTKIT_TOOL_ID")
@@ -131,26 +130,12 @@ def execute_skills(
if skills:
cmd.extend(["--skills"] + skills)
- # TODO: remove after agentkit supports custom environment variables setting
- res = ve_request(
- request_body={},
- action="GetCallerIdentity",
- ak=ak,
- sk=sk,
- service="sts",
- version="2018-01-01",
- region=region,
- host="sts.volcengineapi.com",
- header=header,
- )
- try:
- account_id = res["Result"]["AccountId"]
- except KeyError as e:
- logger.error(f"Error occurred while getting account id: {e}, response is {res}")
- return res
+ skill_space_id = os.getenv("SKILL_SPACE_ID", "")
+ if not skill_space_id:
+ logger.warning("SKILL_SPACE_ID environment variable is not set")
env_vars = {
- "TOS_SKILLS_DIR": f"tos://agentkit-platform-{account_id}/skills/",
+ "SKILL_SPACE_ID": skill_space_id,
"TOOL_USER_SESSION_ID": tool_user_session_id,
}
diff --git a/veadk/tools/skills_tools/__init__.py b/veadk/tools/skills_tools/__init__.py
new file mode 100644
index 00000000..b44d44f8
--- /dev/null
+++ b/veadk/tools/skills_tools/__init__.py
@@ -0,0 +1,30 @@
+# 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 .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",
+ "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..1a27cd05
--- /dev/null
+++ b/veadk/tools/skills_tools/bash_tool.py
@@ -0,0 +1,158 @@
+# 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}/
+ - Working directory structure:
+ /tmp/veadk/{session_id}/
+ ├── skills/ -> all skills are available here (read-only).
+ ├── uploads/ -> staged user files (temporary)
+ └── outputs/ -> generated files for return
+ - 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..3fdb8bc4
--- /dev/null
+++ b/veadk/tools/skills_tools/file_tool.py
@@ -0,0 +1,234 @@
+# 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.
+
+ Working directory structure:
+ /tmp/veadk/{session_id}/
+ ├── skills/ -> all skills are available here (read-only).
+ ├── uploads/ -> staged user files (temporary)
+ └── outputs/ -> generated files for return
+
+ 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.
+
+ Working directory structure:
+ /tmp/veadk/{session_id}/
+ ├── skills/ -> all skills are available here (read-only).
+ ├── uploads/ -> staged user files (temporary)
+ └── outputs/ -> generated files for return
+
+ 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.
+
+ Working directory structure:
+ /tmp/veadk/{session_id}/
+ ├── skills/ -> all skills are available here (read-only).
+ ├── uploads/ -> staged user files (temporary)
+ └── outputs/ -> generated files for return
+
+ 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..2e985d41
--- /dev/null
+++ b/veadk/tools/skills_tools/session_path.py
@@ -0,0 +1,107 @@
+# 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) -> 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 / "skills").mkdir(parents=True, exist_ok=True)
+ (session_path / "uploads").mkdir(parents=True, exist_ok=True)
+ (session_path / "outputs").mkdir(parents=True, exist_ok=True)
+
+ # 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..06854bd1
--- /dev/null
+++ b/veadk/tools/skills_tools/skills_tool.py
@@ -0,0 +1,236 @@
+# 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 os
+import tempfile
+from pathlib import Path
+from typing import Any, Dict
+
+from google.adk.tools import BaseTool, ToolContext
+from google.genai import types
+
+from veadk.skills.skill import Skill
+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: Dict[str, Skill]):
+ self.skills = skills
+
+ # 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"
+ "- 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"
+ )
+
+ return base_description
+
+ 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, tool_context)
+
+ def _invoke_skill(self, skill_name: str, tool_context: ToolContext) -> str:
+ """Load and return the full content of a skill."""
+ if skill_name not in self.skills:
+ return f"Error: Skill '{skill_name}' does not exist."
+
+ skill = self.skills[skill_name]
+ skill_dir = (
+ Path(tempfile.gettempdir()) / "veadk" / tool_context.session.id / "skills"
+ )
+
+ if skill.skill_space_id:
+ logger.info(f"Attempting to download skill '{skill_name}' from skill space")
+ try:
+ from veadk.auth.veauth.utils import get_credential_from_vefaas_iam
+ from veadk.integrations.ve_tos.ve_tos import VeTOS
+
+ 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_bucket, tos_path = skill.bucket_name, skill.path
+
+ # Initialize VeTOS client
+ tos_client = VeTOS(
+ ak=access_key,
+ sk=secret_key,
+ session_token=session_token,
+ bucket_name=tos_bucket,
+ )
+
+ save_path = skill_dir / f"{skill_name}.zip"
+
+ success = tos_client.download(
+ bucket_name=tos_bucket,
+ object_key=tos_path,
+ save_path=save_path,
+ )
+
+ if not success:
+ return f"Error: Failed to download skill '{skill_name}' from TOS."
+
+ # Extract downloaded zip into the skill directory
+ import zipfile
+ import shutil
+
+ # Remove existing skill directory to ensure clean extraction
+ target_skill_dir = skill_dir / skill_name
+ if target_skill_dir.exists():
+ try:
+ shutil.rmtree(target_skill_dir)
+ logger.info(
+ f"Removed existing skill directory: {target_skill_dir}"
+ )
+ except Exception as e:
+ logger.warning(
+ f"Failed to remove existing skill directory {target_skill_dir}: {e}"
+ )
+
+ try:
+ with zipfile.ZipFile(save_path, "r") as z:
+ z.extractall(path=str(skill_dir))
+ except zipfile.BadZipFile:
+ logger.error(
+ f"Downloaded file for '{skill_name}' is not a valid zip"
+ )
+ return f"Error: Downloaded file for skill '{skill_name}' is not a valid zip archive."
+ except Exception as e:
+ logger.error(f"Failed to extract skill zip for '{skill_name}': {e}")
+ return (
+ f"Error: Failed to extract skill '{skill_name}' from zip: {e}"
+ )
+
+ logger.info(
+ f"Successfully downloaded skill '{skill_name}' from skill space"
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Failed to download skill '{skill_name}' from skill space: {e}"
+ )
+ return (
+ f"Error: Skill '{skill_name}' not found locally and failed to download from skill space: {e}. "
+ f"Check the available skills list in the tool description."
+ )
+ else:
+ # Create symlink to skills directory
+ skills_mount = Path(skill.path)
+ skills_link = skill_dir / skill_name
+ 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 {str(skills_mount)}: {e}"
+ )
+
+ skill_file = skill_dir / skill_name / "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, str(skill_dir)
+ )
+
+ logger.info(f"Invoke skill '{skill_name}' successfully.")
+ return formatted_content
+
+ except Exception as e:
+ logger.error(f"Failed to invoke skill {skill_name}: {e}")
+ return f"Error invoking skill '{skill_name}': {e}"
+
+ def _format_skill_content(self, skill_name: str, content: str, skill_dir) -> 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: {skill_dir}/{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