Skip to content

Commit b4dd51c

Browse files
committed
feat: integrate skills in veadk
1 parent 6b1d40a commit b4dd51c

File tree

7 files changed

+1021
-0
lines changed

7 files changed

+1021
-0
lines changed

veadk/skills/skills_plugin.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from pathlib import Path
18+
from typing import Optional
19+
20+
from google.adk.agents import BaseAgent, LlmAgent
21+
from google.adk.agents.callback_context import CallbackContext
22+
from google.adk.plugins import BasePlugin
23+
from google.genai import types
24+
25+
from veadk.tools.skills_tools.session_path import initialize_session_path
26+
from veadk.tools.skills_tools.skills_toolset import SkillsToolset
27+
from veadk.utils.logger import get_logger
28+
29+
logger = get_logger(__name__)
30+
31+
32+
class SkillsPlugin(BasePlugin):
33+
"""Convenience plugin for multi-agent apps to automatically register Skills tools.
34+
35+
This plugin is purely a convenience wrapper that automatically adds the SkillsTool
36+
and BashTool and related file tools to all LLM agents in an application.
37+
It does not add any additional functionality beyond tool registration.
38+
39+
For single-agent use cases or when you prefer explicit control, you can skip this plugin
40+
and directly add both tools to your agent's tools list.
41+
42+
Example:
43+
# Without plugin (direct tool usage):
44+
agent = Agent(
45+
tools=[
46+
SkillsTool(skills_directory="./skills"),
47+
BashTool(skills_directory="./skills"),
48+
ReadFileTool(),
49+
WriteFileTool(),
50+
EditFileTool(),
51+
]
52+
)
53+
54+
# With plugin (auto-registration for multi-agent apps):
55+
app = App(
56+
root_agent=agent,
57+
plugins=[SkillsPlugin(skills_directory="./skills")]
58+
)
59+
"""
60+
61+
def __init__(self, skills_directory: str | Path, name: str = "skills_plugin"):
62+
"""Initialize the skills plugin.
63+
64+
Args:
65+
skills_directory: Path to directory containing skill folders.
66+
name: Name of the plugin instance.
67+
"""
68+
super().__init__(name)
69+
self.skills_directory = Path(skills_directory)
70+
71+
async def before_agent_callback(
72+
self, *, agent: BaseAgent, callback_context: CallbackContext
73+
) -> Optional[types.Content]:
74+
"""Initialize session path and add skills tools to agents if not already present.
75+
76+
This hook fires before any tools are invoked, ensuring the session working
77+
directory is set up with the skills symlink before any tool needs it.
78+
"""
79+
# Initialize session path FIRST (before tools run)
80+
# This creates the working directory structure and skills symlink
81+
session_id = callback_context.session.id
82+
initialize_session_path(session_id, str(self.skills_directory))
83+
logger.debug(f"Initialized session path for session: {session_id}")
84+
85+
add_skills_tool_to_agent(self.skills_directory, agent)
86+
87+
88+
def add_skills_tool_to_agent(skills_directory: str | Path, agent: BaseAgent) -> None:
89+
"""Utility function to add Skills and Bash tools to a given agent.
90+
91+
Args:
92+
agent: The LlmAgent instance to which the tools will be added.
93+
skills_directory: Path to directory containing skill folders.
94+
"""
95+
96+
if not isinstance(agent, LlmAgent):
97+
return
98+
99+
skills_directory = Path(skills_directory)
100+
agent.tools.append(SkillsToolset(skills_directory))
101+
logger.debug(f"Added skills toolset to agent: {agent.name}")
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .bash_tool import bash_tool
16+
from .file_tool import edit_file_tool, read_file_tool, write_file_tool
17+
from .skills_tool import SkillsTool
18+
from .skills_toolset import SkillsToolset
19+
from .session_path import initialize_session_path, get_session_path, clear_session_cache
20+
21+
22+
__all__ = [
23+
"bash_tool",
24+
"edit_file_tool",
25+
"read_file_tool",
26+
"write_file_tool",
27+
"SkillsTool",
28+
"SkillsToolset",
29+
"initialize_session_path",
30+
"get_session_path",
31+
"clear_session_cache",
32+
]
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import asyncio
18+
import os
19+
20+
from google.adk.tools import ToolContext
21+
from veadk.tools.skills_tools.session_path import get_session_path
22+
from veadk.utils.logger import get_logger
23+
24+
logger = get_logger(__name__)
25+
26+
27+
async def bash_tool(command: str, description: str, tool_context: ToolContext):
28+
"""Execute bash commands in the skills environment with local shell.
29+
30+
This tool uses the local bash shell to execute commands with:
31+
- Filesystem restrictions (controlled read/write access)
32+
- Network restrictions (controlled domain access)
33+
- Process isolation at the OS level
34+
35+
Use it for command-line operations like running scripts, installing packages, etc.
36+
For file operations (read/write/edit), use the dedicated file tools instead.
37+
38+
Execute bash commands in the skills environment with local shell.
39+
Working Directory & Structure:
40+
- Commands run in a temporary session directory: /tmp/veadk/{session_id}/
41+
- skills_directory -> All skills are available here (read-only).
42+
- Your current working directory is added to PYTHONPATH.
43+
44+
Python Imports (CRITICAL):
45+
- To import from a skill, use the full path from the 'skills' root.
46+
Example: from skills.skills_name.module import function
47+
- If the skills name contains a dash '-', you need to use importlib to import it.
48+
Example:
49+
import importlib
50+
skill_module = importlib.import_module('skills.skill-name.module')
51+
52+
For file operations:
53+
- Use read_file, write_file, and edit_file for interacting with the filesystem.
54+
55+
Timeouts:
56+
- pip install: 120s
57+
- python scripts: 60s
58+
- other commands: 30s
59+
60+
Args:
61+
command: Bash command to execute. Use && to chain commands.
62+
description: Clear, concise description of what this command does (5-10 words)
63+
tool_context: The context of the tool execution, including session info.
64+
65+
Returns:
66+
The output of the bash command or error message.
67+
"""
68+
69+
if not command:
70+
return "Error: No command provided"
71+
72+
try:
73+
# Get session working directory (initialized by SkillsPlugin)
74+
working_dir = get_session_path(session_id=tool_context.session.id)
75+
logger.info(f"Session working directory: {working_dir}")
76+
77+
# Determine timeout based on command
78+
timeout = _get_command_timeout_seconds(command)
79+
80+
# Prepare environment with PYTHONPATH including skills directory
81+
# This allows imports like: from skills.slack_gif_creator.core import something
82+
env = os.environ.copy()
83+
# Add root for 'from skills...' and working_dir for local scripts
84+
pythonpath_additions = [str(working_dir), "/"]
85+
if "PYTHONPATH" in env:
86+
pythonpath_additions.append(env["PYTHONPATH"])
87+
env["PYTHONPATH"] = ":".join(pythonpath_additions)
88+
89+
# Check for BASH_VENV_PATH to use a specific virtual environment
90+
provided = os.environ.get("BASH_VENV_PATH")
91+
if provided and os.path.isdir(provided):
92+
bash_venv_path = provided
93+
bash_venv_bin = os.path.join(bash_venv_path, "bin")
94+
logger.info(f"Using provided BASH_VENV_PATH: {bash_venv_path}")
95+
# Prepend bash venv to PATH so its python and pip are used
96+
env["PATH"] = f"{bash_venv_bin}:{env.get('PATH', '')}"
97+
env["VIRTUAL_ENV"] = bash_venv_path
98+
99+
# Execute with local bash shell
100+
local_bash_command = f"{command}"
101+
102+
process = await asyncio.create_subprocess_shell(
103+
local_bash_command,
104+
stdout=asyncio.subprocess.PIPE,
105+
stderr=asyncio.subprocess.PIPE,
106+
cwd=working_dir,
107+
env=env, # Pass the modified environment
108+
)
109+
110+
try:
111+
stdout, stderr = await asyncio.wait_for(
112+
process.communicate(), timeout=timeout
113+
)
114+
except asyncio.TimeoutError:
115+
process.kill()
116+
await process.wait()
117+
return f"Error: Command timed out after {timeout}s"
118+
119+
stdout_str = stdout.decode("utf-8", errors="replace") if stdout else ""
120+
stderr_str = stderr.decode("utf-8", errors="replace") if stderr else ""
121+
122+
# Handle command failure
123+
if process.returncode != 0:
124+
error_msg = f"Command failed with exit code {process.returncode}"
125+
if stderr_str:
126+
error_msg += f":\n{stderr_str}"
127+
elif stdout_str:
128+
error_msg += f":\n{stdout_str}"
129+
return error_msg
130+
131+
# Return output
132+
output = stdout_str
133+
if stderr_str and "WARNING" not in stderr_str:
134+
output += f"\n{stderr_str}"
135+
136+
result = output.strip() if output.strip() else "Command completed successfully."
137+
138+
logger.info(f"Executed bash command: {command}, description: {description}")
139+
logger.info(f"Command result: {result}")
140+
return result
141+
except Exception as e:
142+
error_msg = f"Error executing command '{command}': {e}"
143+
logger.error(error_msg)
144+
return error_msg
145+
146+
147+
def _get_command_timeout_seconds(command: str) -> float:
148+
"""Determine appropriate timeout for command in seconds."""
149+
if "pip install" in command or "pip3 install" in command:
150+
return 120.0
151+
elif "python " in command or "python3 " in command:
152+
return 60.0
153+
else:
154+
return 30.0

0 commit comments

Comments
 (0)