Skip to content

Commit b54ca35

Browse files
committed
Add skill loading support to generic agent
This commit introduces comprehensive skill loading functionality to the generic agent: Core Features: - Added SKILL_FOLDERS configuration to load custom skills from specified directories - Implemented load_skills_content() function to read skill files (SKILL.md, *.py, *.md) - Enhanced system prompt to include loaded skill descriptions and capabilities - Updated agent card to display available skills alongside MCP servers Error Handling: - Graceful degradation when MCP servers are unavailable - Warning messages instead of failures for missing skill folders - Continues operation with partial skill loading if some skills fail Testing: - Added comprehensive test suite in tests/ directory - Includes test_skill_loading.py for full integration tests - Includes test_skill_loading_simple.py for basic functionality tests - Added run_skill_test.sh script for easy test execution - Created tests/README.md with testing documentation Documentation: - Updated README.md with skill loading information - Added testing section with quick start guide - Documented SKILL_FOLDERS environment variable Code Quality: - Fixed trailing whitespace and newline issues in multiple files - Added proper logging throughout skill loading process - Improved error messages for better debugging Signed-off-by: Eran Raichstein <eranra@il.ibm.com>
1 parent 8655e29 commit b54ca35

15 files changed

Lines changed: 2549 additions & 26 deletions

File tree

a2a/generic_agent/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ ENV PRODUCTION_MODE=True \
1111
RUN chown -R 1001:1001 /app
1212
USER 1001
1313

14-
CMD ["uv", "run", "--no-sync", "server"]
14+
CMD ["uv", "run", "--no-sync", "server"]

a2a/generic_agent/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,26 @@ A flexible A2A agent that can be configured with multiple MCP servers to provide
99
- **Multi-MCP Support**: Connect to multiple MCP servers simultaneously
1010
- **Dynamic Tool Loading**: Automatically discovers and uses tools from connected MCP servers
1111
- **LLM Agnostic**: Works with any OpenAI-compatible API (Ollama, OpenAI, etc.)
12+
- **Skill Loading**: Load custom skills to enhance agent capabilities
1213
- **A2A Protocol**: Full integration with A2A server for task management and streaming
1314

15+
## Testing
16+
17+
To test the agent's skill loading functionality, see the [tests directory](./tests/README.md).
18+
19+
Quick start:
20+
```bash
21+
cd tests
22+
./run_skill_test.sh
23+
```
24+
1425
### Environment Variables
1526

