mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
feat(copilot): device skills per-topic split layout and topic retrieval
SkillsLoader.load_device_skills() now supports two device layouts: the existing single file and a split directory (device/<device>/_base.yaml + one YAML per protocol topic). Topic files are merged into the device skill under 'topics', keyed by their 'topic' field; mismatched device_type, missing _base.yaml and duplicate topics are handled with explicit log-and-skip. get_skill() and DeviceSkillsTool gain a 'topic' parameter following the injection list -> index -> issue pattern. Topic bodies are never returned without an explicit topic request - index/summary/full all serve a topic index instead - so growing a device with new protocol topics no longer grows the token cost of device-level lookups. Also fix reload_skills() to actually drop injection skills that fail validate_skill_format() instead of logging 'skipping' and merging them anyway.
This commit is contained in:
5 files changed
+546
-38
No files matched your search
@@ -100,6 +100,12 @@ class SkillsLoader:
|
||||
"""
|
||||
Load all device skills from YAML files.
|
||||
|
||||
Supports two layouts:
|
||||
- Single file: device/<device>.yaml
|
||||
- Split directory: device/<device>/_base.yaml + device/<device>/<topic>.yaml
|
||||
(topic files are merged into the base skill under "topics",
|
||||
keyed by their "topic" field)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping skill keys to skill definitions
|
||||
"""
|
||||
@@ -107,33 +113,110 @@ class SkillsLoader:
|
||||
logger.error("PyYAML is not installed. Cannot load skills from YAML.")
|
||||
return {}
|
||||
|
||||
skills = {}
|
||||
skills: Dict[str, Dict[str, Any]] = {}
|
||||
device_dir = self.skills_dir / "device"
|
||||
|
||||
if not device_dir.exists():
|
||||
logger.warning(f"Device skills directory not found: {device_dir}")
|
||||
return {}
|
||||
|
||||
for yaml_file in device_dir.glob("*.yaml"):
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
# Use device_type from YAML content as the key
|
||||
# Fallback to filename stem if device_type not present
|
||||
skill_key = skill_data.get("device_type") if isinstance(skill_data, dict) else None
|
||||
if not skill_key:
|
||||
skill_key = yaml_file.stem
|
||||
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
|
||||
skills[skill_key] = skill_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {yaml_file}: {e}")
|
||||
for entry in sorted(device_dir.iterdir()):
|
||||
if entry.is_file() and entry.suffix == ".yaml":
|
||||
self._load_single_device_skill(skills, entry)
|
||||
elif entry.is_dir():
|
||||
self._load_split_device_skill(skills, entry)
|
||||
|
||||
logger.debug(f"Loaded {len(skills)} device skills from device directory")
|
||||
return skills
|
||||
|
||||
def _load_single_device_skill(self, skills: Dict[str, Dict[str, Any]], yaml_file: Path) -> None:
|
||||
"""
|
||||
Load a single-file device skill into the skills dictionary.
|
||||
"""
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
return
|
||||
# Use device_type from YAML content as the key
|
||||
# Fallback to filename stem if device_type not present
|
||||
skill_key = skill_data.get("device_type")
|
||||
if not skill_key:
|
||||
skill_key = yaml_file.stem
|
||||
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
|
||||
skills[skill_key] = skill_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {yaml_file}: {e}")
|
||||
|
||||
def _load_split_device_skill(self, skills: Dict[str, Dict[str, Any]], device_path: Path) -> None:
|
||||
"""
|
||||
Load a split device skill (directory with _base.yaml + topic files).
|
||||
|
||||
The base file provides the device-level skill; every other YAML file
|
||||
in the directory is a protocol topic merged under "topics".
|
||||
"""
|
||||
base_file = device_path / "_base.yaml"
|
||||
if not base_file.exists():
|
||||
logger.error(f"No _base.yaml in device directory: {device_path}, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
base_data = self._load_yaml(base_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {base_file}: {e}")
|
||||
return
|
||||
if not base_data:
|
||||
logger.warning(f"Skipping empty YAML file: {base_file}")
|
||||
return
|
||||
|
||||
skill_key = base_data.get("device_type")
|
||||
if not skill_key:
|
||||
skill_key = device_path.name
|
||||
logger.warning(f"No device_type in {base_file}, using directory name '{skill_key}' as key")
|
||||
|
||||
# Seed topics from the base file (if any), then merge topic files
|
||||
base_topics = base_data.get("topics")
|
||||
topics: Dict[str, Any] = dict(base_topics) if isinstance(base_topics, dict) else {}
|
||||
|
||||
for yaml_file in sorted(device_path.glob("*.yaml")):
|
||||
if yaml_file.name == "_base.yaml":
|
||||
continue
|
||||
try:
|
||||
topic_data = self._load_yaml(yaml_file)
|
||||
if not topic_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
|
||||
topic_device_type = topic_data.pop("device_type", None)
|
||||
if topic_device_type is not None and topic_device_type != skill_key:
|
||||
logger.error(
|
||||
f"device_type mismatch in {yaml_file}: '{topic_device_type}' "
|
||||
f"!= base '{skill_key}', skipping topic"
|
||||
)
|
||||
continue
|
||||
|
||||
topic_key = topic_data.pop("topic", None)
|
||||
if not topic_key:
|
||||
topic_key = yaml_file.stem
|
||||
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
|
||||
if topic_key in topics:
|
||||
logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting")
|
||||
|
||||
# category/topics belong to the base skill only
|
||||
topic_data.pop("category", None)
|
||||
topic_data.pop("topics", None)
|
||||
|
||||
topics[topic_key] = topic_data
|
||||
logger.debug(f"Loaded device topic: {skill_key}/{topic_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load topic from {yaml_file}: {e}")
|
||||
|
||||
if topics:
|
||||
base_data["topics"] = topics
|
||||
skills[skill_key] = base_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {device_path} ({len(topics)} topics)")
|
||||
|
||||
def load_feature_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all feature skills from YAML files.
|
||||
|
||||
@@ -221,11 +221,18 @@ class SkillsManager:
|
||||
logger.warning("No injection skills loaded, keeping existing skills")
|
||||
return False
|
||||
|
||||
# Validate injection skills
|
||||
# Validate injection skills (drop invalid ones before merging)
|
||||
valid_injection_skills = {}
|
||||
for skill_key, skill_data in new_injection_skills.items():
|
||||
if not self.loader.validate_skill_format(skill_data):
|
||||
if self.loader.validate_skill_format(skill_data):
|
||||
valid_injection_skills[skill_key] = skill_data
|
||||
else:
|
||||
logger.error(f"Invalid skill format for {skill_key}, skipping")
|
||||
continue
|
||||
|
||||
if not valid_injection_skills:
|
||||
logger.warning("No valid injection skills loaded, keeping existing skills")
|
||||
return False
|
||||
new_injection_skills = valid_injection_skills
|
||||
|
||||
# Load new device skills from YAML files
|
||||
new_device_skills = self.loader.load_device_skills()
|
||||
|
||||
@@ -373,6 +373,7 @@ def get_skill(
|
||||
category: str | None = None,
|
||||
detail: str = "full",
|
||||
issue: str | None = None,
|
||||
topic: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get skill by device_type, with configurable detail level.
|
||||
@@ -382,6 +383,9 @@ def get_skill(
|
||||
category: Optional category filter
|
||||
detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all)
|
||||
issue: Optional specific issue key to retrieve
|
||||
topic: Optional protocol topic to retrieve (split devices only).
|
||||
Topic bodies are NEVER included without an explicit topic
|
||||
request - all other detail levels return a topic index.
|
||||
|
||||
Returns:
|
||||
Skill dictionary (detail varies by level), or error dict
|
||||
@@ -412,6 +416,28 @@ def get_skill(
|
||||
],
|
||||
}
|
||||
|
||||
topics = skill.get("topics", {})
|
||||
|
||||
# Single topic lookup (topic bodies stay out of every other response)
|
||||
if topic:
|
||||
topic_data = topics.get(topic)
|
||||
if not topic_data:
|
||||
for key, data in topics.items():
|
||||
if key.lower() == topic.lower():
|
||||
topic_data = data
|
||||
topic = key
|
||||
break
|
||||
if not topic_data:
|
||||
return {
|
||||
"error": f"Unknown topic '{topic}' in {device_type}",
|
||||
"available_topics": list(topics.keys()),
|
||||
}
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"skill_name": skill.get("name"),
|
||||
"topic": {topic: topic_data},
|
||||
}
|
||||
|
||||
issues = skill.get("issues", {})
|
||||
|
||||
# Single issue lookup (most token-efficient)
|
||||
@@ -429,17 +455,20 @@ def get_skill(
|
||||
}
|
||||
|
||||
if detail == "index":
|
||||
# Minimal: only issue keys and names (90%+ token savings)
|
||||
return {
|
||||
# Minimal: only issue/topic keys and names (90%+ token savings)
|
||||
result = {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"issues": {k: v["name"] for k, v in issues.items()},
|
||||
}
|
||||
if topics:
|
||||
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
|
||||
return result
|
||||
|
||||
if detail == "summary":
|
||||
# Moderate: names + description + severity + difficulty
|
||||
return {
|
||||
result = {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
@@ -453,14 +482,22 @@ def get_skill(
|
||||
for k, v in issues.items()
|
||||
},
|
||||
}
|
||||
if topics:
|
||||
result["topics"] = {
|
||||
k: {"name": v.get("name", k), "description": v.get("description", "")}
|
||||
for k, v in topics.items()
|
||||
}
|
||||
return result
|
||||
|
||||
# Full detail (original behavior)
|
||||
result = dict(skill)
|
||||
# Full detail: topic bodies are replaced by the topic index
|
||||
result = {k: v for k, v in skill.items() if k != "topics"}
|
||||
result["device_type"] = device_type
|
||||
if topics:
|
||||
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
|
||||
return result
|
||||
|
||||
|
||||
def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
|
||||
def list_available_skills(category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List all available device/feature skills, optionally filtered by category."""
|
||||
skills = []
|
||||
for did, skill in SKILLS_REGISTRY.items():
|
||||
@@ -470,12 +507,14 @@ def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
"topic_count": len(skill.get("topics", {})),
|
||||
})
|
||||
else:
|
||||
skills.append({
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
"topic_count": len(skill.get("topics", {})),
|
||||
})
|
||||
return skills
|
||||
|
||||
@@ -642,15 +681,21 @@ class DeviceSkillsTool(BaseTool):
|
||||
Provides access to device command knowledge (VPCS), topology planning, etc.
|
||||
For fault injection skills, use the injection_skills tool.
|
||||
|
||||
INPUT FORMAT (JSON string):
|
||||
{
|
||||
"action": "get", # "get" (default) or "list"
|
||||
"device_type": "gns3_vpcs_telnet", # Required for action="get"
|
||||
"detail": "full" # "full" (default) for complete skill information
|
||||
}
|
||||
TOKEN-EFFICIENT USAGE:
|
||||
1. List devices: {"action": "list"}
|
||||
2. List topics of a device: {"device_type": "frr_vtysh", "detail": "index"}
|
||||
3. Get ONE protocol topic (devices with topics): {"device_type": "frr_vtysh", "topic": "bgp"}
|
||||
4. Devices without topics: {"device_type": "gns3_vpcs_telnet"}
|
||||
|
||||
For action="list":
|
||||
{"action": "list"} # Lists all available device/feature skills
|
||||
Topic bodies are NEVER returned without an explicit "topic" - fetching a
|
||||
device without one only returns its base skill plus the topic index, so
|
||||
always request the specific protocol topic before configuring it.
|
||||
|
||||
PARAMETERS:
|
||||
- action: "list" or "get" (default "get")
|
||||
- device_type: Required for action="get" (e.g., "frr_vtysh")
|
||||
- topic: Protocol topic key from the topic index (e.g., "ospf", "bgp")
|
||||
- detail: "index" | "summary" | "full" (default "full")
|
||||
"""
|
||||
|
||||
def _run(
|
||||
@@ -693,8 +738,9 @@ class DeviceSkillsTool(BaseTool):
|
||||
category = params.get("category")
|
||||
detail = params.get("detail", "full")
|
||||
issue = params.get("issue")
|
||||
topic = params.get("topic")
|
||||
|
||||
skill = get_skill(device_type, category, detail=detail, issue=issue)
|
||||
skill = get_skill(device_type, category, detail=detail, issue=issue, topic=topic)
|
||||
|
||||
return json.dumps(skill, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
Reference in new issue
Block a user