1- from functools import lru_cache
1+ import logging
2+ from pathlib import Path
23from typing import List
34
45from langchain_core .messages import AIMessage , SystemMessage
1011from generic_agent .config import Configuration
1112
1213config = 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 )
2728def 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+
67228async 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 \n You 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