Skip to content

Commit efd25f8

Browse files
committed
Improve skill loading robustness and add size limits
- Remove @lru_cache from get_mcpclient() to allow retry on transient failures - Add 100KB content size limit to prevent ConfigMap and context window issues - Add size tracking and warnings when limits are exceeded - Simplify MCP client configuration (remove unnecessary try-catch) - Fix test script to use correct test file (manual_test_skill_loading.py) - Improve shell script error handling with set -euo pipefail - Fix relative import in summarizer skill These changes address PR review feedback and improve the reliability of skill loading in production environments. Signed-off-by: Eran Raichstein <eranra@il.ibm.com>
1 parent b54ca35 commit efd25f8

4 files changed

Lines changed: 332 additions & 16 deletions

File tree

a2a/generic_agent/src/generic_agent/graph.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
from functools import lru_cache
32
from pathlib import Path
43
from typing import List
54

@@ -26,7 +25,6 @@ def _get_mcp_urls() -> List[str]:
2625
return [url.strip() for url in urls_str.split(",") if url.strip()]
2726

2827

29-
@lru_cache(maxsize=1)
3028
def get_mcpclient() -> MultiServerMCPClient:
3129
"""
3230
Create MCP client with error handling.
@@ -36,21 +34,21 @@ def get_mcpclient() -> MultiServerMCPClient:
3634
3735
Returns:
3836
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.
3941
"""
4042
urls = _get_mcp_urls()
4143

4244
client_configs = {}
4345
transport = config.MCP_TRANSPORT
4446

4547
for i, url in enumerate(urls, 1):
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
48+
client_configs[f"mcp{i}"] = {
49+
"url": url,
50+
"transport": transport,
51+
}
5452

