Skip to content

Commit bdbdfd1

Browse files
authored
Merge pull request #279 from eranra/feature/add-skill-loading-support
Add skill loading support to generic agent
2 parents 234b1d4 + d76d2b6 commit bdbdfd1

16 files changed

Lines changed: 2863 additions & 24 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: 187 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from functools import lru_cache
1+
import logging
2+
from pathlib import Path
23
from typing import List
34

45
from langchain_core.messages import AIMessage, SystemMessage
@@ -10,6 +11,7 @@
1011
from generic_agent.config import Configuration
1112

1213
config = Configuration()
14+
logger = logging.getLogger(__name__)
1315

1416

1517
# Extend MessagesState to include a final answer
@@ -23,8 +25,20 @@ def _get_mcp_urls() -> List[str]:
2325
return [url.strip() for url in urls_str.split(",") if url.strip()]
2426

2527

26-
@lru_cache(maxsize=1)
2728
def get_mcpclient() -> MultiServerMCPClient:
29+
"""
30+
Create MCP client with error handling.
31+
32+
If individual MCP servers fail to connect, logs warnings but continues
33+
with available servers.
34+
35+
Returns:
36+
MultiServerMCPClient instance (may have empty configs if all servers fail)
37+
38+
Note:
39+
This function is not cached to allow retry on transient failures.
40+
Each call creates a new client instance.
41+
"""
2842
urls = _get_mcp_urls()
2943

3044
client_configs = {}
@@ -35,6 +49,10 @@ def get_mcpclient() -> MultiServerMCPClient:
3549
"url": url,
3650
"transport": transport,
3751
}
52+
53+
if not client_configs:
54+
logger.warning("No MCP servers configured successfully. Agent will work without MCP tools.")
55+
3856
return MultiServerMCPClient(client_configs)
3957

4058

@@ -64,6 +82,149 @@ def get_mcp_server_names() -> List[str]:
6482
return mcp_names
6583

6684

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

75-
# Get tools asynchronously
76-
tools = await client.get_tools()
77-
llm_with_tools = llm.bind_tools(tools)
236+
# Get tools asynchronously with error handling
237+
try:
238+
tools = await client.get_tools()
239+
if tools:
240+
logger.info(f"Successfully loaded {len(tools)} MCP tool(s)")
241+
else:
242+
logger.warning("No MCP tools available")
243+
llm_with_tools = llm.bind_tools(tools)
244+
except Exception as e:
245+
logger.warning(f"Failed to load MCP tools: {e}. Agent will work without MCP tools.")
246+
tools = []
247+
llm_with_tools = llm
78248

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-
)
249+
# Load skills content if available
250+
skills_content = load_skills_content()
251+
252+
# Build system message
253+
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."
254+
255+
if skills_content:
256+
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}"
257+
else:
258+
system_content = base_instruction
259+
260+
sys_msg = SystemMessage(content=system_content)
83261

84262
# Node
85263
def assistant(state: ExtendedMessagesState) -> ExtendedMessagesState:

0 commit comments

Comments
 (0)