Merge branch '3.1' into mypy-agent-gns3_copilot-plus

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-09-29 13:20:27 +02:00
commit 726da8383a
46 files changed
+185 -204

No files matched your search

+2 -2
View File
@@ -57,7 +57,7 @@ except ImportError as e:
AI_COPILOT_AVAILABLE = False
# Provide stub functions that raise helpful errors
async def get_project_agent_manager():
async def get_project_agent_manager() -> "ProjectAgentManager":
"""
Get the global ProjectAgentManager singleton instance.
@@ -68,7 +68,7 @@ except ImportError as e:
"AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
)
class ProjectAgentManager:
class ProjectAgentManager: # type: ignore[no-redef]
"""
Stub class for ProjectAgentManager when AI dependencies are not installed.
"""
@@ -140,7 +140,7 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
for tool in tools:
try:
# Build tool schema in OpenAI format
tool_schema = {
tool_schema: dict[str, Any] = {
"type": "function",
"function": {
"name": tool.name,
@@ -110,7 +110,7 @@ def create_base_model(
try:
# Prepare parameters for init_chat_model
init_params = {
init_params: dict[str, Any] = {
"model": config_vars["model_name"],
"model_provider": config_vars["model_provider"],
"api_key": config_vars["api_key"],
@@ -177,7 +177,7 @@ def create_title_model(
try:
# Prepare parameters for init_chat_model
init_params = {
init_params: dict[str, Any] = {
"model": config_vars["model_name"],
"model_provider": config_vars["model_provider"],
"api_key": config_vars["api_key"],
@@ -125,7 +125,7 @@ class ChatSessionsRepository:
project_id: str,
title: str = "New Conversation",
copilot_mode: Optional[str] = None,
) -> ChatSession:
) -> Optional[ChatSession]:
"""
Create a new chat session.
@@ -158,6 +158,8 @@ class ChatSessionsRepository:
session_id = cursor.lastrowid
log.info("Created chat session: id=%s, thread_id=%s, copilot_mode=%s", session_id, thread_id, copilot_mode)
if session_id is None:
return None
return await self.get_session_by_id(session_id)
async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]:
@@ -214,7 +216,7 @@ class ChatSessionsRepository:
List of ChatSession
"""
query = "SELECT * FROM chat_sessions"
params = []
params: List[Any] = []
conditions = []
if user_id:
@@ -268,7 +270,7 @@ class ChatSessionsRepository:
Updated ChatSession or None
"""
updates = []
params = []
params: List[Any] = []
now = datetime.utcnow().isoformat()
@@ -162,14 +162,14 @@ def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict
}
def _filter_node_response(node: dict, fields: list[str] = None) -> dict:
def _filter_node_response(node: dict, fields: list[str] | None = None) -> dict:
"""Filter node response to only include requested fields."""
if not fields:
fields = ["node_id", "name", "node_type", "status", "console"]
return {k: node[k] for k in fields if k in node}
def _filter_link_response(link: dict, fields: list[str] = None) -> dict:
def _filter_link_response(link: dict, fields: list[str] | None = None) -> dict:
"""Filter link response to only include requested fields."""
if not fields:
fields = LINK_DEFAULT_FIELDS
@@ -278,7 +278,7 @@ def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
return list(pool.map(_act, node_ids))
def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -296,7 +296,7 @@ def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
return {"message": f"Node {node_id} started", "node_id": node_id}
def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -314,7 +314,7 @@ def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
return {"message": f"Node {node_id} stopped", "node_id": node_id}
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -332,7 +332,7 @@ def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
return {"message": f"Node {node_id} suspended", "node_id": node_id}
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
@@ -401,7 +401,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return _filter_node_response(resp, fields)
def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -706,7 +706,7 @@ def available_filters_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
return conn.http_call("get", url).json()
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -762,7 +762,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return _filter_link_response(resp, fields)
def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -809,7 +809,7 @@ def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
# ── Link capture / reset handlers ──────────────────────────────────────
def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -347,7 +347,7 @@ def get_gns3_server_host() -> str:
host = get_gns3_server_host()
print(f"GNS3 server host: {host}")
"""
url = _detect_url_for_api()
url = _detect_url_for_api() or DEFAULT_GNS3_URL
# Extract host from URL
# URL format: protocol://host:port
@@ -49,7 +49,7 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
"""
inventory: dict[str, Any] = {}
for n in nodes:
inventory[n.get("name")] = {
inventory[n.get("name") or ""] = {
"server": server_host,
"name": n.get("name"),
"node_id": n.get("node_id"),
@@ -68,7 +68,7 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
return inventory
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Build a human/LLM-friendly link list resolving node and port names.
@@ -76,7 +76,7 @@ def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]
Links whose endpoints cannot be resolved are skipped, mirroring the
original Project.links_summary behavior.
"""
summary: list[dict[str, str]] = []
summary: list[dict[str, Any]] = []
for link in links:
if not link.get("nodes"):
continue
@@ -248,7 +248,7 @@ class SkillsLoader:
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
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")
@@ -37,8 +37,10 @@ from typing import Optional, Dict, Any
try:
import git
GIT_AVAILABLE = True
except ImportError:
git = None
GIT_AVAILABLE = False
from gns3server.config import Config
from .loader import SkillsLoader
@@ -66,7 +68,7 @@ class SkillsManager:
- Version tracking
"""
def __init__(self, repo_url: str = None, branch: str = "main", auto_update: bool = False):
def __init__(self, repo_url: Optional[str] = None, branch: str = "main", auto_update: bool = False):
"""
Initialize the skills manager.
@@ -90,7 +92,7 @@ class SkillsManager:
self._repo: Optional["git.Repo"] = None
self._prompt_count = 0
if git is None:
if not GIT_AVAILABLE:
logger.warning("GitPython is not installed. Skills management features will be limited.")
def initialize(self) -> bool:
@@ -134,7 +136,7 @@ class SkillsManager:
- Up to date → nothing
- Network error → use existing files, log warning
"""
if git is None or self._repo is None:
if not GIT_AVAILABLE or self._repo is None:
return
# Check for uncommitted changes
@@ -173,7 +175,7 @@ class SkillsManager:
Returns:
True if successful, False otherwise
"""
if git is None:
if not GIT_AVAILABLE:
logger.error("GitPython is not installed. Cannot clone repository.")
return False
@@ -347,7 +349,7 @@ class SkillsManager:
Returns:
Commit hash as string, or empty string if not available
"""
if git is None or self._repo is None:
if not GIT_AVAILABLE or self._repo is None:
try:
self._repo = git.Repo(self.local_path)
except Exception:
@@ -409,7 +411,7 @@ class SkillsManager:
Returns:
True if successful, False otherwise
"""
if git is None:
if not GIT_AVAILABLE:
logger.error("GitPython is not installed. Cannot rollback.")
return False
@@ -436,7 +438,7 @@ class SkillsManager:
Returns:
List of commit information dictionaries
"""
if git is None:
if not GIT_AVAILABLE:
return []
try:
@@ -78,7 +78,7 @@ try:
# so we use importlib to get the actual module
import importlib
sd = importlib.import_module("netmiko.ssh_dispatcher")
sd: Any = importlib.import_module("netmiko.ssh_dispatcher")
# Recalculate platforms lists to include custom device types
sd.platforms = list(sd.CLASS_MAPPER.keys())
@@ -196,7 +196,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
if len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
# Filter forbidden commands and store blocked commands info
@@ -78,7 +78,7 @@ try:
# so we use importlib to get the actual module
import importlib
sd = importlib.import_module("netmiko.ssh_dispatcher")
sd: Any = importlib.import_module("netmiko.ssh_dispatcher")
# Recalculate platforms lists to include custom device types
sd.platforms = list(sd.CLASS_MAPPER.keys())
@@ -199,7 +199,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
if len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
# Filter forbidden commands and store blocked commands info
@@ -222,6 +222,8 @@ class GNS3LinkTool(BaseTool):
},
gns3_ctx,
)
if isinstance(link_resp, list):
raise RuntimeError("Unexpected batch response when creating a single link")
if "error" in link_resp:
raise RuntimeError(link_resp["error"])
@@ -230,6 +230,8 @@ class GNS3CreateNodeTool(BaseTool):
},
gns3_ctx,
)
if isinstance(created, list):
raise RuntimeError("Unexpected batch response when creating a single node")
if "error" in created:
raise RuntimeError(created["error"])
node_info = {
@@ -286,7 +286,7 @@ class GNS3PacketFilterTool(BaseTool):
# Check if output contains "Invalid" indicating syntax error
if "Invalid" in result.stdout or "Invalid" in result.stderr:
error_lines = []
error_lines: list[str] = []
if "Invalid" in result.stderr:
error_lines.extend(line for line in result.stderr.split("\n") if "Invalid" in line)
if "Invalid" in result.stdout:
@@ -136,7 +136,11 @@ class GNS3StartNodeTool(BaseTool):
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
start_results = start_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
start_errors = {r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"}
start_errors = (
{r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"}
if isinstance(start_results, list)
else {}
)
# Get immediate status (likely 'starting' or 'stopped') — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@@ -124,7 +124,11 @@ class GNS3StopNodeTool(BaseTool):
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
stop_results = stop_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
stop_errors = {r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"}
stop_errors = (
{r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"}
if isinstance(stop_results, list)
else {}
)
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@@ -130,7 +130,11 @@ class GNS3SuspendNodeTool(BaseTool):
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
suspend_results = suspend_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
suspend_errors = {r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"}
suspend_errors = (
{r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"}
if isinstance(suspend_results, list)
else {}
)
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@@ -143,7 +143,8 @@ class PacketAnalysisTool(BaseTool):
stderr=subprocess.DEVNULL,
text=True,
)
tshark.stdout.close()
if tshark.stdout is not None:
tshark.stdout.close()
stdout, _ = grep.communicate(timeout=30)
tshark.wait(timeout=5)
except Exception as e:
@@ -68,7 +68,7 @@ try:
# CRITICAL: Update netmiko.ssh_dispatcher platforms lists
import importlib
sd = importlib.import_module("netmiko.ssh_dispatcher")
sd: Any = importlib.import_module("netmiko.ssh_dispatcher")
# Recalculate platforms lists to include custom device types
sd.platforms = list(sd.CLASS_MAPPER.keys())
@@ -179,7 +179,7 @@ class VPCSCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
if len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
# Create a mapping of device names to their commands
@@ -386,7 +386,7 @@ class VPCSCommands(BaseTool):
def _prepare_device_hosts_data(
self,
device_configs_list: list[dict[str, Any]],
project_id: str,
project_id: str | None,
jwt_token: str | None = None,
url: str | None = None,
) -> dict[str, dict[str, Any]]:
@@ -415,7 +415,7 @@ class VPCSCommands(BaseTool):
device_ports = get_device_ports_from_topology(device_names, project_id=project_id, jwt_token=jwt_token, url=url)
# Build Nornir inventory hosts data
hosts_data = {}
hosts_data: dict[str, Any] = {}
for device_name in device_names:
if device_name not in device_ports:
logger.error("Device '%s' not found in topology", device_name)
@@ -37,6 +37,7 @@ from typing import Dict
from langchain_core.messages import AIMessage
from langchain_core.messages import HumanMessage
from langchain_core.messages import SystemMessage
from langchain_core.messages import ToolCall
from langchain_core.messages import ToolMessage
@@ -86,7 +87,7 @@ def convert_langchain_to_openai(lc_message) -> Dict[str, Any]:
tool_calls = []
for tc in lc_message.tool_calls:
# Convert to dict if it's an object
tc_dict = tc if isinstance(tc, dict) else tc.model_dump()
tc_dict = tc
tool_calls.append(
{
"id": tc_dict.get("id", str(uuid.uuid4())),
@@ -139,7 +140,7 @@ def convert_openai_to_langchain(msg: Dict[str, Any]):
# Restore tool calls if present
if "tool_calls" in msg and msg["tool_calls"]:
tool_calls = []
tool_calls: list[ToolCall] = []
for tc in msg["tool_calls"]:
tool_calls.append(
{
@@ -214,9 +214,7 @@ def parse_tool_content(
return content
# Handle unsupported types
error_msg = ( # type: ignore[unreachable]
f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}"
)
error_msg = f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}"
logger.error(error_msg)
if strict_mode:
@@ -81,10 +81,7 @@ from gns3server.utils.uuid_validator import validate_uuid
if __name__ == "__main__":
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
try:
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
except ImportError:
from manager import WebWiresharkManager
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
logger = logging.getLogger(__name__)
+8 -8
View File
@@ -84,7 +84,7 @@ class WebWiresharkManager:
logger.warning(f"Container {container_id[:12]} health check failed: {e}")
return False
async def _exec_in_container(self, container_id: str, command: str, timeout: int = None) -> tuple:
async def _exec_in_container(self, container_id: str, command: str, timeout: Optional[int] = None) -> tuple:
"""Execute command in container with timeout.
Args:
@@ -197,7 +197,7 @@ class WebWiresharkManager:
stdout, _ = await proc.communicate()
# Build parent->children mapping
children_map = {}
children_map: dict[int, list[int]] = {}
for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2)
if len(parts) < 3:
@@ -326,7 +326,7 @@ class WebWiresharkManager:
return
# Build parent->children mapping and collect process info
children_map = {} # ppid -> [pid]
children_map: dict[int, list[int]] = {} # ppid -> [pid]
process_info = {} # pid -> (ppid, command)
for line in stdout.decode().strip().split("\n"):
@@ -480,7 +480,7 @@ class WebWiresharkManager:
logger.debug(f"Cannot get URL from Config: {e}")
return None
async def _get_container_gateway_ip(self, container_id: str = None) -> Optional[str]:
async def _get_container_gateway_ip(self, container_id: Optional[str] = None) -> Optional[str]:
"""Get the Docker bridge gateway IP for container to access host.
Args:
@@ -519,7 +519,7 @@ class WebWiresharkManager:
return None
async def get_container_ip(self, container_name: str, container_id: str = None) -> Optional[str]:
async def get_container_ip(self, container_name: str, container_id: Optional[str] = None) -> Optional[str]:
"""Get the container IP address in the wireshark network.
Args:
@@ -626,7 +626,7 @@ class WebWiresharkManager:
project_id: str,
image: str = "gns3/web-wireshark:latest",
memory: str = "2g",
memory_swap: str = None,
memory_swap: Optional[str] = None,
cpus: float = 1.0,
pids_limit: int = 1000,
) -> str:
@@ -935,10 +935,10 @@ class WebWiresharkManager:
project_id: str,
link_id: str,
jwt_token: str,
capture_stream_url: str = None,
capture_stream_url: Optional[str] = None,
image: str = "gns3/web-wireshark:latest",
memory: str = "2g",
memory_swap: str = None,
memory_swap: Optional[str] = None,
cpus: float = 1.0,
pids_limit: int = 1000,
):
+2 -2
View File
@@ -7,7 +7,7 @@ about Web Wireshark containers and sessions.
import logging
import subprocess
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -30,7 +30,7 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
"""
from .manager import WebWiresharkManager
stats = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
stats: Dict[str, Any] = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
# Create a single manager instance and reuse it
manager = WebWiresharkManager()