1627
| Variable | Description | Default |
1728
|----------|-------------|---------|
1829
| `MCP_URLS` | Comma-separated list of MCP server URLs | `http://localhost:8000/mcp` |
1930
| `MCP_TRANSPORT` | Transport protocol for MCP | `streamable_http` |
31+
| `SKILL_FOLDERS` | Comma-separated list of skill folder paths | `/app/skills/` |
2032
| `LLM_MODEL` | Model name to use | `llama3.2:3b-instruct-fp16` |
2133
| `LLM_API_BASE` | Base URL for LLM API | `http://localhost:11434/v1` |
2234
| `LLM_API_KEY` | API key for LLM service | `dummy` |
@@ -40,7 +52,7 @@ Once deployed, the agent can handle requests like:
4052
"Find me flights from SFO to TPE for November 22, 2025."
4153
```
4254

43-
The agent automatically selects the appropriate tool from connected MCP servers and returns formatted results.
55+
The agent automatically selects the appropriate tool from connected MCP servers and returns formatted results.
4456

4557
## Running in Kagenti
4658

@@ -49,4 +61,4 @@ When deploying in the Kagenti UI:
4961
1. Import your intended tools using the `Import New Tools` section.
5062
2. In the `Import New Agent` section, follow the given prompts and configure these environmental variables:
5163
- Choose the `ollama` or `openai` environmental variable set
52-
- Create a new variable, `MCP_URLS`, and add your list of MCP server URLs separated with commas.
64+
- Create a new variable, `MCP_URLS`, and add your list of MCP server URLs separated with commas.

a2a/generic_agent/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ server = "generic_agent.agent:run"
2424

2525
[build-system]
2626
requires = ["hatchling"]
27-
build-backend = "hatchling.build"
27+
build-backend = "hatchling.build"

a2a/generic_agent/src/generic_agent/agent.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from a2a.types import AgentCapabilities, AgentCard, AgentSkill, TaskState, TextPart
1616
from a2a.utils import new_agent_text_message, new_task
1717
from generic_agent.config import Configuration
18-
from generic_agent.graph import get_graph, get_mcp_server_names, get_mcpclient
18+
from generic_agent.graph import get_graph, get_mcp_server_names, get_mcpclient, get_skill_folder_paths
1919

2020
logging.basicConfig(level=logging.DEBUG)
2121
logger = logging.getLogger(__name__)
@@ -31,23 +31,36 @@ def get_agent_card(host: str, port: int) -> AgentCard:
3131
except Exception as e:
3232
logger.warning(f"Failed to get MCP server names: {e}")
3333
mcp_names = []
34+
35+
try:
36+
skill_folder_paths = get_skill_folder_paths()
37+
# Extract skill names from folder paths (last component of path)
38+
skill_names = [path.rstrip("/").split("/")[-1] for path in skill_folder_paths if path]
39+
except Exception as e:
40+
logger.warning(f"Failed to get skill folder paths: {e}")
41+
skill_names = []
42+
3443
mcp_section = ""
3544
if mcp_names:
3645
mcp_section = "\n\nConnected MCP Servers:\n" + "\n".join(f"- {name}" for name in mcp_names)
3746

47+
skills_section = ""
48+
if skill_names:
49+
skills_section = "\n\nAvailable Skills:\n" + "\n".join(f"- {name}" for name in skill_names)
50+
3851
capabilities = AgentCapabilities(streaming=True)
3952
skill = AgentSkill(
4053
id="generic_agent",
4154
name="Generic Agent",
4255
description="**Generic Assistant** – Multi-purpose assistant for different tasks based on different MCP tools.",
43-
tags=mcp_names,
56+
tags=mcp_names + skill_names,
4457
examples=[],
4558
)
4659
return AgentCard(
4760
name="Generic Agent",
4861
description=dedent(
4962
f"""\
50-
This agent provides assistance for various tasks using different MCP tools.{mcp_section}
63+
This agent provides assistance for various tasks using different MCP tools.{mcp_section}{skills_section}
5164
""",
5265
),
5366
# Allow env var AGENT_ENDPOINT to override the URL in the agent card
@@ -133,25 +146,30 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
133146

134147
try:
135148
output = None
136-
# Test MCP connection first
149+
# Initialize MCP client with error handling
137150
logger.info(f"Attempting to connect to MCP server(s) at: {config.MCP_URLS}")
138151

139152
mcpclient = get_mcpclient()
140153

141154
# Try to get tools to verify connection
142155
try:
143156
tools = await mcpclient.get_tools()
144-
logger.info(
145-
f"Successfully connected to MCP server(s). Available tools: {[tool.name for tool in tools]}"
146-
)
157+
if tools:
158+
logger.info(
159+
f"Successfully connected to MCP server(s). Available tools: {[tool.name for tool in tools]}"
160+
)
161+
else:
162+
logger.warning("No MCP tools available, but agent will continue")
147163
except Exception as tool_error:
148-
logger.error(f"Failed to connect to MCP server(s): {tool_error}")
164+
logger.warning(
165+
f"Failed to connect to MCP server(s): {tool_error}. Agent will continue without MCP tools."
166+
)
149167
await event_emitter.emit_event(
150-
f"Error: Cannot connect to MCP server(s) at {config.MCP_URLS}. Please ensure the MCP server(s) are running. Error: {tool_error}",
151-
failed=True,
168+
"⚠️ Warning: Cannot connect to MCP server(s). Agent will continue with limited capabilities.",
169+
final=False,
152170
)
153-
return
154171

172+
# Create graph (will work even without MCP tools)
155173
graph = await get_graph(mcpclient)
156174
async for event in graph.astream(input, stream_mode="updates"):
157175
await event_emitter.emit_event(

a2a/generic_agent/src/generic_agent/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ class Configuration(BaseSettings):
99
MCP_TRANSPORT: str = "streamable_http"
1010
MAX_EVENT_DISPLAY_LENGTH: int = 256
1111
AGENT_VERSION: str = "1.0.0"
12+
SKILL_FOLDERS: str = "/app/skills/"

a2a/generic_agent/src/generic_agent/graph.py

Lines changed: 168 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import logging
12
from functools import lru_cache
3+
from pathlib import Path
24
from typing import List
35

46
from langchain_core.messages import AIMessage, SystemMessage
@@ -10,6 +12,7 @@
1012
from generic_agent.config import Configuration
1113

1214
config = Configuration()
15+
logger = logging.getLogger(__name__)
1316

1417

1518
# Extend MessagesState to include a final answer
@@ -25,16 +28,33 @@ def _get_mcp_urls() -> List[str]:
2528

2629
@lru_cache(maxsize=1)
2730
def get_mcpclient() -> MultiServerMCPClient:
31+
"""
32+
Create MCP client with error handling.
33+
34+
If individual MCP servers fail to connect, logs warnings but continues
35+
with available servers.
36+
37+
Returns:
38+
MultiServerMCPClient instance (may have empty configs if all servers fail)
39+
"""
2840
urls = _get_mcp_urls()
2941

3042
client_configs = {}
3143
transport = config.MCP_TRANSPORT
3244

3345
for i, url in enumerate(urls, 1):
34-
client_configs[f"mcp{i}"] = {
35-
"url": url,
36-
"transport": transport,
37-
}
46+
try:
47+
client_configs[f"mcp{i}"] = {
48+
"url": url,
49+
"transport": transport,
50+
}
51+
except Exception as e:
52+
logger.warning(f"Failed to configure MCP server {url}: {e}")
53+
continue
54+
55+
if not client_configs:
56+
logger.warning("No MCP servers configured successfully. Agent will work without MCP tools.")
57+
3858
return MultiServerMCPClient(client_configs)
3959

4060

@@ -64,6 +84,126 @@ def get_mcp_server_names() -> List[str]:
6484
return mcp_names
6585

6686

87+
def get_skill_folder_paths() -> List[str]:
88+
"""
89+
Extract skill folder paths from the SKILL_FOLDERS configuration.
90+
91+
The SKILL_FOLDERS field contains comma-separated folder paths.
92+
Example: "/app/skills/pdf,/app/skills/skill-creator" -> ["/app/skills/pdf", "/app/skills/skill-creator"]
93+
94+
Returns:
95+
List of skill folder paths
96+
"""
97+
folders_str = config.SKILL_FOLDERS
98+
if not folders_str or not folders_str.strip():
99+
return []
100+
101+
folder_paths = []
102+
for folder in folders_str.split(","):
103+
folder = folder.strip()
104+
if folder:
105+
folder_paths.append(folder)
106+
107+
return folder_paths
108+
109+
110+
def load_skills_content() -> str:
111+
"""
112+
Load skill content from skill folders with error handling.
113+
114+
Reads all relevant files from each skill folder:
115+
- SKILL.md: Main skill description and instructions
116+
- *.py: Python scripts and tools
117+
- *.md: Additional documentation files
118+
119+
If individual skills fail to load, logs warnings but continues with other skills.
120+
121+
Returns:
122+
Combined skill content as a string, or empty string if no skills found
123+
"""
124+
skill_folders = get_skill_folder_paths()
125+
if not skill_folders:
126+
return ""
127+
128+
skills_content = []
129+
failed_skills = []
130+
131+
for folder_path in skill_folders:
132+
try:
133+
skill_path = Path(folder_path)
134+
if not skill_path.exists() or not skill_path.is_dir():
135+
logger.warning(f"Skill folder does not exist or is not a directory: {folder_path}")
136+
failed_skills.append(skill_path.name if skill_path else folder_path)
137+
continue
138+
139+
skill_name = skill_path.name
140+
skill_parts = [f"### Skill: {skill_name}\n"]
141+
142+
# Load SKILL.md first (main description)
143+
skill_md_path = skill_path / "SKILL.md"
144+
if skill_md_path.exists() and skill_md_path.is_file():
145+
try:
146+
with open(skill_md_path, "r", encoding="utf-8") as f:
147+
content = f.read().strip()
148+
if content:
149+
skill_parts.append(f"#### Main Description\n\n{content}")
150+
except Exception as e:
151+
logger.warning(f"Failed to read {skill_md_path}: {e}")
152+
153+
# Load Python scripts (these are tools/utilities the skill provides)
154+
python_files = sorted(skill_path.rglob("*.py"))
155+
if python_files:
156+
scripts_content = []
157+
for py_file in python_files:
158+
try:
159+
# Get relative path from skill folder
160+
rel_path = py_file.relative_to(skill_path)
161+
with open(py_file, "r", encoding="utf-8") as f:
162+
code = f.read().strip()
163+
if code:
164+
scripts_content.append(f"**File: {rel_path}**\n```python\n{code}\n```")
165+
except Exception as e:
166+
logger.warning(f"Failed to read {py_file}: {e}")
167+
168+
if scripts_content:
169+
skill_parts.append("#### Available Scripts\n\n" + "\n\n".join(scripts_content))
170+
171+
# Load additional markdown files (documentation, examples, etc.)
172+
md_files = sorted([f for f in skill_path.rglob("*.md") if f.name != "SKILL.md"])
173+
if md_files:
174+
docs_content = []
175+
for md_file in md_files:
176+
try:
177+
rel_path = md_file.relative_to(skill_path)
178+
with open(md_file, "r", encoding="utf-8") as f:
179+
content = f.read().strip()
180+
if content:
181+
docs_content.append(f"**{rel_path}**\n\n{content}")
182+
except Exception as e:
183+
logger.warning(f"Failed to read {md_file}: {e}")
184+
185+
if docs_content:
186+
skill_parts.append("#### Additional Documentation\n\n" + "\n\n".join(docs_content))
187+
188+
if len(skill_parts) > 1: # More than just the header
189+
skills_content.append("\n\n".join(skill_parts))
190+
except Exception as e:
191+
logger.warning(f"Failed to load skill from {folder_path}: {e}")
192+
failed_skills.append(Path(folder_path).name)
193+
continue
194+
195+
if failed_skills:
196+
logger.warning(f"Failed to load {len(failed_skills)} skill(s): {', '.join(failed_skills)}")
197+
198+
if skills_content:
199+
loaded_count = len(skills_content)
200+
logger.info(f"Successfully loaded {loaded_count} skill(s)")
201+
return "\n\n" + "\n\n---\n\n".join(skills_content)
202+
203+
logger.info("No skills loaded")
204+
return ""
205+
206+
67207
async def get_graph(client: MultiServerMCPClient) -> StateGraph:
68208
llm = ChatOpenAI(
69209
model=config.LLM_MODEL,
@@ -72,14 +212,31 @@ async def get_graph(client: MultiServerMCPClient) -> StateGraph:
72212
temperature=0,
73213
)
74214

75-
# Get tools asynchronously
76-
tools = await client.get_tools()
77-
llm_with_tools = llm.bind_tools(tools)
215+
# Get tools asynchronously with error handling
216+
try:
217+
tools = await client.get_tools()
218+
if tools:
219+
logger.info(f"Successfully loaded {len(tools)} MCP tool(s)")
220+
else:
221+
logger.warning("No MCP tools available")
222+
llm_with_tools = llm.bind_tools(tools)
223+
except Exception as e:
224+
logger.warning(f"Failed to load MCP tools: {e}. Agent will work without MCP tools.")
225+
tools = []
226+
llm_with_tools = llm
78227

79-
# System message
80-
sys_msg = SystemMessage(
81-
content="You are the **Generic Assistant**, a multi-purpose, tool-based expert. Your primary directive is to fulfill user requests by effectively utilizing the available **MCP tools**. You will select the most appropriate tool(s) based on the user's need (e.g., weather, calculations, data retrieval) and strictly adhere to their output to generate your final answer. Be precise and concise."
82-
)
228+
# Load skills content if available
229+
skills_content = load_skills_content()
230+
231+
# Build system message
232+
base_instruction = "You are the **Generic Assistant**, a multi-purpose, tool-based expert. Your primary directive is to fulfill user requests by effectively utilizing the available **MCP tools**. You will select the most appropriate tool(s) based on the user's need (e.g., weather, calculations, data retrieval) and strictly adhere to their output to generate your final answer. Be precise and concise."
233+
234+
if skills_content:
235+
system_content = f"{base_instruction}\n\n## Available Skills\n\nYou have access to the following specialized skills. Use them when the user's request matches the skill's capabilities:{skills_content}"
236+
else:
237+
system_content = base_instruction
238+
239+
sys_msg = SystemMessage(content=system_content)
83240

84241
# Node
85242
def assistant(state: ExtendedMessagesState) -> ExtendedMessagesState:

0 commit comments

Comments
 (0)