5553
if not client_configs:
5654
logger.warning("No MCP servers configured successfully. Agent will work without MCP tools.")
@@ -120,13 +118,19 @@ def load_skills_content() -> str:
120118
121119
Returns:
122120
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.
123125
"""
124126
skill_folders = get_skill_folder_paths()
125127
if not skill_folders:
126128
return ""
127129

128130
skills_content = []
129131
failed_skills = []
132+
total_size = 0
133+
MAX_CONTENT_SIZE = 100 * 1024 # 100KB limit (summarizer skill is ~45KB)
130134

131135
for folder_path in skill_folders:
132136
try:
@@ -161,7 +165,16 @@ def load_skills_content() -> str:
161165
with open(py_file, "r", encoding="utf-8") as f:
162166
code = f.read().strip()
163167
if code:
164-
scripts_content.append(f"**File: {rel_path}**\n```python\n{code}\n```")
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)
165178
except Exception as e:
166179
logger.warning(f"Failed to read {py_file}: {e}")
167180

@@ -197,8 +210,16 @@ def load_skills_content() -> str:
197210

198211
if skills_content:
199212
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)
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
202223

203224
logger.info("No skills loaded")
204225
return ""
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"""
2+
Manual Test for Generic Agent with Summarizer Skill
3+
4+
This is a MANUAL TEST that requires a running Ollama instance and cannot be
5+
run in automated CI/CD pipelines. It verifies that the generic agent can:
6+
1. Load the summarizer skill from the SKILL_FOLDERS environment variable
7+
2. Start successfully with Ollama as the LLM backend
8+
3. Process a summarization request using the loaded skill
9+
4. Generate a response that demonstrates the skill was used
10+
11+
Prerequisites:
12+
- Ollama running locally on default port (11434)
13+
- A model available (default: llama3.2:3b-instruct-fp16)
14+
- The summarizer skill located at ../../../skills/summarizer (relative to this test file)
15+
16+
Usage:
17+
Run this test manually using the provided shell script:
18+
./run_skill_test.sh
19+
"""
20+
21+
import asyncio
22+
import os
23+
import sys
24+
from pathlib import Path
25+
26+
# Add the generic_agent module to the path
27+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
28+
29+
from generic_agent.config import Configuration
30+
from generic_agent.graph import get_graph, get_mcpclient, load_skills_content
31+
from langchain_core.messages import HumanMessage
32+
33+
34+
async def test_skill_loading():
35+
"""Test that the summarizer skill is loaded and used by the agent."""
36+
37+
print("=" * 80)
38+
print("Generic Agent - Summarizer Skill Test")
39+
print("=" * 80)
40+
41+
# Step 1: Set up environment to load the summarizer skill
42+
# Use relative path from this test file to the skills directory
43+
test_dir = Path(__file__).parent
44+
repo_root = test_dir.parent.parent.parent
45+
skill_path = str(repo_root / "skills" / "summarizer")
46+
os.environ["SKILL_FOLDERS"] = skill_path
47+
48+
print(f"\n✓ Set SKILL_FOLDERS environment variable to: {skill_path}")
49+
50+
# Verify the skill folder exists
51+
if not Path(skill_path).exists():
52+
print(f"\n✗ ERROR: Skill folder does not exist at {skill_path}")
53+
return False
54+
print("✓ Verified skill folder exists")
55+
56+
# Step 2: Load configuration
57+
config = Configuration()
58+
print("\n✓ Configuration loaded:")
59+
print(f" - LLM Model: {config.LLM_MODEL}")
60+
print(f" - LLM API Base: {config.LLM_API_BASE}")
61+
print(f" - Skill Folders: {config.SKILL_FOLDERS}")
62+
63+
# Step 3: Verify skill content is loaded
64+
print("\n" + "=" * 80)
65+
print("Testing Skill Loading")
66+
print("=" * 80)
67+
68+
skills_content = load_skills_content()
69+
70+
if not skills_content:
71+
print("\n✗ ERROR: No skills content loaded")
72+
return False
73+
74+
print("\n✓ Skills content loaded successfully")
75+
print(f" - Content length: {len(skills_content)} characters")
76+
77+
# Verify the summarizer skill is in the content
78+
if "summarizer" not in skills_content.lower():
79+
print("\n✗ ERROR: Summarizer skill not found in loaded content")
80+
return False
81+
82+
print("✓ Verified 'summarizer' skill is present in loaded content")
83+
84+
# Check for key skill components
85+
skill_indicators = ["summarization", "bullet", "executive summary", "key points"]
86+
87+
found_indicators = [ind for ind in skill_indicators if ind.lower() in skills_content.lower()]
88+
print(f"✓ Found {len(found_indicators)}/{len(skill_indicators)} skill indicators:")
89+
for indicator in found_indicators:
90+
print(f" - {indicator}")
91+
92+
# Step 4: Initialize MCP client (may be empty, that's OK)
93+
print("\n" + "=" * 80)
94+
print("Initializing Agent Components")
95+
print("=" * 80)
96+
97+
try:
98+
mcpclient = get_mcpclient()
99+
print("\n✓ MCP client initialized")
100+
101+
# Try to get tools (may be empty)
102+
try:
103+
tools = await mcpclient.get_tools()
104+
print(f"✓ MCP tools available: {len(tools)}")
105+
except Exception as e:
106+
print(f"⚠ No MCP tools available (this is OK for this test): {e}")
107+
except Exception as e:
108+
print(f"⚠ MCP client initialization warning: {e}")
109+
print(" (This is OK - agent will work without MCP tools)")
110+
111+
# Step 5: Create the graph with the skill loaded
112+
print("\n✓ Creating agent graph with loaded skills...")
113+
114+
try:
115+
graph = await get_graph(mcpclient)
116+
print("✓ Agent graph created successfully")
117+
except Exception as e:
118+
print(f"\n✗ ERROR: Failed to create agent graph: {e}")
119+
return False
120+
121+
# Step 6: Test the agent with a summarization request
122+
print("\n" + "=" * 80)
123+
print("Testing Agent with Summarization Request")
124+
print("=" * 80)
125+
126+
# Create a test document that needs summarization
127+
test_document = """
128+
The quarterly review meeting was held on January 15, 2026. The team discussed
129+
the product launch timeline and budget allocation. It was decided to move the
130+
launch date to March 1, 2026, to allow for additional testing. The marketing
131+
budget was approved at $250,000. Sarah will finalize the marketing plan by
132+
January 22. Mike needs to complete beta testing by February 1. The team agreed
133+
to review the pricing strategy in the next meeting. Revenue projections show
134+
a 23% increase over last quarter. Customer satisfaction scores improved to 4.5
135+
out of 5. The development team identified three critical bugs that must be
136+
fixed before launch. John will coordinate with the QA team to prioritize these
137+
issues. The meeting concluded with a commitment to weekly status updates.
138+
"""
139+
140+
user_prompt = f"""Please summarize the following meeting notes in bullet points:
141+
142+
{test_document}
143+
144+
Provide a clear, concise summary with the key decisions, action items, and important data points."""
145+
146+
print(f"\n✓ Test prompt prepared (length: {len(user_prompt)} characters)")
147+
print("\nPrompt preview:")
148+
print("-" * 80)
149+
print(user_prompt[:200] + "...")
150+
print("-" * 80)
151+
152+
# Create input message
153+
messages = [HumanMessage(content=user_prompt)]
154+
input_data = {"messages": messages}
155+
156+
print("\n✓ Invoking agent...")
157+
print(" (This may take 30-60 seconds depending on the model)")
158+
159+
try:
160+
# Stream the graph execution
161+
final_output = None
162+
step_count = 0
163+
164+
async for event in graph.astream(input_data, stream_mode="updates"):
165+
step_count += 1
166+
print(f"\n Step {step_count}:")
167+
for key, value in event.items():
168+
# Print abbreviated output
169+
value_str = str(value)
170+
if len(value_str) > 200:
171+
value_str = value_str[:200] + "..."
172+
print(f" {key}: {value_str}")
173+
final_output = event
174+
175+
print(f"\n✓ Agent completed execution in {step_count} steps")
176+
177+
except Exception as e:
178+
print(f"\n✗ ERROR: Agent execution failed: {e}")
179+
import traceback
180+
181+
traceback.print_exc()
182+
return False
183+
184+
# Step 7: Verify the response
185+
print("\n" + "=" * 80)
186+
print("Analyzing Agent Response")
187+
print("=" * 80)
188+
189+
if not final_output:
190+
print("\n✗ ERROR: No output received from agent")
191+
return False
192+
193+
# Extract the final answer
194+
final_answer = final_output.get("assistant", {}).get("final_answer")
195+
196+
if not final_answer:
197+
print("\n✗ ERROR: No final answer in agent output")
198+
print(f"Output keys: {final_output.keys()}")
199+
return False
200+
201+
print(f"\n✓ Final answer received (length: {len(final_answer)} characters)")
202+
print("\nFinal Answer:")
203+
print("=" * 80)
204+
print(final_answer)
205+
print("=" * 80)
206+
207+
# Step 8: Verify the response shows skill usage
208+
print("\n" + "=" * 80)
209+
print("Verifying Skill Usage")
210+
print("=" * 80)
211+
212+
# Check for indicators that the summarization skill was used
213+
summary_indicators = [
214+
"•", # Bullet points
215+
"-", # Dashes for lists
216+
"summary",
217+
"key",
218+
"decision",
219+
"action",
220+
"budget",
221+
"march",
222+
"250",
223+
]
224+
225+
found_in_response = [ind for ind in summary_indicators if ind.lower() in final_answer.lower()]
226+
227+
print(f"\n✓ Found {len(found_in_response)}/{len(summary_indicators)} summary indicators in response:")
228+
for indicator in found_in_response[:5]: # Show first 5
229+
print(f" - '{indicator}'")
230+
231+
# Check if response is actually a summary (shorter than input)
232+
response_length = len(final_answer.split())
233+
input_length = len(test_document.split())
234+
compression_ratio = response_length / input_length
235+
236+
print("\n✓ Compression analysis:")
237+
print(f" - Input words: {input_length}")
238+
print(f" - Response words: {response_length}")
239+
print(f" - Compression ratio: {compression_ratio:.2f}")
240+
241+
if compression_ratio > 1.5:
242+
print(" ⚠ Warning: Response is longer than input (may not be a proper summary)")
243+
else:
244+
print(" ✓ Response is appropriately condensed")
245+
246+
# Final verdict
247+
print("\n" + "=" * 80)
248+
print("Test Results")
249+
print("=" * 80)
250+
251+
success_criteria = [
252+
("Skill folder exists", Path(skill_path).exists()),
253+
("Skills content loaded", bool(skills_content)),
254+
("Summarizer skill found", "summarizer" in skills_content.lower()),
255+
("Agent graph created", graph is not None),
256+
("Agent executed successfully", final_output is not None),
257+
("Final answer received", bool(final_answer)),
258+
("Summary indicators present", len(found_in_response) >= 3),
259+
]
260+
261+
print("\nSuccess Criteria:")
262+
all_passed = True
263+
for criterion, passed in success_criteria:
264+
status = "✓ PASS" if passed else "✗ FAIL"
265+
print(f" {status}: {criterion}")
266+
if not passed:
267+
all_passed = False
268+
269+
print("\n" + "=" * 80)
270+
if all_passed:
271+
print("✓ TEST PASSED: Summarizer skill loaded and used successfully!")
272+
else:
273+
print("✗ TEST FAILED: Some criteria not met")
274+
print("=" * 80)
275+
276+
return all_passed
277+
278+
279+
async def main():
280+
"""Main test runner."""
281+
try:
282+
success = await test_skill_loading()
283+
sys.exit(0 if success else 1)
284+
except Exception as e:
285+
print(f"\n✗ FATAL ERROR: {e}")
286+
import traceback
287+
288+
traceback.print_exc()
289+
sys.exit(1)
290+
291+
292+
if __name__ == "__main__":
293+
asyncio.run(main())
294+
295+
# Made with Bob

0 commit comments

Comments
 (0)