Merge branch '3.1' into mypy-agent-copilot-tools-nornir

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-09-29 12:38:40 +02:00
commit 77a323e1e8
24 files changed
+208 -187

No files matched your search

@@ -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"],
+16 -21
View File
@@ -256,17 +256,16 @@ class AgentService:
)
# Ensure checkpointer is initialized
if not self._checkpointer_conn:
await self._get_checkpointer()
checkpointer = await self._get_checkpointer()
# Get or create chat session
repo = ChatSessionsRepository(self._checkpointer_conn)
repo = ChatSessionsRepository(checkpointer.conn)
session = await repo.get_session_by_thread(session_id)
is_new_session = session is None
if is_new_session:
# Create new session
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
copilot_mode = (llm_config or {}).get("copilot_mode", "teaching_assistant").lower()
session = await repo.create_session(
thread_id=session_id,
user_id=user_id or "",
@@ -290,7 +289,7 @@ class AgentService:
# Build config - only thread-safe identifiers
# Determine recursion_limit based on copilot_mode
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
copilot_mode = (llm_config or {}).get("copilot_mode", "teaching_assistant").lower()
if copilot_mode == "troubleshooting_injection":
recursion_limit = 100 # Need more recursion depth for fault injection workflow
log.debug("Using extended recursion_limit for troubleshooting_injection mode: 100")
@@ -453,10 +452,10 @@ class AgentService:
yield chunk
else:
# Use stateless converter for other events
chunk = self._convert_event_to_chunk(event, session_id)
if chunk:
log.debug("Yielding chunk: type=%s", chunk.get("type"))
yield chunk
converted = self._convert_event_to_chunk(event, session_id)
if converted:
log.debug("Yielding chunk: type=%s", converted.get("type"))
yield converted
# Check if stream was aborted and yield tool_end events for aborted tools
from gns3server.agent.gns3_copilot.agent.gns3_copilot import (
@@ -624,10 +623,9 @@ class AgentService:
Returns:
List of session dictionaries
"""
if not self._checkpointer_conn:
await self._get_checkpointer()
checkpointer = await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
repo = ChatSessionsRepository(checkpointer.conn)
sessions = await repo.list_sessions(user_id=user_id, copilot_mode=copilot_mode, limit=limit)
return [s.to_dict() for s in sessions]
@@ -641,10 +639,9 @@ class AgentService:
Returns:
True if deleted, False if not found
"""
if not self._checkpointer_conn:
await self._get_checkpointer()
checkpointer = await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
repo = ChatSessionsRepository(checkpointer.conn)
return await repo.delete_session(session_id)
async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]:
@@ -658,10 +655,9 @@ class AgentService:
Returns:
Updated session dictionary or None
"""
if not self._checkpointer_conn:
await self._get_checkpointer()
checkpointer = await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
repo = ChatSessionsRepository(checkpointer.conn)
session = await repo.update_session(thread_id=session_id, title=new_title)
return session.to_dict() if session else None
@@ -676,10 +672,9 @@ class AgentService:
Returns:
Updated session dictionary or None
"""
if not self._checkpointer_conn:
await self._get_checkpointer()
checkpointer = await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
repo = ChatSessionsRepository(checkpointer.conn)
session = await repo.pin_session(thread_id=session_id, pinned=pinned)
return session.to_dict() if session else None
@@ -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:
+28 -26
View File
@@ -405,22 +405,24 @@ async def project_stats(
@mcp.tool()
async def project_update(
project_id: Annotated[str, Field(description="UUID of the project to update")],
name: Annotated[str, Field(description="New project name")] = None,
auto_close: Annotated[bool, Field(description="Close project when last client leaves")] = None,
auto_open: Annotated[bool, Field(description="Project opens when GNS3 starts")] = None,
auto_start: Annotated[bool, Field(description="Project starts when opened")] = None,
scene_width: Annotated[int, Field(description="Width of the drawing area")] = None,
scene_height: Annotated[int, Field(description="Height of the drawing area")] = None,
zoom: Annotated[int, Field(description="Zoom of the drawing area")] = None,
show_layers: Annotated[bool, Field(description="Show layers on the drawing area")] = None,
snap_to_grid: Annotated[bool, Field(description="Snap to grid on the drawing area")] = None,
show_grid: Annotated[bool, Field(description="Show the grid on the drawing area")] = None,
grid_size: Annotated[int, Field(description="Grid size for the drawing area for nodes")] = None,
drawing_grid_size: Annotated[int, Field(description="Grid size for the drawing area for drawings")] = None,
show_interface_labels: Annotated[bool, Field(description="Show interface labels on the drawing area")] = None,
name: Annotated[str | None, Field(description="New project name")] = None,
auto_close: Annotated[bool | None, Field(description="Close project when last client leaves")] = None,
auto_open: Annotated[bool | None, Field(description="Project opens when GNS3 starts")] = None,
auto_start: Annotated[bool | None, Field(description="Project starts when opened")] = None,
scene_width: Annotated[int | None, Field(description="Width of the drawing area")] = None,
scene_height: Annotated[int | None, Field(description="Height of the drawing area")] = None,
zoom: Annotated[int | None, Field(description="Zoom of the drawing area")] = None,
show_layers: Annotated[bool | None, Field(description="Show layers on the drawing area")] = None,
snap_to_grid: Annotated[bool | None, Field(description="Snap to grid on the drawing area")] = None,
show_grid: Annotated[bool | None, Field(description="Show the grid on the drawing area")] = None,
grid_size: Annotated[int | None, Field(description="Grid size for the drawing area for nodes")] = None,
drawing_grid_size: Annotated[int | None, Field(description="Grid size for the drawing area for drawings")] = None,
show_interface_labels: Annotated[
bool | None, Field(description="Show interface labels on the drawing area")
] = None,
) -> list[dict[str, Any]]:
"""Update a project's properties (name, auto_close, auto_open, etc.)."""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
local_vars = {
"name": name,
"auto_close": auto_close,
@@ -449,7 +451,7 @@ async def project_duplicate(
reset_mac_addresses: Annotated[bool, Field(description="Reset MAC addresses for this project")] = False,
) -> list[dict[str, Any]]:
"""Duplicate a project."""
params = {"project_id": project_id, "name": name}
params: dict[str, Any] = {"project_id": project_id, "name": name}
if reset_mac_addresses:
params["reset_mac_addresses"] = reset_mac_addresses
return await asyncio.to_thread(_run_handler_sync, duplicate_project_handler, params)
@@ -523,7 +525,7 @@ async def node_start(
] = None,
) -> list[dict[str, Any]]:
"""Start one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -540,7 +542,7 @@ async def node_stop(
] = None,
) -> list[dict[str, Any]]:
"""Stop one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -557,7 +559,7 @@ async def node_suspend(
] = None,
) -> list[dict[str, Any]]:
"""Suspend one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -635,7 +637,7 @@ async def node_delete(
] = None,
) -> list[dict[str, Any]]:
"""Delete one or more nodes from a project. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -756,7 +758,7 @@ async def link_create(
"fields": fields,
},
)
params = {"project_id": project_id, "nodes": nodes, "link_type": link_type, "fields": fields}
params: dict[str, Any] = {"project_id": project_id, "nodes": nodes, "link_type": link_type, "fields": fields}
if filters:
params["filters"] = filters
return await asyncio.to_thread(_run_handler_sync, create_link_handler, params)
@@ -771,7 +773,7 @@ async def link_delete(
] = None,
) -> list[dict[str, Any]]:
"""Delete one or more links from a project."""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -1201,7 +1203,7 @@ async def link_reset(
while keeping the filter configuration intact. Filters are preserved but
their internal application state resets.
"""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -1222,7 +1224,7 @@ async def link_capture_start(
] = None,
) -> list[dict[str, Any]]:
"""Start packet capture on one or more links."""
params = {
params: dict[str, Any] = {
"project_id": project_id,
"data_link_type": data_link_type,
"capture_file_name": capture_file_name,
@@ -1245,7 +1247,7 @@ async def link_capture_stop(
] = None,
) -> list[dict[str, Any]]:
"""Stop packet capture on one or more links."""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -1273,7 +1275,7 @@ async def link_capture_download(
- The ticket expires after 10 minutes: call this tool again to get a
fresh one; do not reuse an old URL.
"""
params = {"project_id": project_id}
params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -1555,7 +1557,7 @@ async def drawing_update(
rotation: Annotated[int | None, Field(description="Rotation angle in degrees, -359 to 359")] = None,
) -> list[dict[str, Any]]:
"""Update a drawing's properties (svg, position, lock state, rotation, etc.)."""
params = {"project_id": project_id, "drawing_id": drawing_id}
params: dict[str, Any] = {"project_id": project_id, "drawing_id": drawing_id}
local_vars = {"svg": svg, "locked": locked, "x": x, "y": y, "z": z, "rotation": rotation}
for key, val in local_vars.items():
if val is not None:
@@ -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()