Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions veadk/skills/skills_plugin.py
Original file line number Diff line number Diff line change
@@ -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}")
32 changes: 32 additions & 0 deletions veadk/tools/skills_tools/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
154 changes: 154 additions & 0 deletions veadk/tools/skills_tools/bash_tool.py
Original file line number Diff line number Diff line change
@@ -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
Loading