YueGuobin 89da58fe1b
fix: promote skills/prompts load logs to INFO level
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:46:36 +08:00

460 lines
15 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin
# Author: Yue Guobin
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
Skills Manager
This module provides functionality to manage the skills repository,
including Git operations and hot reload of skills.
"""
import logging
import os
from pathlib import Path
from typing import Optional, Dict, Any
try:
import git
except ImportError:
git = None
from gns3server.config import Config
from .loader import SkillsLoader
logger = logging.getLogger(__name__)
# Git timeout settings for network operations.
# Applied per-command via the `env` parameter to avoid polluting
# the global process environment.
_GIT_TIMEOUT_ENV = {
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s)
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window
'GIT_HTTP_LOW_SPEED_LIMIT': '10240', # < 10 KB/s = slow → abort
}
class SkillsManager:
"""
Manage skills repository and hot reload functionality.
This class handles:
- Cloning the skills repository
- Pulling latest updates
- Hot reloading skills into memory
- Version tracking
"""
def __init__(
self,
repo_url: str = None,
branch: str = "main",
auto_update: bool = False
):
"""
Initialize the skills manager.
Args:
repo_url: Git repository URL (default: https://github.com/yueguobin/GNS3-Skills.git)
branch: Git branch to use (default: "main")
auto_update: Whether to automatically pull updates on reload
"""
if repo_url is None:
repo_url = "https://github.com/yueguobin/GNS3-Skills.git"
# Get local path from GNS3 config directory
config_dir = Config.instance().config_dir
local_path = os.path.join(config_dir, "skills")
self.repo_url = repo_url
self.local_path = Path(local_path)
self.branch = branch
self.auto_update = auto_update
self.loader = SkillsLoader(str(local_path))
self._repo: Optional["git.Repo"] = None
self._prompt_count = 0
if git is None:
logger.warning("GitPython is not installed. Skills management features will be limited.")
def initialize(self) -> bool:
"""
Initialize the skills repository.
Behavior:
- If no local repo → clone from remote
- If local repo exists → open and run _update_if_needed()
Returns:
True if successful, False otherwise
"""
try:
if not self.local_path.exists():
logger.info(f"Creating skills directory: {self.local_path}")
self.local_path.mkdir(parents=True, exist_ok=True)
if not (self.local_path / ".git").exists():
return self._clone()
# Repo exists, open it
try:
self._repo = git.Repo(self.local_path)
except Exception as e:
logger.error(f"Failed to open existing repository: {e}")
return False
self._update_if_needed()
return True
except Exception as e:
logger.error(f"Failed to initialize skills repository: {e}")
return False
def _update_if_needed(self) -> None:
"""
Check local repo status and pull updates if safe.
- Uncommitted changes → warn and skip
- Behind remote → pull
- Up to date → nothing
- Network error → use existing files, log warning
"""
if git is None or self._repo is None:
return
# Check for uncommitted changes
if self._repo.is_dirty(untracked_files=True):
logger.warning(
"Skills repository has uncommitted changes, skipping pull. "
"Commit or stash changes in %s to enable automatic updates.",
self.local_path
)
return
# Fetch remote
try:
origin = self._repo.remotes.origin
origin.fetch(env=_GIT_TIMEOUT_ENV)
except Exception as e:
logger.warning(f"Failed to fetch remote: {e}. Using local files.")
return
# Check if behind and pull
try:
behind_commits = list(self._repo.iter_commits(
f'{self.branch}..origin/{self.branch}'
))
if behind_commits:
logger.info(
"Skills repository is behind by %d commit(s), pulling...",
len(behind_commits)
)
origin.pull(self.branch, env=_GIT_TIMEOUT_ENV)
logger.info(f"Updated to commit {self.get_current_version()}")
else:
logger.info("Skills repository is up to date")
except Exception as e:
logger.warning(f"Failed to pull updates: {e}. Using local files.")
def _clone(self) -> bool:
"""
Clone the skills repository.
Returns:
True if successful, False otherwise
"""
if git is None:
logger.error("GitPython is not installed. Cannot clone repository.")
return False
try:
self._repo = git.Repo.clone_from(
self.repo_url,
self.local_path,
branch=self.branch,
env=_GIT_TIMEOUT_ENV
)
logger.info(f"Successfully cloned skills repository to {self.local_path}")
return True
except git.GitCommandError as e:
logger.error(f"Git clone failed: {e}")
return False
def reload_skills(self) -> bool:
"""
Hot reload skills from YAML files into the registry.
Loads the latest skill definitions from YAML files and updates
the INJECTION_SKILLS_REGISTRY.
Returns:
True if successful, False otherwise
"""
try:
# Import here to avoid circular dependency
from .registry import INJECTION_SKILLS_REGISTRY, SKILLS_REGISTRY
# Load new injection skills from YAML files
new_injection_skills = self.loader.load_injection_skills()
if not new_injection_skills:
logger.warning("No injection skills loaded, keeping existing skills")
return False
# Validate injection skills
for skill_key, skill_data in new_injection_skills.items():
if not self.loader.validate_skill_format(skill_data):
logger.error(f"Invalid skill format for {skill_key}, skipping")
continue
# Load new device/feature skills from YAML files
new_device_skills = self.loader.load_device_skills()
# Update registries (safe replace - never leaves dict empty)
for k in list(INJECTION_SKILLS_REGISTRY):
if k not in new_injection_skills:
del INJECTION_SKILLS_REGISTRY[k]
INJECTION_SKILLS_REGISTRY.update(new_injection_skills)
for k in list(SKILLS_REGISTRY):
if k not in new_device_skills:
del SKILLS_REGISTRY[k]
SKILLS_REGISTRY.update(new_device_skills)
logger.info(f"Loaded {len(new_injection_skills)} injection skills and {len(new_device_skills)} device skills")
return True
except Exception as e:
logger.error(f"Failed to reload skills: {e}")
return False
def reload_packet_analysis_protocols(self) -> bool:
"""
Hot reload packet analysis protocol definitions from YAML files.
Loads the latest protocol definitions from YAML files and updates
the PACKET_ANALYSIS_REGISTRY.
Returns:
True if successful, False otherwise
"""
try:
from .registry import PACKET_ANALYSIS_REGISTRY
# Load new packet analysis protocols from YAML files
new_protocols = self.loader.load_packet_analysis_protocols()
# Update registry (safe replace - never leaves dict empty)
for k in list(PACKET_ANALYSIS_REGISTRY):
if k not in new_protocols:
del PACKET_ANALYSIS_REGISTRY[k]
PACKET_ANALYSIS_REGISTRY.update(new_protocols)
logger.info(f"Successfully reloaded {len(new_protocols)} packet analysis protocols")
return True
except Exception as e:
logger.error(f"Failed to reload packet analysis protocols: {e}")
return False
def reload_prompts(self) -> bool:
"""
Hot reload prompts from Markdown files.
Loads the latest prompt definitions from Markdown files in
the skills repository.
Returns:
True if successful, False otherwise
"""
try:
# Available prompt names
prompt_names = [
"lab_automation_assistant",
"teaching_assistant",
"troubleshooting_injection",
"title"
]
# Load all prompts
loaded_count = 0
for prompt_name in prompt_names:
prompt_content = self.loader.load_prompt(prompt_name)
if prompt_content:
loaded_count += 1
else:
logger.warning(f"Failed to load prompt: {prompt_name}")
if loaded_count == 0:
logger.warning("No prompts loaded, keeping existing prompts")
return False
self._prompt_count = loaded_count
logger.info(f"Loaded {loaded_count} prompts")
return True
except Exception as e:
logger.error(f"Failed to reload prompts: {e}")
return False
def load_prompt(self, prompt_name: str) -> str:
"""
Load a specific prompt from the skills repository.
Args:
prompt_name: Name of the prompt (without .md extension)
Returns:
Prompt content as string, or empty string if not found
"""
try:
return self.loader.load_prompt(prompt_name)
except Exception as e:
logger.error(f"Failed to load prompt '{prompt_name}': {e}")
return ""
def load_forbidden_commands(self) -> list:
"""
Load forbidden command patterns from the skills repository.
Returns:
List of forbidden command patterns, or empty list if not found
"""
try:
return self.loader.load_forbidden_commands()
except Exception as e:
logger.error(f"Failed to load forbidden commands: {e}")
return []
def get_current_version(self) -> str:
"""
Get the current git commit hash of the skills repository.
Returns:
Commit hash as string, or empty string if not available
"""
if git is None or self._repo is None:
try:
self._repo = git.Repo(self.local_path)
except Exception:
return ""
try:
return self._repo.head.commit.hexsha
except Exception:
return ""
def get_skill_count(self) -> int:
"""
Get the number of currently loaded injection skills.
Returns:
Number of skills in the registry
"""
try:
from .registry import INJECTION_SKILLS_REGISTRY
return len(INJECTION_SKILLS_REGISTRY)
except Exception:
return 0
def get_prompt_count(self) -> int:
"""
Get the number of currently loaded prompts.
Returns:
Number of prompts loaded
"""
return self._prompt_count
def get_repository_info(self) -> Dict[str, Any]:
"""
Get information about the skills repository.
Returns:
Dictionary containing repository information
"""
return {
"repo_url": self.repo_url,
"local_path": str(self.local_path),
"branch": self.branch,
"current_version": self.get_current_version(),
"skill_count": self.get_skill_count(),
"prompt_count": self.get_prompt_count(),
"auto_update": self.auto_update,
"is_initialized": (self.local_path / ".git").exists()
}
def rollback(self, commit_hash: str) -> bool:
"""
Rollback the skills repository to a specific commit.
Args:
commit_hash: Git commit hash to rollback to
Returns:
True if successful, False otherwise
"""
if git is None:
logger.error("GitPython is not installed. Cannot rollback.")
return False
try:
if self._repo is None:
self._repo = git.Repo(self.local_path)
self._repo.git.reset("--hard", commit_hash)
logger.info(f"Successfully rolled back to commit {commit_hash}")
# Reload skills after rollback
return self.reload_skills()
except git.GitCommandError as e:
logger.error(f"Git rollback failed: {e}")
return False
def get_available_versions(self, limit: int = 10) -> list:
"""
Get a list of recent commit hashes.
Args:
limit: Maximum number of commits to return
Returns:
List of commit information dictionaries
"""
if git is None:
return []
try:
if self._repo is None:
self._repo = git.Repo(self.local_path)
commits = []
for commit in self._repo.iter_commits(max_count=limit):
commits.append({
"hash": commit.hexsha,
"message": commit.message.strip(),
"author": str(commit.author),
"date": commit.committed_datetime.isoformat()
})
return commits
except Exception as e:
logger.error(f"Failed to get commit history: {e}")
return []