From 7c3b832bca1393dcaa4e0a182cf4f751f4447c1e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 10 Mar 2026 01:05:17 +0800 Subject: [PATCH] style: fix E501 line-too-long errors in gns3_copilot Fix all 423 E501 line length violations across 26 files to comply with PEP 8 88-character line limit. Changes: - Split long f-strings across multiple lines - Break long docstring descriptions and parameter lists - Split markdown table rows and list examples - Break long URL construction f-strings - Split long logger messages and comments - Add noqa: E501 for SVG strings (cannot be split) Modified files: - agent/: context_manager.py, gns3_copilot.py, model_factory.py - gns3_client/: connector_factory.py, context_helpers.py, custom_gns3fy.py, gns3_project_info.py, gns3_topology_reader.py - prompts/: __init__.py, lab_automation_assistant_prompt.py, prompt_loader.py, teaching_assistant_prompt.py - tools_v2/: __init__.py, config_tools_nornir.py, display_tools_nornir.py, gns3_create_link.py, gns3_create_node.py, gns3_get_node_temp.py, gns3_start_node.py, gns3_update_node_name.py, vpcs_tools_telnetlib3.py - utils/: __init__.py, command_filter.py, get_gns3_device_port.py, gns3_drawing_utils.py, llm_config_helper.py, message_converters.py, parse_tool_content.py, tool_call_stream.py All files now pass ruff E501 checks. Co-Authored-By: Yue Guobin --- .../gns3_copilot/agent/context_manager.py | 183 +++++--- .../agent/gns3_copilot/agent/gns3_copilot.py | 185 ++++++--- .../agent/gns3_copilot/agent/model_factory.py | 16 +- .../agent/gns3_copilot/agent_service.py | 199 ++++++--- .../gns3_copilot/chat_sessions_repository.py | 80 +++- .../gns3_client/connector_factory.py | 110 +++-- .../gns3_client/context_helpers.py | 21 +- .../gns3_copilot/gns3_client/custom_gns3fy.py | 389 ++++++++++++++---- .../gns3_client/gns3_project_info.py | 38 +- .../gns3_client/gns3_topology_reader.py | 49 ++- .../gns3_copilot/project_agent_manager.py | 18 +- .../agent/gns3_copilot/prompts/__init__.py | 4 +- .../lab_automation_assistant_prompt.py | 19 +- .../gns3_copilot/prompts/prompt_loader.py | 28 +- .../prompts/teaching_assistant_prompt.py | 9 +- .../agent/gns3_copilot/tools_v2/__init__.py | 4 +- .../tools_v2/config_tools_nornir.py | 222 +++++++--- .../tools_v2/display_tools_nornir.py | 205 ++++++--- .../gns3_copilot/tools_v2/gns3_create_link.py | 75 +++- .../gns3_copilot/tools_v2/gns3_create_node.py | 72 ++-- .../tools_v2/gns3_get_node_temp.py | 53 +-- .../gns3_copilot/tools_v2/gns3_start_node.py | 132 ++++-- .../tools_v2/gns3_update_node_name.py | 79 +++- .../tools_v2/vpcs_tools_telnetlib3.py | 116 ++++-- .../agent/gns3_copilot/utils/__init__.py | 2 +- .../gns3_copilot/utils/command_filter.py | 11 +- .../utils/get_gns3_device_port.py | 31 +- .../gns3_copilot/utils/gns3_drawing_utils.py | 84 +++- .../gns3_copilot/utils/llm_config_helper.py | 13 +- .../gns3_copilot/utils/message_converters.py | 31 +- .../gns3_copilot/utils/parse_tool_content.py | 140 +++++-- .../gns3_copilot/utils/tool_call_stream.py | 111 +++-- 32 files changed, 1967 insertions(+), 762 deletions(-) diff --git a/gns3server/agent/gns3_copilot/agent/context_manager.py b/gns3server/agent/gns3_copilot/agent/context_manager.py index fccbb03c9..297f5088e 100644 --- a/gns3server/agent/gns3_copilot/agent/context_manager.py +++ b/gns3server/agent/gns3_copilot/agent/context_manager.py @@ -136,26 +136,37 @@ def estimate_tool_tokens(tools: list[Any]) -> int: if hasattr(tool, "args_schema") and tool.args_schema: try: # Try Pydantic v2 method (model_json_schema) - tool_schema["function"]["parameters"] = tool.args_schema.model_json_schema() + tool_schema["function"]["parameters"] = ( + tool.args_schema.model_json_schema() + ) except AttributeError: # Fallback to Pydantic v1 method (schema) try: - tool_schema["function"]["parameters"] = tool.args_schema.schema() + tool_schema["function"]["parameters"] = ( + tool.args_schema.schema() + ) except Exception: # Both methods failed, use empty schema + tool_name = getattr(tool, "name", "unknown") logger.debug( - "Failed to get schema for tool %s, using empty parameters", getattr(tool, "name", "unknown") + "Failed to get schema for tool %s, using empty " + "parameters", + tool_name, ) tool_schema["function"]["parameters"] = {} except Exception as e: # model_json_schema() raised an exception + tool_name = getattr(tool, "name", "unknown") logger.debug( - "model_json_schema() failed for tool %s: %s, trying v1 fallback", - getattr(tool, "name", "unknown"), + "model_json_schema() failed for tool %s: %s, " + "trying v1 fallback", + tool_name, e, ) try: - tool_schema["function"]["parameters"] = tool.args_schema.schema() + tool_schema["function"]["parameters"] = ( + tool.args_schema.schema() + ) except Exception: tool_schema["function"]["parameters"] = {} @@ -165,7 +176,12 @@ def estimate_tool_tokens(tools: list[Any]) -> int: total_tokens += tokens except Exception as e: - logger.debug(f"Failed to estimate tokens for tool {getattr(tool, 'name', 'unknown')}: {e}") + tool_name = getattr(tool, "name", "unknown") + logger.debug( + "Failed to estimate tokens for tool %s: %s", + tool_name, + e, + ) # Rough fallback: 1000 tokens per tool total_tokens += 1000 @@ -179,7 +195,8 @@ def _count_tokens_for_message(message: BaseMessage) -> int: This function is called by trim_messages for each message. Args: - message: A single message (HumanMessage, AIMessage, SystemMessage, etc.) + message: A single message (HumanMessage, AIMessage, SystemMessage, + etc.) Returns: Estimated token count for the message @@ -206,13 +223,16 @@ def create_pre_model_hook( Create a pre_model_hook function for LangGraph agent. This hook will be automatically called before each LLM invocation, - handling topology injection, tool token estimation, and message trimming. + handling topology injection, tool token estimation, and message + trimming. IMPORTANT USAGE REQUIREMENTS: - This hook MUST be passed via model.invoke() config, NOT used as a Node return value. + This hook MUST be passed via model.invoke() config, NOT used as + a Node return value. CORRECT Usage: - model.invoke(messages, config={"configurable": {"pre_model_hook": pre_hook}}) + model.invoke(messages, + config={"configurable": {"pre_model_hook": pre_hook}}) INCORRECT Usage: # ❌ Don't use as Node return value @@ -224,56 +244,69 @@ def create_pre_model_hook( messages: Annotated[list, add_messages] # Incompatible! COMPATIBILITY: - - Works with: model.invoke(config={"configurable": {"pre_model_hook": ...}}) + - Works with: model.invoke(config={"configurable": + {"pre_model_hook": ...}}) - Does NOT work as: StateGraph Node return value - Does NOT work with: Annotated[list, add_messages] state reducers - The hook returns a complete message list that overwrites the model input, - which is correct for invoke() but wrong for state updates with add_messages. + The hook returns a complete message list that overwrites the model + input, which is correct for invoke() but wrong for state updates with + add_messages. Args: - system_prompt: System prompt template (must contain {{topology_info}} placeholder) + system_prompt: System prompt template (must contain + {{topology_info}} placeholder) get_topology_func: Function to extract topology from state get_llm_config_func: Function to get LLM config from context - get_tools_func: Optional function to get tools list for token estimation + get_tools_func: Optional function to get tools list for token + estimation Returns: A pre_model_hook function for use with model.invoke(config=...) Thread Safety: - Thread-safe if get_llm_config_func uses request-scoped context (e.g., contextvars) + Thread-safe if get_llm_config_func uses request-scoped context + (e.g., contextvars) Performance Notes: - - SystemMessage is reconstructed on each LLM call (intentional design) + - SystemMessage is reconstructed on each LLM call (intentional + design) - Overhead: ~1-2ms per call (negligible compared to LLM latency) - Trade-off: Simplicity > micro-optimization - - In ReAct loops with multiple LLM calls, topology is re-injected each time - - This is acceptable for GNS3-Copilot's usage patterns (low concurrency, short conversations) + - In ReAct loops with multiple LLM calls, topology is re-injected + each time + - This is acceptable for GNS3-Copilot's usage patterns (low + concurrency, short conversations) """ def pre_model_hook(state: dict) -> dict: """ LangGraph pre_model_hook - called before each LLM invocation. - This function is NOT a StateGraph Node. It is a preprocessing hook that - modifies the input to the LLM, not the agent state. + This function is NOT a StateGraph Node. It is a preprocessing hook + that modifies the input to the LLM, not the agent state. Usage Context: Called automatically by LangChain when passed via: - model.invoke(messages, config={"configurable": {"pre_model_hook": this}}) + model.invoke(messages, + config={"configurable": + {"pre_model_hook": this}}) Args: state: Current agent state containing messages and topology_info Returns: dict with 'messages' key containing prepared and trimmed messages - Note: This return value is used by LangChain to replace the model input, - NOT to update the agent state. + Note: This return value is used by LangChain to replace the model + input, NOT to update the agent state. Raises: ValueError: If context_limit is missing or invalid """ - logger.debug("pre_model_hook invoked: messages=%d", len(state.get("messages", []))) + logger.debug( + "pre_model_hook invoked: messages=%d", + len(state.get("messages", [])), + ) messages = state.get("messages", []) if not messages: @@ -285,11 +318,14 @@ def create_pre_model_hook( if not llm_config: logger.error("LLM config not found. context_limit is required.") - raise ValueError("LLM config not found. context_limit is required.") + raise ValueError( + "LLM config not found. context_limit is required." + ) if "context_limit" not in llm_config: logger.error( - "context_limit not found in LLM config. " "This is a required field. Please configure context_limit." + "context_limit not found in LLM config. " + "This is a required field. Please configure context_limit." ) raise ValueError("context_limit is required in LLM config") @@ -302,7 +338,11 @@ def create_pre_model_hook( strategy = llm_config.get("context_strategy", DEFAULT_CONTEXT_STRATEGY) if strategy not in CONTEXT_STRATEGY_RATIOS: - logger.warning("Invalid context_strategy '%s', using '%s'", strategy, DEFAULT_CONTEXT_STRATEGY) + logger.warning( + "Invalid context_strategy '%s', using '%s'", + strategy, + DEFAULT_CONTEXT_STRATEGY, + ) strategy = DEFAULT_CONTEXT_STRATEGY # Step 1: Estimate tool tokens @@ -311,13 +351,20 @@ def create_pre_model_hook( try: tools = get_tools_func() tool_tokens = estimate_tool_tokens(tools) - logger.debug("Tool definitions estimated at ~%d tokens (%d tools)", tool_tokens, len(tools)) + logger.debug( + "Tool definitions estimated at ~%d tokens (%d tools)", + tool_tokens, + len(tools), + ) except Exception as e: logger.warning("Failed to estimate tool tokens: %s", e) # Step 2: Inject topology into system prompt messages_with_system = _inject_topology_into_system( - messages=messages, system_prompt=system_prompt, state=state, get_topology_func=get_topology_func + messages=messages, + system_prompt=system_prompt, + state=state, + get_topology_func=get_topology_func, ) # Step 3: Calculate token breakdown for logging and validation @@ -325,7 +372,9 @@ def create_pre_model_hook( system_tokens = _count_tokens_for_message(system_message) # Calculate tokens for messages_with_system (including system) - messages_with_system_tokens = sum(_count_tokens_for_message(m) for m in messages_with_system) + messages_with_system_tokens = sum( + _count_tokens_for_message(m) for m in messages_with_system + ) # Calculate available budget model_limit_tokens = context_limit_k * TOKENS_PER_K @@ -333,17 +382,19 @@ def create_pre_model_hook( max_input_tokens = int(model_limit_tokens * strategy_ratio) # Calculate max tokens for trim_messages - # Important: trim_messages counts ALL messages (including system), so we only - # subtract tool_tokens here, NOT system_tokens. Let trim_messages handle system. + # Important: trim_messages counts ALL messages (including system), + # so we only subtract tool_tokens here, NOT system_tokens. + # Let trim_messages handle system. max_tokens_for_trim = max_input_tokens - tool_tokens # Validate budget and provide actionable warnings - if system_tokens + tool_tokens > max_input_tokens: + if system_tokens + tool_tokens > max_input_tokens: # noqa: E501 logger.error( - "System prompt (%d tokens) + tools (%d tokens) EXCEED input budget (%d tokens). " - "This will likely cause LLM call failures. " - "Recommendations: 1) Reduce system prompt length, 2) Reduce number of tools, " - "3) Use a model with larger context window, or 4) Switch to 'conservative' strategy.", + "System prompt (%d tokens) + tools (%d tokens) EXCEED input " + "budget (%d tokens). This will likely cause LLM call failures. " # noqa: E501 + "Recommendations: 1) Reduce system prompt length, 2) Reduce " + "number of tools, 3) Use a model with larger context window, " + "or 4) Switch to 'conservative' strategy.", system_tokens, tool_tokens, max_input_tokens, @@ -351,7 +402,8 @@ def create_pre_model_hook( elif max_tokens_for_trim < system_tokens * 1.5: # Less than 1.5x system tokens means very little room for history logger.warning( - "System prompt (%d tokens) + tools (%d tokens) leave minimal room for conversation history (%d tokens remaining). " + "System prompt (%d tokens) + tools (%d tokens) leave minimal " + "room for conversation history (%d tokens remaining). " "Consider reducing system prompt length or number of tools.", system_tokens, tool_tokens, @@ -360,7 +412,8 @@ def create_pre_model_hook( # Debug log with clear terminology logger.debug( - "Token breakdown: system=%d, all_messages=%d (system+history), tools=%d, trim_budget=%d (limit=%dK, strategy=%s)", + "Token breakdown: system=%d, all_messages=%d (system+history), " + "tools=%d, trim_budget=%d (limit=%dK, strategy=%s)", system_tokens, messages_with_system_tokens, tool_tokens, @@ -370,7 +423,8 @@ def create_pre_model_hook( ) # Step 4: Trim messages to fit - # Note: max_tokens_for_trim INCLUDES system message, trim_messages will handle it + # Note: max_tokens_for_trim INCLUDES system message, trim_messages + # will handle it try: trimmed = trim_messages( messages=messages_with_system, @@ -382,11 +436,14 @@ def create_pre_model_hook( # Calculate final token counts final_total = sum(_count_tokens_for_message(m) for m in trimmed) - usage_percent = (final_total + tool_tokens) / model_limit_tokens * 100 + usage_percent = ( + (final_total + tool_tokens) / model_limit_tokens * 100 + ) if len(trimmed) < len(messages_with_system): logger.info( - "Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s", + "Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d " + "tools = %d / %dK (%.1f%%), strategy=%s", len(messages_with_system), len(trimmed), final_total, @@ -398,7 +455,8 @@ def create_pre_model_hook( ) else: logger.info( - "Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s", + "Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK " + "(%.1f%%), strategy=%s", len(trimmed), final_total, tool_tokens, @@ -449,23 +507,33 @@ def _inject_topology_into_system( if topology_data: topology_str = str(topology_data) - formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_str}") + formatted_prompt = system_prompt.replace( + "{{topology_info}}", f"\n\n## Current Topology\n{topology_str}" + ) logger.info( "✓ Topology injected: %d chars, nodes: %s", len(topology_str), list(topology_data.get("nodes", {}).keys())[:5], ) # Show first 5 node names - logger.debug("Full topology data: %s", topology_str[:500]) # First 500 chars + logger.debug( + "Full topology data: %s", topology_str[:500] + ) # First 500 chars else: - formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)") + formatted_prompt = system_prompt.replace( + "{{topology_info}}", "(No topology information available)" + ) logger.warning("✗ Topology data is None, injecting placeholder") # Filter out existing SystemMessage instances - non_system_messages = [m for m in messages if not isinstance(m, SystemMessage)] + non_system_messages = [ + m for m in messages if not isinstance(m, SystemMessage) + ] filtered_count = len(messages) - len(non_system_messages) if filtered_count > 0: - logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count) + logger.debug( + "Filtered out %d existing SystemMessage(s)", filtered_count + ) return [SystemMessage(content=formatted_prompt)] + non_system_messages @@ -487,7 +555,8 @@ def prepare_context_messages( **DEPRECATED**: Use create_pre_model_hook() instead. """ warnings.warn( - "prepare_context_messages() is deprecated. Use create_pre_model_hook() instead.", + "prepare_context_messages() is deprecated. Use " + "create_pre_model_hook() instead.", DeprecationWarning, stacklevel=2, ) @@ -495,9 +564,13 @@ def prepare_context_messages( if "{{topology_info}}" not in system_prompt: formatted_prompt = system_prompt elif topology_context: - formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_context}") + formatted_prompt = system_prompt.replace( + "{{topology_info}}", f"\n\n## Current Topology\n{topology_context}" + ) else: - formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)") + formatted_prompt = system_prompt.replace( + "{{topology_info}}", "(No topology information available)" + ) return [SystemMessage(content=formatted_prompt)] + state_messages @@ -543,7 +616,9 @@ if __name__ == "__main__": # Test invocation print("\nTest 3: Invoke pre_model_hook") test_state = { - "messages": [HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)], + "messages": [ + HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5) + ], "topology_info": {"project_id": "test123", "nodes": 3}, } diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py index 98a874c8a..4f3f47d5f 100644 --- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -39,7 +39,8 @@ The agent provides: - Integration with GNS3 topology management Copilot Modes: -- "teaching_assistant" (default): Diagnostic tools only, no configuration changes +- "teaching_assistant" (default): Diagnostic tools only, no configuration + changes - "lab_automation_assistant": Full diagnostic and configuration tools """ @@ -64,19 +65,27 @@ from langgraph.managed.is_last_step import RemainingSteps from typing_extensions import TypedDict # Local imports -from gns3server.agent.gns3_copilot.agent.context_manager import create_pre_model_hook +from gns3server.agent.gns3_copilot.agent.context_manager import ( + create_pre_model_hook, +) from gns3server.agent.gns3_copilot.agent.model_factory import ( create_base_model_with_tools, ) -from gns3server.agent.gns3_copilot.agent.model_factory import create_title_model +from gns3server.agent.gns3_copilot.agent.model_factory import ( + create_title_model, +) from gns3server.agent.gns3_copilot.gns3_client import GNS3TopologyTool from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( get_current_llm_config, ) from gns3server.agent.gns3_copilot.prompts import TITLE_PROMPT from gns3server.agent.gns3_copilot.prompts import load_system_prompt -from gns3server.agent.gns3_copilot.tools_v2 import ExecuteMultipleDeviceCommands -from gns3server.agent.gns3_copilot.tools_v2 import ExecuteMultipleDeviceConfigCommands +from gns3server.agent.gns3_copilot.tools_v2 import ( + ExecuteMultipleDeviceCommands, +) +from gns3server.agent.gns3_copilot.tools_v2 import ( + ExecuteMultipleDeviceConfigCommands, +) from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool @@ -87,8 +96,9 @@ from gns3server.agent.gns3_copilot.tools_v2 import VPCSMultiCommands # Set up logger for GNS3-Copilot logger = logging.getLogger(__name__) -# Note: LLM model configuration is now managed by the new llm_model_configs system. -# The model_factory module handles model creation with configuration from the database. +# Note: LLM model configuration is now managed by the new llm_model_configs +# system. The model_factory module handles model creation with configuration +# from the database. # Define tools for different copilot modes # Teaching assistant mode: READ-ONLY diagnostic tools only @@ -98,7 +108,8 @@ TEACHING_ASSISTANT_MODE_TOOLS = [ GNS3LinkTool(), # Create links between nodes GNS3StartNodeTool(), # Start GNS3 nodes GNS3UpdateNodeNameTool(), # Update node name - ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands (READ-ONLY) + ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands + # (READ-ONLY) ] # Lab automation assistant mode: Full diagnostic AND configuration tools @@ -108,7 +119,8 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [ GNS3LinkTool(), # Create links between nodes GNS3StartNodeTool(), # Start GNS3 nodes GNS3UpdateNodeNameTool(), # Update node name - ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands (READ-ONLY) + ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands + # (READ-ONLY) ExecuteMultipleDeviceConfigCommands(), # Execute configuration commands VPCSMultiCommands(), # Execute VPCS commands on multiple devices ] @@ -117,7 +129,8 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [ tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS # Create combined tool lookup for tool_node (supports both modes) -# tool_node will receive tool calls based on mode-specific tools bound to the model +# tool_node will receive tool calls based on mode-specific tools bound to the +# model ALL_TOOLS = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS tools_by_name = {tool.name: tool for tool in ALL_TOOLS} @@ -135,14 +148,18 @@ class MessagesState(TypedDict): """ GNS3-Copilot conversation state management class. - Maintains the conversation state for the LangGraph workflow, including message history, - call counters, and session titles for comprehensive dialogue management. + Maintains the conversation state for the LangGraph workflow, including + message history, call counters, and session titles for comprehensive + dialogue management. Attributes: - messages: List of conversation messages with cumulative updates using operator.add + messages: List of conversation messages with cumulative updates using + operator.add llm_calls: Counter for tracking the number of LLM invocations - remaining_steps: Is automatically managed by LangGraph's RemainingSteps to track and limit recursion depth. - conversation_title: Optional conversation title for session identification and management + remaining_steps: Is automatically managed by LangGraph's RemainingSteps + to track and limit recursion depth. + conversation_title: Optional conversation title for session + identification and management topology_info: Dictionary containing GNS3 project topology information """ @@ -183,7 +200,9 @@ def llm_call(state: dict, config: RunnableConfig | None = None): } logger.debug( - "LLM config retrieved from context: provider=%s, model=%s", llm_config.get("provider"), llm_config.get("model") + "LLM config retrieved from context: provider=%s, model=%s", + llm_config.get("provider"), + llm_config.get("model"), ) # Defensive check: skip LLM call if no user messages @@ -211,16 +230,25 @@ def llm_call(state: dict, config: RunnableConfig | None = None): if topology and "error" not in topology: topology_info = topology logger.info( - "Successfully retrieved topology for project_id: %s, name: %s", project_id, topology.get("name") + "Successfully retrieved topology for project_id: %s, " + "name: %s", + project_id, + topology.get("name"), ) else: logger.warning( "Failed to retrieve topology for project_id %s: %s", project_id, - topology.get("error", "Unknown error") if topology else "No result", + topology.get("error", "Unknown error") + if topology + else "No result", ) except Exception as e: - logger.warning("Error retrieving topology for project_id %s: %s", project_id, e) + logger.warning( + "Error retrieving topology for project_id %s: %s", + project_id, + e, + ) # Store topology_info in state for pre_model_hook to access state["topology_info"] = topology_info @@ -229,10 +257,15 @@ def llm_call(state: dict, config: RunnableConfig | None = None): copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower() if copilot_mode == "lab_automation_assistant": mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS - logger.info("Using LAB_AUTOMATION_ASSISTANT mode tools (includes configuration tools)") + logger.info( + "Using LAB_AUTOMATION_ASSISTANT mode tools (includes " + "configuration tools)" + ) else: # teaching_assistant mode (default) mode_tools = TEACHING_ASSISTANT_MODE_TOOLS - logger.info("Using TEACHING_ASSISTANT mode tools (diagnostic tools only)") + logger.info( + "Using TEACHING_ASSISTANT mode tools (diagnostic tools only)" + ) # Create pre_model_hook for automatic topology injection and trimming # Load system prompt based on copilot_mode configuration @@ -241,7 +274,8 @@ def llm_call(state: dict, config: RunnableConfig | None = None): system_prompt=system_prompt, get_topology_func=lambda s: s.get("topology_info"), get_llm_config_func=get_current_llm_config, - get_tools_func=lambda: mode_tools, # Pass mode-specific tools for token estimation + get_tools_func=lambda: mode_tools, # Pass mode-specific tools for + # token estimation ) # Create fresh model with tools for each LLM call @@ -252,14 +286,22 @@ def llm_call(state: dict, config: RunnableConfig | None = None): copilot_mode, len(mode_tools), ) - model_with_tools = create_base_model_with_tools(mode_tools, llm_config=llm_config) + model_with_tools = create_base_model_with_tools( + mode_tools, llm_config=llm_config + ) - # Call pre_hook directly to prepare messages (topology injection + trimming) - # Note: LangGraph's pre_model_hook only works with prebuilt agents, not custom StateGraph + # Call pre_hook directly to prepare messages (topology injection + + # trimming) + # Note: LangGraph's pre_model_hook only works with prebuilt agents, not + # custom StateGraph logger.info("Calling pre_hook to prepare %d messages", len(messages)) - prepared_state = pre_hook({"messages": messages, "topology_info": topology_info}) + prepared_state = pre_hook( + {"messages": messages, "topology_info": topology_info} + ) prepared_messages = prepared_state["messages"] - logger.info("Messages prepared: %d → %d", len(messages), len(prepared_messages)) + logger.info( + "Messages prepared: %d → %d", len(messages), len(prepared_messages) + ) # Invoke model with prepared messages response = model_with_tools.invoke(prepared_messages) @@ -267,15 +309,22 @@ def llm_call(state: dict, config: RunnableConfig | None = None): # Add metadata with created_at timestamp to AI response if hasattr(response, "metadata"): existing_metadata = response.metadata or {} - response.metadata = {**existing_metadata, "created_at": datetime.utcnow().isoformat()} + response.metadata = { + **existing_metadata, + "created_at": datetime.utcnow().isoformat(), + } else: - # LangChain messages should have metadata attribute, but defensive fallback + # LangChain messages should have metadata attribute, but defensive + # fallback try: response.metadata = {"created_at": datetime.utcnow().isoformat()} except Exception: logger.warning("Could not add metadata to AI response") - logger.info("LLM call completed: tool_calls=%d", len(response.tool_calls) if hasattr(response, "tool_calls") else 0) + logger.info( + "LLM call completed: tool_calls=%d", + len(response.tool_calls) if hasattr(response, "tool_calls") else 0, + ) return { "messages": [response], @@ -285,10 +334,13 @@ def llm_call(state: dict, config: RunnableConfig | None = None): # Define generate title node -def generate_title(state: MessagesState, config: RunnableConfig | None = None) -> dict: +def generate_title( + state: MessagesState, config: RunnableConfig | None = None +) -> dict: """ - Generate a conversation title using a lightweight assistant LLM (title_model). - This node is only executed when no title has been set yet (first round only). + Generate a conversation title using a lightweight assistant LLM + (title_model). This node is only executed when no title has been set yet + (first round only). """ # Get llm_config from request-scoped context variable @@ -314,24 +366,37 @@ def generate_title(state: MessagesState, config: RunnableConfig | None = None) - # Call the title generation model (create fresh instance for each call) try: title_model = create_title_model(llm_config=llm_config) - response = title_model.invoke(title_prompt_messages, config={"configurable": {"foo_temperature": 1.0}}) + response = title_model.invoke( + title_prompt_messages, + config={"configurable": {"foo_temperature": 1.0}}, + ) raw_content = response.content new_title = raw_content.strip() # Validate the generated title if not new_title or len(new_title) < 3: - raise ValueError(f"Generated title too short or empty: '{new_title}'") + raise ValueError( + f"Generated title too short or empty: '{new_title}'" + ) - if new_title in ["New Conversation", "Untitled Session", "GNS3 Session"]: - raise ValueError(f"Generated title is a default value: '{new_title}'") + if new_title in [ + "New Conversation", + "Untitled Session", + "GNS3 Session", + ]: + raise ValueError( + f"Generated title is a default value: '{new_title}'" + ) # Safety: truncate long titles and avoid line breaks if len(new_title) > TITLE_MAX_LENGTH: new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..." # Remove unwanted characters - new_title = new_title.replace("\n", " ").replace('"', "").replace("'", "") + new_title = ( + new_title.replace("\n", " ").replace('"', "").replace("'", "") + ) logger.info("Generated new title: %s", new_title) return {"conversation_title": new_title} @@ -355,11 +420,17 @@ def generate_title(state: MessagesState, config: RunnableConfig | None = None) - fallback_title = fallback_title[:28] + ".." if fallback_title: - logger.info(f"Using fallback title from user message: '{fallback_title}'") + logger.info( + "Using fallback title from user message: '%s'", + fallback_title, + ) return {"conversation_title": fallback_title} # Final fallback - logger.info(f"Using final fallback title: '{UNTITLED_SESSION_FALLBACK}'") + logger.info( + "Using final fallback title: '%s'", + UNTITLED_SESSION_FALLBACK, + ) return {"conversation_title": UNTITLED_SESSION_FALLBACK} # Title already exists → no update needed @@ -376,17 +447,24 @@ def tool_node(state: dict, config: RunnableConfig | None = None): result = [] for tool_call in tool_calls: tool_name = tool_call["name"] - logger.debug("Executing tool: %s with args: %s", tool_name, tool_call["args"]) + logger.debug( + "Executing tool: %s with args: %s", tool_name, tool_call["args"] + ) tool = tools_by_name[tool_name] try: observation = tool.invoke(tool_call["args"]) - logger.debug("Tool %s completed: output_length=%d", tool_name, len(str(observation)) if observation else 0) + logger.debug( + "Tool %s completed: output_length=%d", + tool_name, + len(str(observation)) if observation else 0, + ) except Exception as e: logger.error("Tool %s failed: %s", tool_name, e, exc_info=True) observation = f"Error: {str(e)}" # Serialize observation to JSON string if it's not already a string - # This ensures ToolMessage.content is always JSON format, not Python str() + # This ensures ToolMessage.content is always JSON format, not Python + # str() if not isinstance(observation, str): observation = json.dumps(observation, ensure_ascii=False, indent=2) @@ -395,7 +473,7 @@ def tool_node(state: dict, config: RunnableConfig | None = None): content=observation, tool_call_id=tool_call["id"], name=tool_call["name"], - metadata={"created_at": datetime.utcnow().isoformat()} + metadata={"created_at": datetime.utcnow().isoformat()}, ) result.append(tool_msg) @@ -410,7 +488,8 @@ def should_continue( Determine the next step after the LLM has produced a response. - If the LLM requested any tool calls → route to tool_node - - If this is the first complete turn (llm_calls == 1) and no title exists → generate a title + - If this is the first complete turn (llm_calls == 1) and no title + exists → generate a title - Otherwise → conversation is complete, go to END """ last_message = state["messages"][-1] @@ -433,8 +512,8 @@ def recursion_limit_continue(state: MessagesState) -> Literal["llm_call", END]: """ Routing logic after tool execution to prevent infinite recursion. - Determines whether to continue with another LLM call or end the conversation - based on remaining steps and message type. + Determines whether to continue with another LLM call or end the + conversation based on remaining steps and message type. Args: state: Current conversation state with messages and remaining steps @@ -467,13 +546,16 @@ agent_builder.add_node("title_generator_node", generate_title) # Add edges to connect nodes agent_builder.add_edge(START, "llm_call") # Conditional routing after LLM response -# Determines the next step based on whether LLM needs to call tools or generate title +# Determines the next step based on whether LLM needs to call tools or +# generate title agent_builder.add_conditional_edges( "llm_call", should_continue, { - "tool_node": "tool_node", # Route to tool execution if LLM requested tools - "title_generator_node": "title_generator_node", # Generate title on first interaction + "tool_node": "tool_node", # Route to tool execution if LLM requested + # tools + "title_generator_node": "title_generator_node", # Generate title on + # first interaction END: END, # End conversation if no tools needed }, ) @@ -483,7 +565,8 @@ agent_builder.add_conditional_edges( "tool_node", recursion_limit_continue, { - "llm_call": "llm_call", # Continue to LLM if tools executed and steps remain + "llm_call": "llm_call", # Continue to LLM if tools executed and steps + # remain END: END, # End conversation to prevent infinite loops }, ) diff --git a/gns3server/agent/gns3_copilot/agent/model_factory.py b/gns3server/agent/gns3_copilot/agent/model_factory.py index ad5445b72..e638ac734 100644 --- a/gns3server/agent/gns3_copilot/agent/model_factory.py +++ b/gns3server/agent/gns3_copilot/agent/model_factory.py @@ -58,7 +58,11 @@ def _load_llm_config( if not llm_config: raise ValueError("LLM configuration is required") - logger.info("Using LLM config: provider=%s, model=%s", llm_config.get("provider"), llm_config.get("model")) + logger.info( + "Using LLM config: provider=%s, model=%s", + llm_config.get("provider"), + llm_config.get("model"), + ) return { "model_name": llm_config.get("model", ""), @@ -90,7 +94,8 @@ def create_base_model( # Log the loaded configuration (mask sensitive data) logger.info( - "Creating base model: name=%s, provider=%s, base_url=%s, temperature=%s", + "Creating base model: name=%s, provider=%s, base_url=%s, " + "temperature=%s", config_vars["model_name"], config_vars["model_provider"], config_vars["base_url"] if config_vars["base_url"] else "default", @@ -130,8 +135,8 @@ def create_title_model( Create a fresh title generation model instance. This creates a model instance suitable for generating conversation titles. - It uses the same configuration as the base model but with a higher temperature - for more creative output. + It uses the same configuration as the base model but with a higher + temperature for more creative output. Args: llm_config: Configuration dictionary from database @@ -147,7 +152,8 @@ def create_title_model( config_vars = _load_llm_config(llm_config) logger.info( - "Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0", + "Creating title model: name=%s, provider=%s, base_url=%s, " + "temperature=1.0", config_vars["model_name"], config_vars["model_provider"], config_vars["base_url"] if config_vars["base_url"] else "default", diff --git a/gns3server/agent/gns3_copilot/agent_service.py b/gns3server/agent/gns3_copilot/agent_service.py index 4b03b4a4d..1cdc57c9c 100644 --- a/gns3server/agent/gns3_copilot/agent_service.py +++ b/gns3server/agent/gns3_copilot/agent_service.py @@ -108,7 +108,9 @@ class AgentService: return self._checkpointer checkpoint_dir = self._get_checkpoint_dir() - checkpointer_path = os.path.join(checkpoint_dir, "copilot_checkpoints.db") + checkpointer_path = os.path.join( + checkpoint_dir, "copilot_checkpoints.db" + ) log.debug("Creating checkpointer at: %s", checkpointer_path) @@ -118,13 +120,17 @@ class AgentService: await self._checkpointer_conn.close() log.debug("Closed previous checkpointer connection") except Exception as e: - log.warning("Error closing old checkpointer connection: %s", e) + log.warning( + "Error closing old checkpointer connection: %s", e + ) # Create new connection conn = await aiosqlite.connect(checkpointer_path) # Enable WAL mode for better concurrent performance await conn.execute("PRAGMA journal_mode=WAL;") - self._checkpointer_conn = conn # Save connection reference to prevent GC + self._checkpointer_conn = ( + conn # Save connection reference to prevent GC + ) self._checkpointer = AsyncSqliteSaver(conn) # CRITICAL: Initialize database schema @@ -176,22 +182,33 @@ class AgentService: """) # Create indexes - await conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_id ON chat_sessions(thread_id)") - await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_project ON chat_sessions(user_id, project_id)") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_thread_id ON " + "chat_sessions(thread_id)" + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_project ON " + "chat_sessions(user_id, project_id)" + ) - # Check if pinned column exists, add it if not (migration for existing databases) + # Check if pinned column exists, add it if not (migration for existing + # databases) cursor = await conn.execute("PRAGMA table_info(chat_sessions)") columns = await cursor.fetchall() column_names = [col[1] for col in columns] if "pinned" not in column_names: log.debug("Adding pinned column to existing chat_sessions table") - await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE") + await conn.execute( + "ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT " + "FALSE" + ) await conn.commit() # Create pinned index (after column is guaranteed to exist) await conn.execute( - "CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)" + "CREATE INDEX IF NOT EXISTS idx_pinned_updated ON " + "chat_sessions(pinned DESC, updated_at DESC)" ) await conn.commit() @@ -202,7 +219,9 @@ class AgentService: if self._graph is None: checkpointer = await self._get_checkpointer() self._graph = agent_builder.compile(checkpointer=checkpointer) - log.info("LangGraph agent compiled for project: %s", self.project_path) + log.info( + "LangGraph agent compiled for project: %s", self.project_path + ) return self._graph async def stream_chat( @@ -225,13 +244,15 @@ class AgentService: user_id: User ID for metadata tracking jwt_token: JWT token for API authentication (optional) mode: Interaction mode (default: "text") - llm_config: LLM configuration dict (provider, model, api_key, etc.) + llm_config: LLM configuration dict (provider, model, api_key, + etc.) Yields: Dict containing SSE-compatible response chunks """ log.info( - "Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s", + "Stream chat started: project_id=%s, user_id=%s, session_id=%s, " + "mode=%s", project_id, user_id, session_id, @@ -250,7 +271,10 @@ class AgentService: if is_new_session: # Create new session session = await repo.create_session( - thread_id=session_id, user_id=user_id or "", project_id=project_id or "", title="New Conversation" + thread_id=session_id, + user_id=user_id or "", + project_id=project_id or "", + title="New Conversation", ) log.debug("Created new chat session: thread_id=%s", session_id) @@ -261,7 +285,9 @@ class AgentService: if llm_config: set_current_llm_config(llm_config) log.debug( - "LLM config set in context: provider=%s, model=%s", llm_config.get("provider"), llm_config.get("model") + "LLM config set in context: provider=%s, model=%s", + llm_config.get("provider"), + llm_config.get("model"), ) # Build config - only thread-safe identifiers @@ -304,33 +330,49 @@ class AgentService: ai_response_counted = False tool_messages_counted = 0 - # Initialize tool call stream accumulator for handling progressive tool call arguments + # Initialize tool call stream accumulator for handling progressive + # tool call arguments tool_call_accumulator = ToolCallStreamAccumulator() # Stream events try: - async for event in graph.astream_events(inputs, config=config, version="v2"): + async for event in graph.astream_events( + inputs, config=config, version="v2" + ): event_type = event.get("event", "") data = event.get("data", {}) # Track LLM calls and tokens if event_type == "on_chat_model_start": # Filter out title_generator_node from statistics - langgraph_node = event.get("metadata", {}).get("langgraph_node", "") + langgraph_node = event.get("metadata", {}).get( + "langgraph_node", "" + ) if langgraph_node != "title_generator_node": llm_calls_count += 1 - log.debug("LLM call started, count=%d", llm_calls_count) + log.debug( + "LLM call started, count=%d", llm_calls_count + ) else: - log.debug("Skipping LLM call count for internal node: title_generator_node") + log.debug( + "Skipping LLM call count for internal node: " + "title_generator_node" + ) elif event_type == "on_chat_model_end": # Filter out title_generator_node from token counting - langgraph_node = event.get("metadata", {}).get("langgraph_node", "") + langgraph_node = event.get("metadata", {}).get( + "langgraph_node", "" + ) if langgraph_node == "title_generator_node": - log.debug("Skipping token counting for internal node: title_generator_node") + log.debug( + "Skipping token counting for internal node: " + "title_generator_node" + ) else: # Extract token usage from response metadata - # Try multiple possible locations where token usage might be stored + # Try multiple possible locations where token usage + # might be stored token_info_found = False # Method 1: response.usage_metadata @@ -348,8 +390,12 @@ class AgentService: if hasattr(output_msg, "usage_metadata"): usage = output_msg.usage_metadata if usage: - input_tokens += usage.get("input_tokens", 0) - output_tokens += usage.get("output_tokens", 0) + input_tokens += usage.get( + "input_tokens", 0 + ) + output_tokens += usage.get( + "output_tokens", 0 + ) token_info_found = True # Method 3: Check data directly for token usage fields @@ -358,7 +404,10 @@ class AgentService: input_tokens += data.get("input_tokens", 0) if "output_tokens" in data: output_tokens += data.get("output_tokens", 0) - if "input_tokens" in data or "output_tokens" in data: + if ( + "input_tokens" in data + or "output_tokens" in data + ): token_info_found = True # Count AI response as one message (only once per turn) @@ -370,16 +419,26 @@ class AgentService: elif event_type == "on_tool_end": message_count += 1 # Tool result message tool_messages_counted += 1 - log.debug("Tool message counted, message_count=%d", message_count) + log.debug( + "Tool message counted, message_count=%d", message_count + ) # Convert event to chunk for SSE streaming - # Use accumulator for on_chat_model_stream events to handle progressive tool calls + # Use accumulator for on_chat_model_stream events to handle + # progressive tool calls - # Filter out internal nodes (title_generator_node) from streaming to frontend - langgraph_node = event.get("metadata", {}).get("langgraph_node", "") + # Filter out internal nodes (title_generator_node) from + # streaming to frontend + langgraph_node = event.get("metadata", {}).get( + "langgraph_node", "" + ) if langgraph_node == "title_generator_node": - # Skip all events from the title_generator_node (internal use only) - log.debug("Skipping event from internal node: title_generator_node") + # Skip all events from the title_generator_node (internal + # use only) + log.debug( + "Skipping event from internal node: " + "title_generator_node" + ) continue if event_type == "on_chat_model_stream": @@ -387,7 +446,10 @@ class AgentService: for chunk in chunks: # Add session_id to each chunk chunk["session_id"] = session_id - log.debug("Yielding accumulated chunk: type=%s", chunk.get("type")) + log.debug( + "Yielding accumulated chunk: type=%s", + chunk.get("type"), + ) yield chunk else: # Use stateless converter for other events @@ -407,7 +469,8 @@ class AgentService: last_message_at=last_message_at, ) log.info( - "Session statistics updated: thread_id=%s, messages=%d, llm_calls=%d, tokens=%d+%d=%d", + "Session statistics updated: thread_id=%s, messages=%d, " + "llm_calls=%d, tokens=%d+%d=%d", session_id, message_count, llm_calls_count, @@ -421,15 +484,26 @@ class AgentService: if final_state and "conversation_title" in final_state.values: generated_title = final_state.values["conversation_title"] current_session = await repo.get_session_by_thread(session_id) - if current_session and current_session.title != generated_title: - await repo.update_session(thread_id=session_id, title=generated_title) - log.info("Auto-generated title synced: thread_id=%s, title=%s", session_id, generated_title) + if ( + current_session + and current_session.title != generated_title + ): + await repo.update_session( + thread_id=session_id, title=generated_title + ) + log.info( + "Auto-generated title synced: thread_id=%s, title=%s", + session_id, + generated_title, + ) except Exception as e: log.error("Error in stream_chat: %s", e, exc_info=True) yield {"type": "error", "error": str(e), "session_id": session_id} - def _convert_event_to_chunk(self, event: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]: + def _convert_event_to_chunk( + self, event: Dict[str, Any], session_id: str + ) -> Optional[Dict[str, Any]]: """ Convert LangGraph event to API response chunk. @@ -441,15 +515,17 @@ class AgentService: Dict for SSE response or None if event should be filtered Note: - on_chat_model_stream events are handled by ToolCallStreamAccumulator - before calling this method, so they are not processed here. + on_chat_model_stream events are handled by + ToolCallStreamAccumulator before calling this method, so they are + not processed here. """ event_type = event.get("event", "") data = event.get("data", {}) if event_type == "on_tool_start": # Tool execution started - # Extract tool_call_id from event metadata to associate with tool_call event + # Extract tool_call_id from event metadata to associate with + # tool_call event tool_call_id = event.get("metadata", {}).get("tool_call_id", "") return { "type": "tool_start", @@ -460,9 +536,11 @@ class AgentService: elif event_type == "on_tool_end": # Tool execution completed + # Extract tool output and convert to JSON string output = data.get("output", "") # Convert output to JSON string if it's not already a string - # This ensures dict/list outputs are properly serialized for frontend parsing + # This ensures dict/list outputs are properly serialized for + # frontend parsing if not isinstance(output, str): output = json.dumps(output, ensure_ascii=False, indent=2) return { @@ -474,7 +552,9 @@ class AgentService: return None - async def get_history(self, session_id: str, limit: int = 100) -> Dict[str, Any]: + async def get_history( + self, session_id: str, limit: int = 100 + ) -> Dict[str, Any]: """ Get conversation history for a session. @@ -496,19 +576,31 @@ class AgentService: for msg in state.values["messages"][-limit:]: messages.append(self._convert_message_to_dict(msg)) - title = state.values.get("conversation_title", "New Conversation") + title = state.values.get( + "conversation_title", "New Conversation" + ) - return {"thread_id": session_id, "title": title, "messages": messages} + return { + "thread_id": session_id, + "title": title, + "messages": messages, + } except Exception as e: log.error("Error getting history: %s", e, exc_info=True) - return {"thread_id": session_id, "title": "New Conversation", "messages": []} + return { + "thread_id": session_id, + "title": "New Conversation", + "messages": [], + } def _convert_message_to_dict(self, msg) -> Dict[str, Any]: """Convert a LangChain message to OpenAI-compatible dict format.""" return convert_langchain_to_openai(msg) - async def list_sessions(self, user_id: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]: + async def list_sessions( + self, user_id: Optional[str] = None, limit: int = 100 + ) -> List[Dict[str, Any]]: """ List chat sessions for this project. @@ -542,7 +634,9 @@ class AgentService: repo = ChatSessionsRepository(self._checkpointer_conn) return await repo.delete_session(session_id) - async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]: + async def rename_session( + self, session_id: str, new_title: str + ) -> Optional[Dict[str, Any]]: """ Rename a chat session. @@ -557,10 +651,14 @@ class AgentService: await self._get_checkpointer() repo = ChatSessionsRepository(self._checkpointer_conn) - session = await repo.update_session(thread_id=session_id, title=new_title) + session = await repo.update_session( + thread_id=session_id, title=new_title + ) return session.to_dict() if session else None - async def pin_session(self, session_id: str, pinned: bool = True) -> Optional[Dict[str, Any]]: + async def pin_session( + self, session_id: str, pinned: bool = True + ) -> Optional[Dict[str, Any]]: """ Pin or unpin a chat session. @@ -586,7 +684,10 @@ class AgentService: if self._checkpointer_conn: try: await self._checkpointer_conn.close() - log.debug("Checkpointer connection closed for: %s", self.project_path) + log.debug( + "Checkpointer connection closed for: %s", + self.project_path, + ) except Exception as e: log.warning("Error closing checkpointer connection: %s", e) finally: diff --git a/gns3server/agent/gns3_copilot/chat_sessions_repository.py b/gns3server/agent/gns3_copilot/chat_sessions_repository.py index af07f4db7..6acdd2e17 100644 --- a/gns3server/agent/gns3_copilot/chat_sessions_repository.py +++ b/gns3server/agent/gns3_copilot/chat_sessions_repository.py @@ -119,7 +119,11 @@ class ChatSessionsRepository: self.conn = conn async def create_session( - self, thread_id: str, user_id: str, project_id: str, title: str = "New Conversation" + self, + thread_id: str, + user_id: str, + project_id: str, + title: str = "New Conversation", ) -> ChatSession: """ Create a new chat session. @@ -146,11 +150,15 @@ class ChatSessionsRepository: await self.conn.commit() session_id = cursor.lastrowid - log.info("Created chat session: id=%s, thread_id=%s", session_id, thread_id) + log.info( + "Created chat session: id=%s, thread_id=%s", session_id, thread_id + ) return await self.get_session_by_id(session_id) - async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]: + async def get_session_by_id( + self, session_id: int + ) -> Optional[ChatSession]: """ Get a session by its database ID. @@ -160,14 +168,18 @@ class ChatSessionsRepository: Returns: ChatSession or None """ - cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE id = ?", (session_id,)) + cursor = await self.conn.execute( + "SELECT * FROM chat_sessions WHERE id = ?", (session_id,) + ) row = await cursor.fetchone() if row: return self._row_to_session(row) return None - async def get_session_by_thread(self, thread_id: str) -> Optional[ChatSession]: + async def get_session_by_thread( + self, thread_id: str + ) -> Optional[ChatSession]: """ Get a session by thread_id. @@ -177,7 +189,9 @@ class ChatSessionsRepository: Returns: ChatSession or None """ - cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,)) + cursor = await self.conn.execute( + "SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,) + ) row = await cursor.fetchone() if row: @@ -185,7 +199,10 @@ class ChatSessionsRepository: return None async def list_sessions( - self, user_id: Optional[str] = None, project_id: Optional[str] = None, limit: int = 100 + self, + user_id: Optional[str] = None, + project_id: Optional[str] = None, + limit: int = 100, ) -> List[ChatSession]: """ List sessions with optional filters. @@ -288,7 +305,10 @@ class ChatSessionsRepository: params.append(now) params.append(thread_id) - query = f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id = ?" + query = ( + f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id " + f"= ?" + ) await self.conn.execute(query, params) await self.conn.commit() @@ -307,15 +327,21 @@ class ChatSessionsRepository: True if deleted, False if not found """ # First, delete the checkpoint data - await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)) + await self.conn.execute( + "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,) + ) # Then delete the session - cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,)) + cursor = await self.conn.execute( + "DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,) + ) await self.conn.commit() deleted = cursor.rowcount > 0 if deleted: - log.info("Deleted chat session and checkpoints: thread_id=%s", thread_id) + log.info( + "Deleted chat session and checkpoints: thread_id=%s", thread_id + ) return deleted @@ -330,24 +356,37 @@ class ChatSessionsRepository: Number of sessions deleted """ # Get all thread_ids for this project - cursor = await self.conn.execute("SELECT thread_id FROM chat_sessions WHERE project_id = ?", (project_id,)) + cursor = await self.conn.execute( + "SELECT thread_id FROM chat_sessions WHERE project_id = ?", + (project_id,), + ) rows = await cursor.fetchall() thread_ids = [row[0] for row in rows] # Delete checkpoints and sessions for thread_id in thread_ids: - await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)) + await self.conn.execute( + "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,) + ) - cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE project_id = ?", (project_id,)) + cursor = await self.conn.execute( + "DELETE FROM chat_sessions WHERE project_id = ?", (project_id,) + ) await self.conn.commit() deleted_count = cursor.rowcount if deleted_count > 0: - log.info("Deleted %d sessions for project: %s", deleted_count, project_id) + log.info( + "Deleted %d sessions for project: %s", + deleted_count, + project_id, + ) return deleted_count - async def pin_session(self, thread_id: str, pinned: bool = True) -> Optional[ChatSession]: + async def pin_session( + self, thread_id: str, pinned: bool = True + ) -> Optional[ChatSession]: """ Pin or unpin a session. @@ -360,12 +399,17 @@ class ChatSessionsRepository: """ now = datetime.utcnow().isoformat() await self.conn.execute( - "UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE thread_id = ?", + "UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE " + "thread_id = ?", (1 if pinned else 0, now, thread_id), ) await self.conn.commit() - log.debug("Session pin status updated: thread_id=%s, pinned=%s", thread_id, pinned) + log.debug( + "Session pin status updated: thread_id=%s, pinned=%s", + thread_id, + pinned, + ) return await self.get_session_by_thread(thread_id) def _row_to_session(self, row) -> ChatSession: diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py index fb3a0d25b..a2144b2b9 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -30,7 +30,8 @@ This module provides factory functions for creating Gns3Connector instances with JWT token authentication and context-aware configuration management. Features: -- Context variable based request-scoped data management (JWT tokens, LLM config) +- Context variable based request-scoped data management (JWT tokens, LLM + config) - Auto-detection of GNS3 server URL from Controller/Config - Fallback URL strategy for flexible deployment - LLM configuration retrieval for users @@ -49,7 +50,9 @@ from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( ) # Local imports -from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector +from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import ( + Gns3Connector, +) logger = logging.getLogger(__name__) @@ -69,7 +72,10 @@ def _get_url_from_controller() -> Optional[str]: controller = Controller.instance() local_compute = controller.get_compute("local") - url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}" + url = ( + f"{local_compute.protocol}://{local_compute.host}:" + f"{local_compute.port}" + ) logger.debug( "Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)", url, @@ -88,7 +94,9 @@ def _get_url_from_controller() -> Optional[str]: logger.debug("Local compute not found in Controller: %s", str(e)) return None except Exception as e: - logger.warning("Unexpected error getting URL from Controller: %s", str(e)) + logger.warning( + "Unexpected error getting URL from Controller: %s", str(e) + ) return None @@ -102,7 +110,10 @@ def _get_url_from_config() -> Optional[str]: from gns3server.config import Config server_config = Config.instance().settings.Server - url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}" + url = ( + f"{server_config.protocol.value}://{server_config.host}:" + f"{server_config.port}" + ) logger.debug( "Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)", url, @@ -122,7 +133,9 @@ def _get_url_from_config() -> Optional[str]: return None -def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = None) -> Optional[Gns3Connector]: +def get_gns3_connector( + jwt_token: Optional[str] = None, url: Optional[str] = None +) -> Optional[Gns3Connector]: """Create and return a Gns3Connector instance with JWT authentication. URL Resolution Strategy (in order): @@ -132,7 +145,8 @@ def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = Non 4. Fallback to DEFAULT_GNS3_URL (http://127.0.0.1:3080) Args: - jwt_token: JWT token for authentication (optional, will be retrieved from context if not provided) + jwt_token: JWT token for authentication (optional, will be retrieved + from context if not provided) url: GNS3 server URL (optional, auto-detected if not provided) Returns: @@ -181,9 +195,9 @@ def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = Non logger.debug("Config not available, using default URL") url = DEFAULT_GNS3_URL logger.warning( - "Using fallback default URL: %s. " - "This may not be correct if your GNS3 server is configured differently. " - "Consider providing the URL explicitly or ensuring gns3server is running.", + "Using fallback default URL: %s. This may not be " + "correct if your GNS3 server is configured " + "differently. Consider providing the URL explicitly.", url, ) else: @@ -205,11 +219,15 @@ def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = Non return connector except Exception as e: - logger.error("Failed to create Gns3Connector: %s", str(e), exc_info=True) + logger.error( + "Failed to create Gns3Connector: %s", str(e), exc_info=True + ) return None -async def get_gns3_connector_with_llm_config(user_id, jwt_token: str, url: Optional[str] = None, app=None) -> Optional[dict]: +async def get_gns3_connector_with_llm_config( + user_id, jwt_token: str, url: Optional[str] = None, app=None +) -> Optional[dict]: """ Create Gns3Connector and retrieve LLM model configuration for the user. @@ -221,7 +239,8 @@ async def get_gns3_connector_with_llm_config(user_id, jwt_token: str, url: Optio user_id: User UUID (can be string or UUID object) jwt_token: JWT token for authentication url: GNS3 server URL (optional, auto-detected if not provided) - app: FastAPI application instance (optional, for direct database access) + app: FastAPI application instance (optional, for direct database + access) Returns: Dictionary with keys: @@ -261,7 +280,9 @@ async def get_gns3_connector_with_llm_config(user_id, jwt_token: str, url: Optio url = _detect_url_for_api() # Step 3: Get LLM config - llm_config = get_llm_config(user_id=user_id, jwt_token=jwt_token, app=app) + llm_config = get_llm_config( + user_id=user_id, jwt_token=jwt_token, app=app + ) if not llm_config: logger.warning(f"No LLM config found for user {user_id}") @@ -269,8 +290,8 @@ async def get_gns3_connector_with_llm_config(user_id, jwt_token: str, url: Optio return {"connector": connector, "llm_config": None} logger.info( - f"Successfully initialized GNS3 connector and LLM config for user {user_id}: " - f"connector_url={connector.url}, " + f"Successfully initialized GNS3 connector and LLM config for " + f"user {user_id}: connector_url={connector.url}, " f"llm_provider={llm_config.get('provider')}, " f"llm_model={llm_config.get('model')}" ) @@ -278,7 +299,9 @@ async def get_gns3_connector_with_llm_config(user_id, jwt_token: str, url: Optio return {"connector": connector, "llm_config": llm_config} except Exception as e: - logger.error(f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True) + logger.error( + f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True + ) return None @@ -317,8 +340,8 @@ def get_gns3_server_host() -> str: """ Get GNS3 server hostname from Controller or Config. - This is a convenience function for extracting the hostname only, - useful for Nornir tools that need the GNS3 server address. + This is a convenience function for extracting the hostname only, useful + for Nornir tools that need the GNS3 server address. Uses the same priority order as get_gns3_connector: 1. Controller.instance().compute("local") @@ -329,7 +352,9 @@ def get_gns3_server_host() -> str: Hostname/IP address string Example: - from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host + from gns3server.agent.gns3_copilot.gns3_client import ( + get_gns3_server_host, + ) host = get_gns3_server_host() print(f"GNS3 server host: {host}") @@ -346,7 +371,9 @@ def get_gns3_server_host() -> str: logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url) return host except Exception as e: - logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e) + logger.warning( + "Failed to extract host from URL %s: %s, using fallback", url, e + ) return DEFAULT_GNS3_URL.split("://")[1].split(":")[0] @@ -355,20 +382,24 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]: Get LLM model configuration for a user. This function retrieves the user's LLM configuration from the database. - When `app` is provided, it directly accesses the database (bypassing API restrictions). - Otherwise, it falls back to API call (which may mask group config API keys). + When `app` is provided, it directly accesses the database (bypassing API + restrictions). Otherwise, it falls back to API call (which may mask + group config API keys). Args: user_id: User UUID (can be string or UUID object) jwt_token: JWT token for authentication - app: FastAPI application instance (optional, for direct database access) + app: FastAPI application instance (optional, for direct database + access) Returns: - Dictionary with LLM configuration keys (provider, model, api_key, etc.), - or None if not found. + Dictionary with LLM configuration keys (provider, model, api_key, + etc.), or None if not found. Example: - from gns3server.agent.gns3_copilot.gns3_client import get_llm_config + from gns3server.agent.gns3_copilot.gns3_client import ( + get_llm_config, + ) config = get_llm_config(user_id, jwt_token) if config: @@ -393,7 +424,9 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]: # We're in an async context with a running loop # This shouldn't happen since this is a sync function with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app)) + future = executor.submit( + asyncio.run, get_user_llm_config_with_app(user_id, app) + ) return future.result(timeout=10) except RuntimeError: # No running event loop - we're in a sync context @@ -405,22 +438,33 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]: if loop.is_running(): # Loop is running but not the running loop (edge case) with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app)) + future = executor.submit( + asyncio.run, + get_user_llm_config_with_app(user_id, app), + ) return future.result(timeout=10) else: # Loop exists but not running - use it - return loop.run_until_complete(get_user_llm_config_with_app(user_id, app)) + return loop.run_until_complete( + get_user_llm_config_with_app(user_id, app) + ) except RuntimeError: # No event loop exists - create a new one loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - return loop.run_until_complete(get_user_llm_config_with_app(user_id, app)) + return loop.run_until_complete( + get_user_llm_config_with_app(user_id, app) + ) finally: loop.close() - # Fallback: No app provided, try API call (will mask group config API keys) - logger.warning("No app provided for get_llm_config, group config API keys may be masked") + # Fallback: No app provided, try API call (will mask group config API + # keys) + logger.warning( + "No app provided for get_llm_config, group config API keys may be " + "masked" + ) return None except Exception as e: diff --git a/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py index 424b9e2ae..73e275767 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py @@ -62,8 +62,12 @@ logger = logging.getLogger(__name__) # Context variables for request-scoped data # Automatically cleaned up when request context ends -_jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None) -_llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None) +_jwt_token_context: ContextVar[Optional[str]] = ContextVar( + "_jwt_token_context", default=None +) +_llm_config_context: ContextVar[Optional[dict]] = ContextVar( + "_llm_config_context", default=None +) def set_current_jwt_token(token: str) -> None: @@ -94,10 +98,15 @@ def set_current_llm_config(config: dict) -> None: """Set the LLM config for the current request context. Args: - config: LLM configuration dictionary with provider, model, api_key, etc. + config: LLM configuration dictionary with provider, model, api_key, + etc. """ _llm_config_context.set(config) - logger.debug("LLM config set in context: provider=%s, model=%s", config.get("provider"), config.get("model")) + logger.debug( + "LLM config set in context: provider=%s, model=%s", + config.get("provider"), + config.get("model"), + ) def get_current_llm_config() -> Optional[dict]: @@ -109,7 +118,9 @@ def get_current_llm_config() -> Optional[dict]: config = _llm_config_context.get() if config: logger.debug( - "LLM config retrieved from context: provider=%s, model=%s", config.get("provider"), config.get("model") + "LLM config retrieved from context: provider=%s, model=%s", + config.get("provider"), + config.get("model"), ) else: logger.warning("LLM config not found in context") diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py index 3540b4cfb..80f4df10d 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py @@ -26,12 +26,14 @@ """ Adapted gns3fy module for GNS3-Copilot -This module is based on the upstream gns3fy project (https://github.com/davidban77/gns3fy). +This module is based on the upstream gns3fy project +(https://github.com/davidban77/gns3fy). Modifications made for GNS3-Copilot: -- Adjusted pydantic usages and dataclass configuration to reduce dependency conflicts - with langchain (pydantic version/api differences) -- Kept the original API surface where possible but simplified validators/config +- Adjusted pydantic usages and dataclass configuration to reduce dependency + conflicts with langchain (pydantic version/api differences) +- Kept the original API surface where possible but simplified + validators/config - Added JWT token authentication support - Integrated with context-aware connector factory @@ -124,11 +126,21 @@ class Gns3Connector: ```python >>> # API v2 with basic auth - >>> server = Gns3Connector(url="http://
:3080", user="admin", cred="password", api_version=2) + >>> server = Gns3Connector( + ... url="http://
:3080", user="admin", cred="password", + ... api_version=2 + ... ) >>> # API v3 with username/password (auto-fetches JWT token) - >>> server = Gns3Connector(url="http://
:3080", user="admin", cred="password", api_version=3) + >>> server = Gns3Connector( + ... url="http://
:3080", user="admin", cred="password", + ... api_version=3 + ... ) >>> # API v3 with direct JWT token - >>> server = Gns3Connector(url="http://
:3080", jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", api_version=3) + >>> server = Gns3Connector( + ... url="http://
:3080", + ... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + ... api_version=3 + ... ) >>> print(server.get_version()) {'local': False, 'version': '2.2.0b4'} ``` @@ -151,7 +163,7 @@ class Gns3Connector: if url is None: raise ValueError("URL is required for Gns3Connector") - self.url = url.strip('/') # Store original URL for reference + self.url = url.strip("/") # Store original URL for reference self.base_url = f"{self.url}/v{api_version}" self.user = user self.cred = cred @@ -160,7 +172,8 @@ class Gns3Connector: self.api_calls = 0 # v3 authentication attributes - # If jwt_token is provided directly, use it; otherwise will be fetched via username/password + # If jwt_token is provided directly, use it; otherwise will be + # fetched via username/password self.access_token = jwt_token self.token_expiry = None self.auth_type = "basic" if api_version == 2 else "jwt" @@ -177,11 +190,17 @@ class Gns3Connector: self.session.headers["Accept"] = "application/json" # pragma: no cover # Set authentication based on API version - if self.auth_type == "basic" and self.user is not None and self.cred is not None: + if ( + self.auth_type == "basic" + and self.user is not None + and self.cred is not None + ): self.session.auth = (self.user, self.cred) # pragma: no cover elif self.auth_type == "jwt" and self.access_token: - self.session.headers["Authorization"] = f"Bearer {self.access_token}" + self.session.headers["Authorization"] = ( + f"Bearer {self.access_token}" + ) def _authenticate_v3(self) -> None: """ @@ -193,10 +212,15 @@ class Gns3Connector: return if not self.user or not self.cred: - raise ValueError("Username and password are required for v3 authentication when no JWT token is provided") + raise ValueError( + "Username and password are required for v3 authentication " + "when no JWT token is provided" + ) # Construct authentication URL (v3 API uses different base URL) - auth_url = f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate" + auth_url = ( + f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate" + ) auth_data = {"username": self.user, "password": self.cred} # Use temporary session for authentication @@ -204,15 +228,22 @@ class Gns3Connector: temp_session.headers["Content-Type"] = "application/json" try: - response = temp_session.post(auth_url, json=auth_data, verify=self.verify, timeout=10.0) + response = temp_session.post( + auth_url, json=auth_data, verify=self.verify, timeout=10.0 + ) if response.status_code == 200: auth_result = response.json() self.access_token = auth_result["access_token"] # Update session with new token - self.session.headers["Authorization"] = f"Bearer {self.access_token}" + self.session.headers["Authorization"] = ( + f"Bearer {self.access_token}" + ) # print(f"Successfully authenticated to v3 API, token obtained") else: - raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}") + raise HTTPError( + f"v3 API authentication failed: {response.status_code} - " + f"{response.text}" + ) except Exception as e: raise HTTPError(f"v3 API authentication error: {str(e)}") from e @@ -226,7 +257,9 @@ class Gns3Connector: try: # Decode token without verification to check expiry - decoded: dict[str, Any] = jwt.decode(token, options={"verify_signature": False}) + decoded: dict[str, Any] = jwt.decode( + token, options={"verify_signature": False} + ) exp = decoded.get("exp") if exp is not None: return time.time() > float(exp) @@ -255,7 +288,12 @@ class Gns3Connector: Executes HTTP operations and handles GNS3-specific error logic. """ # Handle JWT authentication - if self.auth_type == "jwt" and not self.access_token and self.user and self.cred: + if ( + self.auth_type == "jwt" + and not self.access_token + and self.user + and self.cred + ): self._authenticate_v3() # Get request function (e.g., session.get, session.post) @@ -298,10 +336,15 @@ class Gns3Connector: try: # Only attempt parsing when Content-Type is JSON - if "application/json" in response.headers.get("Content-Type", "").lower(): + if ( + "application/json" + in response.headers.get("Content-Type", "").lower() + ): error_json = response.json() status = error_json.get("status", "Unknown Status") - message = error_json.get("message", "No message provided in JSON.") + message = error_json.get( + "message", "No message provided in JSON." + ) # Construct a more descriptive new error new_err = HTTPError( f"{status}: {message} (Original {response.status_code} Error)", @@ -323,7 +366,9 @@ class Gns3Connector: response = self.http_call("get", url=f"{self.base_url}/version") return cast(dict[str, Any], response.json()) - def projects_summary(self, is_print: bool = True) -> list[tuple[str, str, int, int, str]] | None: + def projects_summary( + self, is_print: bool = True + ) -> list[tuple[str, str, int, int, str]] | None: """ Returns a summary of the projects in the server. If `is_print` is `False`, it will return a list of tuples like: @@ -333,7 +378,9 @@ class Gns3Connector: _projects_summary = [] for _p in self.get_projects(): # Retrieve the project stats - _stats = self.http_call("get", f"{self.base_url}/projects/{_p['project_id']}/stats").json() + _stats = self.http_call( + "get", f"{self.base_url}/projects/{_p['project_id']}/stats" + ).json() if is_print: print( f"{_p['name']}: {_p['project_id']} -- Nodes: {_stats['nodes']} -- " @@ -355,10 +402,14 @@ class Gns3Connector: """ Returns the list of the projects on the server """ - response = self.http_call("get", url=f"{self.base_url}/projects").json() + response = self.http_call( + "get", url=f"{self.base_url}/projects" + ).json() return cast(list[dict[str, Any]], response) - def get_project(self, name: str | None = None, project_id: str | None = None) -> dict[str, Any] | None: + def get_project( + self, name: str | None = None, project_id: str | None = None + ) -> dict[str, Any] | None: """ Retrieves a project from either a name or ID @@ -367,18 +418,24 @@ class Gns3Connector: - `name` or `project_id` """ if project_id: - _response = self.http_call("get", url=f"{self.base_url}/projects/{project_id}") + _response = self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}" + ) return cast(dict[str, Any], _response.json()) elif name: try: - return next(p for p in self.get_projects() if p["name"] == name) + return next( + p for p in self.get_projects() if p["name"] == name + ) except StopIteration: # Project not found return None else: raise ValueError("Must provide either a name or project_id") - def templates_summary(self, is_print: bool = True) -> list[tuple[str, str, str, bool, str, str]] | None: + def templates_summary( + self, is_print: bool = True + ) -> list[tuple[str, str, str, bool, str, str]] | None: """ Returns a summary of the templates in the server. If `is_print` is `False`, it will return a list of tuples like: @@ -412,10 +469,14 @@ class Gns3Connector: """ Returns the templates defined on the server. """ - _response_data = self.http_call("get", url=f"{self.base_url}/templates").json() + _response_data = self.http_call( + "get", url=f"{self.base_url}/templates" + ).json() return cast(list[dict[str, Any]], _response_data) - def get_template(self, name: str | None = None, template_id: str | None = None) -> dict[str, Any] | None: + def get_template( + self, name: str | None = None, template_id: str | None = None + ) -> dict[str, Any] | None: """ Retrieves a template from either a name or ID @@ -424,11 +485,15 @@ class Gns3Connector: - `name` or `template_id` """ if template_id: - _response_json = self.http_call("get", url=f"{self.base_url}/templates/{template_id}").json() + _response_json = self.http_call( + "get", url=f"{self.base_url}/templates/{template_id}" + ).json() return cast(dict[str, Any], _response_json) elif name: try: - return next(t for t in self.get_templates() if t["name"] == name) + return next( + t for t in self.get_templates() if t["name"] == name + ) except StopIteration: # Template name not found return None @@ -452,14 +517,17 @@ class Gns3Connector: **Optional Attributes (can be passed via kwargs):** - - `tags` (list): List of tags for the template (e.g., ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - `tags` (list): List of tags for the template (e.g., + ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) - Any other template attributes supported by GNS3 API """ # Get existing template _template = self.get_template(name=name, template_id=template_id) # Type check: handle case where get_template might return None if _template is None: - raise ValueError(f"Template not found (name={name}, id={template_id})") + raise ValueError( + f"Template not found (name={name}, id={template_id})" + ) # Update local dictionary and send request _template.update(**kwargs) @@ -485,7 +553,8 @@ class Gns3Connector: **Optional Attributes (can be passed via kwargs):** - - `tags` (list): List of tags for the template (e.g., ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - `tags` (list): List of tags for the template (e.g., + ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) - Any other template attributes supported by GNS3 API **Example:** @@ -498,10 +567,13 @@ class Gns3Connector: ... ) ``` """ - # kwargs["name"] might raise KeyError at runtime, for more robust code we can use get first + # kwargs["name"] might raise KeyError at runtime, for more robust + # code we can use get first template_name = kwargs.get("name") if not template_name: - raise ValueError("Attribute 'name' is required to create a template") + raise ValueError( + "Attribute 'name' is required to create a template" + ) # Check if template already exists _template = self.get_template(name=kwargs["name"]) @@ -513,11 +585,15 @@ class Gns3Connector: kwargs["compute_id"] = "local" # Send request - response = self.http_call("post", url=f"{self.base_url}/templates", json_data=kwargs) + response = self.http_call( + "post", url=f"{self.base_url}/templates", json_data=kwargs + ) # Return and convert type return cast(dict[str, Any], response.json()) - def delete_template(self, name: str | None = None, template_id: str | None = None) -> None: + def delete_template( + self, name: str | None = None, template_id: str | None = None + ) -> None: """ Deletes a template by giving its attributes. For more information [API INFO] (http://api.gns3.net/en/2.2/api/v2/controller/template/ @@ -538,9 +614,13 @@ class Gns3Connector: # Final check: ensure template_id has a value at this point if not template_id: - raise ValueError("Must provide either a 'name' or 'template_id' to delete a template.") + raise ValueError( + "Must provide either a 'name' or 'template_id' to delete a template." + ) - self.http_call("delete", url=f"{self.base_url}/templates/{template_id}") + self.http_call( + "delete", url=f"{self.base_url}/templates/{template_id}" + ) def get_nodes(self, project_id: str) -> list[dict[str, Any]]: """ @@ -550,7 +630,9 @@ class Gns3Connector: - `project_id` """ - _response_data = self.http_call("get", url=f"{self.base_url}/projects/{project_id}/nodes").json() + _response_data = self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/nodes" + ).json() return cast(list[dict[str, Any]], _response_data) @@ -575,7 +657,9 @@ class Gns3Connector: - `project_id` """ - _response_data = self.http_call("get", url=f"{self.base_url}/projects/{project_id}/links").json() + _response_data = self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/links" + ).json() return cast(list[dict[str, Any]], _response_data) @@ -650,7 +734,9 @@ class Gns3Connector: return cast(dict[str, Any], _response_data) - def get_compute_images(self, emulator: str, compute_id: str = "local") -> list[dict[str, Any]]: + def get_compute_images( + self, emulator: str, compute_id: str = "local" + ) -> list[dict[str, Any]]: """ Returns a list of images available for a compute. @@ -669,7 +755,9 @@ class Gns3Connector: return cast(list[dict[str, Any]], _response_data) - def upload_compute_image(self, emulator: str, file_path: str, compute_id: str = "local") -> None: + def upload_compute_image( + self, emulator: str, file_path: str, compute_id: str = "local" + ) -> None: """ uploads an image for use by a compute. @@ -733,9 +821,15 @@ def verify_connector_and_id(f: F) -> F: _url = f"{_conn.base_url}/projects/{_project_id}/nodes" _response = _conn.http_call("get", _url) - extracted = [node for node in _response.json() if node["name"] == self.name] + extracted = [ + node + for node in _response.json() + if node["name"] == self.name + ] if len(extracted) > 1: # pragma: no cover - raise ValueError("Multiple nodes found with same name. Need to submit node_id") + raise ValueError( + "Multiple nodes found with same name. Need to submit node_id" + ) self.node_id = extracted[0]["node_id"] # Checks for Link if self.__class__.__name__ == "Link": @@ -814,7 +908,9 @@ class Link: @field_validator("filters") @classmethod - def _valid_filters(cls, value: dict[str, Any] | None) -> dict[str, Any] | None: + def _valid_filters( + cls, value: dict[str, Any] | None + ) -> dict[str, Any] | None: if type(value) is not dict and value is not None: raise ValueError(f"Not a valid filters - {value}") return value @@ -870,7 +966,9 @@ class Link: if _project_id is None: raise ValueError("Need to submit project_id") if _link_id is None: - raise ValueError("Link ID is missing. The link might have already been deleted.") + raise ValueError( + "Link ID is missing. The link might have already been deleted." + ) _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}" @@ -896,7 +994,12 @@ class Link: _url = f"{self.connector.base_url}/projects/{self.project_id}/links" - data = {k: v for k, v in self.__dict__.items() if k not in ("connector", "__initialised__") if v is not None} + data = { + k: v + for k, v in self.__dict__.items() + if k not in ("connector", "__initialised__") + if v is not None + } _response = self.connector.http_call("post", _url, json_data=data) @@ -928,7 +1031,10 @@ class Link: if not self.project_id: raise ValueError("Need to submit project_id") - _url = f"{self.connector.base_url}/projects/{self.project_id}/links/{self.link_id}" + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/links/" + f"{self.link_id}" + ) # TODO: Verify that the passed kwargs are supported ones _response = self.connector.http_call("put", _url, json_data=kwargs) @@ -1042,7 +1148,10 @@ class Node: @field_validator("status") @classmethod def _valid_status(cls, value: Any) -> Any: - if value not in ("stopped", "started", "suspended") and value is not None: + if ( + value not in ("stopped", "started", "suspended") + and value is not None + ): raise ValueError(f"Not a valid status - {value}") return value @@ -1068,7 +1177,10 @@ class Node: if not self.project_id: raise ValueError("Need to submit project_id") - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/{self.node_id}" + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes/" + f"{self.node_id}" + ) _response = self.connector.http_call("get", _url) # Update object @@ -1094,7 +1206,10 @@ class Node: if not self.project_id: raise ValueError("Need to submit project_id") - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" f"/{self.node_id}/links" + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes" + f"/{self.node_id}/links" + ) _response = self.connector.http_call("get", _url) # Create the Link array but cleanup cache if there is one @@ -1136,7 +1251,9 @@ class Node: else: # api_version 3 - _response = _conn.http_call("post", _url, json_data={"additionalProp1": {}}) + _response = _conn.http_call( + "post", _url, json_data={"additionalProp1": {}} + ) # successful response code 204 if _response.status_code in (204,): self.get() @@ -1145,7 +1262,9 @@ class Node: try: error_detail = _response.json() except Exception: - error_detail = getattr(_response, "text", "No response body") + error_detail = getattr( + _response, "text", "No response body" + ) _msg = ( "Failed to start node: " @@ -1186,7 +1305,9 @@ class Node: return True else: # api_version 3 - _response = _conn.http_call("post", _url, json_data={"additionalProp1": {}}) + _response = _conn.http_call( + "post", _url, json_data={"additionalProp1": {}} + ) # successful response code 204 if _response.status_code in (204,): self.get() @@ -1196,7 +1317,10 @@ class Node: error_detail = _response.json() except Exception: error_detail = _response.text - _msg = f"Failed to stop node: {_response.status_code}, " f"Detail: {error_detail}" + _msg = ( + f"Failed to stop node: {_response.status_code}, " + f"Detail: {error_detail}" + ) raise RuntimeError(_msg) from None @verify_connector_and_id @@ -1217,7 +1341,9 @@ class Node: _node_id = self.node_id assert _node_id is not None - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/reload" + _url = ( + f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/reload" + ) _response = _conn.http_call("post", _url) if "v2" in _url.lower(): # api_version 2 @@ -1235,7 +1361,9 @@ class Node: else: # api_version 3 - _response = _conn.http_call("post", _url, json_data={"additionalProp1": {}}) + _response = _conn.http_call( + "post", _url, json_data={"additionalProp1": {}} + ) # successful response code 204 if _response.status_code in (204,): self.get() @@ -1245,7 +1373,10 @@ class Node: error_detail = _response.json() except Exception: error_detail = _response.text - _msg = f"Failed to reload node: {_response.status_code}, " f"Detail: {error_detail}" + _msg = ( + f"Failed to reload node: {_response.status_code}, " + f"Detail: {error_detail}" + ) raise RuntimeError(_msg) from None @verify_connector_and_id @@ -1266,7 +1397,9 @@ class Node: _node_id = self.node_id assert _node_id is not None - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/suspend" + _url = ( + f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/suspend" + ) _response = _conn.http_call("post", _url) # Update object or perform get if change was not reflected @@ -1335,7 +1468,9 @@ class Node: _template = self.connector.get_template(name=self.template) if _template is None: raise ValueError(f"Template {self.template} not found") - self.template_id = self.connector.get_template(name=self.template).get("template_id") + self.template_id = self.connector.get_template( + name=self.template + ).get("template_id") else: raise ValueError("Need either 'template' of 'template_id'") @@ -1354,9 +1489,16 @@ class Node: if v is not None } - _url = f"{self.connector.base_url}/projects/{self.project_id}/" f"templates/{self.template_id}" + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/" + f"templates/{self.template_id}" + ) - _response = self.connector.http_call("post", _url, json_data={"x": 0, "y": 0, "compute_id": self.compute_id}) + _response = self.connector.http_call( + "post", + _url, + json_data={"x": 0, "y": 0, "compute_id": self.compute_id}, + ) self._update(_response.json()) @@ -1535,7 +1677,12 @@ class Project: if k in self.__dict__: setattr(self, k, v) - def get(self, get_links: bool = True, get_nodes: bool = True, get_stats: bool = True) -> None: + def get( + self, + get_links: bool = True, + get_nodes: bool = True, + get_stats: bool = True, + ) -> None: """ Retrieves the projects information. @@ -1955,7 +2102,9 @@ class Project: time.sleep(poll_wait_time) self.get_nodes() - def nodes_summary(self, is_print: bool = True) -> list[tuple[Any, ...]] | None: + def nodes_summary( + self, is_print: bool = True + ) -> list[tuple[Any, ...]] | None: """ Returns a summary of the nodes insode the project. If `is_print` is `False`, it will return a list of tuples like: @@ -1974,7 +2123,10 @@ class Project: _nodes_summary = [] for _n in self.nodes: if is_print: - print(f"{_n.name}: {_n.status} -- Console: {_n.console} -- " f"ID: {_n.node_id}") + print( + f"{_n.name}: {_n.status} -- Console: {_n.console} -- " + f"ID: {_n.node_id}" + ) _nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id)) return _nodes_summary if not is_print else None @@ -2010,7 +2162,9 @@ class Project: _nodes_inventory = {} conn = self.connector if not conn: - raise ValueError("Gns3Connector not assigned. Please set the connector first.") + raise ValueError( + "Gns3Connector not assigned. Please set the connector first." + ) _server = urlparse(conn.base_url).hostname @@ -2036,7 +2190,9 @@ class Project: return _nodes_inventory - def links_summary(self, is_print: bool = True) -> list[tuple[str, str, str, str]] | None: + def links_summary( + self, is_print: bool = True + ) -> list[tuple[str, str, str, str]] | None: """ Returns a summary of the links insode the project. If `is_print` is False, it will return a list of tuples like: @@ -2067,7 +2223,9 @@ class Project: try: # Add type-safe lookup logic - _node_a = next(x for x in self.nodes if x.node_id == _side_a["node_id"]) + _node_a = next( + x for x in self.nodes if x.node_id == _side_a["node_id"] + ) # Ensure getting str to resolve [return-value] error _port_a = str( next( @@ -2078,7 +2236,9 @@ class Project: ) ) - _node_b = next(x for x in self.nodes if x.node_id == _side_b["node_id"]) + _node_b = next( + x for x in self.nodes if x.node_id == _side_b["node_id"] + ) _port_b = str( next( x["name"] @@ -2116,7 +2276,9 @@ class Project: except IndexError: return None - def get_node(self, name: str | None = None, node_id: str | None = None) -> Any | None: + def get_node( + self, name: str | None = None, node_id: str | None = None + ) -> Any | None: """ Returns the Node object by searching for the `name` or the `node_id`. @@ -2185,13 +2347,20 @@ class Project: if not self.nodes: self.get_nodes() - _node = Node(project_id=self.project_id, connector=self.connector, **kwargs) + _node = Node( + project_id=self.project_id, connector=self.connector, **kwargs + ) _node.create() self.nodes.append(_node) - print(f"Created: {_node.name} -- Type: {_node.node_type} -- " f"Console: {_node.console}") + print( + f"Created: {_node.name} -- Type: {_node.node_type} -- " + f"Console: {_node.console}" + ) - def create_link(self, node_a: str, port_a: str, node_b: str, port_b: str) -> None: + def create_link( + self, node_a: str, port_a: str, node_b: str, port_b: str + ) -> None: """ Creates a link. @@ -2244,7 +2413,9 @@ class Project: ): _matches.append(_l) # pragma: no cover if _matches: - raise ValueError(f"At least one port is used, ID: {_matches[0].link_id}") + raise ValueError( + f"At least one port is used, ID: {_matches[0].link_id}" + ) # Now create the link! _link = Link( @@ -2270,7 +2441,9 @@ class Project: self.links.append(_link) print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") - def delete_link(self, node_a: str, port_a: str, node_b: str, port_b: str) -> None: + def delete_link( + self, node_a: str, port_a: str, node_b: str, port_b: str + ) -> None: """ Deletes a link. @@ -2324,7 +2497,9 @@ class Project: ): _matches.append(_l) if not _matches: - raise ValueError(f"Link not found: {node_a, port_a, node_b, port_b}") # pragma: no cover + raise ValueError( + f"Link not found: {node_a, port_a, node_b, port_b}" + ) # pragma: no cover # now to delete the link via GNS3_api _link = _matches[0] @@ -2332,7 +2507,8 @@ class Project: _link_id = _link.link_id _link.delete() print( - f"Deleted Link-ID: {_link_id} From node {node_a}, port: {port_a} <--> " f"to node {node_b}, port: {port_b}" + f"Deleted Link-ID: {_link_id} From node {node_a}, port: {port_a} <--> " + f"to node {node_b}, port: {port_b}" ) @verify_connector_and_id @@ -2361,11 +2537,15 @@ class Project: self.get_snapshots() try: - return next(_p for _p in (self.snapshots or []) if _p[key] == value) + return next( + _p for _p in (self.snapshots or []) if _p[key] == value + ) except StopIteration: return None - def get_snapshot(self, name: str | None = None, snapshot_id: str | None = None) -> dict[str, Any] | None: + def get_snapshot( + self, name: str | None = None, snapshot_id: str | None = None + ) -> dict[str, Any] | None: """ Returns the Snapshot by searching for the `name` or the `snapshot_id`. @@ -2422,7 +2602,9 @@ class Project: print(f"Created snapshot: {_snapshot['name']}") @verify_connector_and_id - def delete_snapshot(self, name: str | None = None, snapshot_id: str | None = None) -> None: + def delete_snapshot( + self, name: str | None = None, snapshot_id: str | None = None + ) -> None: """ Deletes a snapshot of the project @@ -2446,14 +2628,19 @@ class Project: if not _snapshot: raise ValueError("Snapshot not found") - _url = f"{_conn.base_url}/projects/{_project_id}/snapshots/" f"{_snapshot['snapshot_id']}" + _url = ( + f"{_conn.base_url}/projects/{_project_id}/snapshots/" + f"{_snapshot['snapshot_id']}" + ) _conn.http_call("delete", _url) self.get_snapshots() @verify_connector_and_id - def restore_snapshot(self, name: str | None = None, snapshot_id: str | None = None) -> None: + def restore_snapshot( + self, name: str | None = None, snapshot_id: str | None = None + ) -> None: """ Restore a snapshot from disk @@ -2477,7 +2664,10 @@ class Project: if not _snapshot: raise ValueError("Snapshot not found") - _url = f"{_conn.base_url}/projects/{_project_id}/snapshots/" f"{_snapshot['snapshot_id']}/restore" + _url = ( + f"{_conn.base_url}/projects/{_project_id}/snapshots/" + f"{_snapshot['snapshot_id']}/restore" + ) _conn.http_call("post", _url) @@ -2512,7 +2702,9 @@ class Project: _y = int(radius * (-cos(_angle * index))) n.update(x=_x, y=_y) - def get_drawing(self, drawing_id: str | None = None) -> dict[str, Any] | None: + def get_drawing( + self, drawing_id: str | None = None + ) -> dict[str, Any] | None: """ Returns the drawing by searching for the `svg` or the `drawing_id`. @@ -2528,7 +2720,11 @@ class Project: self.get_drawings() try: - return next(_drawing for _drawing in (self.drawings or []) if _drawing["drawing_id"] == drawing_id) + return next( + _drawing + for _drawing in (self.drawings or []) + if _drawing["drawing_id"] == drawing_id + ) except (StopIteration, KeyError, TypeError): return None @@ -2633,17 +2829,25 @@ class Project: # Type guard: inform Mypy that self.drawings is now an iterable list # Use or [] with next to find target object current_drawing = next( - (d for d in (self.drawings or []) if d.get("drawing_id") == drawing_id), + ( + d + for d in (self.drawings or []) + if d.get("drawing_id") == drawing_id + ), None, ) if current_drawing is None: - raise ValueError(f"Drawing with ID {drawing_id} not found in project.") + raise ValueError( + f"Drawing with ID {drawing_id} not found in project." + ) # If parameter is None, get original value from current object # This way, Mypy won't report errors for list comprehensions of each field final_svg = svg if svg is not None else current_drawing.get("svg") - final_locked = locked if locked is not None else current_drawing.get("locked") + final_locked = ( + locked if locked is not None else current_drawing.get("locked") + ) final_x = x if x is not None else current_drawing.get("x") final_y = y if y is not None else current_drawing.get("y") final_z = z if z is not None else current_drawing.get("z") @@ -2691,7 +2895,10 @@ class Project: if not _drawing: raise ValueError("drawing not found") - _url = f"{_conn.base_url}/projects/{_project_id}/drawings/{_drawing['drawing_id']}" + _url = ( + f"{_conn.base_url}/projects/{_project_id}/drawings/" + f"{_drawing['drawing_id']}" + ) _conn.http_call("delete", _url) diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py index b0bcf09f4..4422b728d 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py @@ -50,7 +50,8 @@ class GNS3ProjectInfoTool(BaseTool): name: str = "gns3_project_info" description: str = """ - Retrieves basic information of a GNS3 project including name, status, node count and link count. + Retrieves basic information of a GNS3 project including name, status, + node count and link count. Input: `project_id` (str, required): UUID of the GNS3 project. @@ -79,18 +80,26 @@ class GNS3ProjectInfoTool(BaseTool): project_id : The UUID of the specific GNS3 project. Returns: - dict: A dictionary containing project info (name, project_id, node_count, link_count, status), - or an error dictionary if an exception occurs or project_id is not provided. + dict: A dictionary containing project info (name, project_id, node_count, + link_count, status), or an error dictionary if an exception occurs + or project_id is not provided. """ # Log received input - logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id) + logger.info( + "Received tool_input: %s, project_id: %s", tool_input, project_id + ) try: # Validate project_id parameter if not project_id: logger.error("project_id parameter is required.") - return {"error": "project_id parameter is required. Please provide a valid project UUID."} + return { + "error": ( + "project_id parameter is required. Please provide a valid " + "project UUID." + ) + } # Initialize Gns3Connector using factory function logger.info("Connecting to GNS3 server...") @@ -98,10 +107,17 @@ class GNS3ProjectInfoTool(BaseTool): if server is None: logger.error("Failed to create GNS3 connector") - return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + return { + "error": ( + "Failed to connect to GNS3 server. Please check your " + "configuration." + ) + } # Use the provided project_id directly - logger.info(f"Retrieving project info for project_id: {project_id}") + logger.info( + f"Retrieving project info for project_id: {project_id}" + ) project = Project(project_id=project_id, connector=server) project.get() # Load project details @@ -119,7 +135,13 @@ class GNS3ProjectInfoTool(BaseTool): "status": project.status, "node_count": node_count, "link_count": link_count, - "tuple": (project.name, project.project_id, node_count, link_count, project.status), + "tuple": ( + project.name, + project.project_id, + node_count, + link_count, + project.status, + ), } # Log result diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py index 7a858cc3e..aa130d129 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -27,7 +27,8 @@ GNS3 Topology Reader Tool This module provides a LangChain BaseTool to retrieve the topology of a -specific GNS3 project by project ID. Returns nodes, links, and project metadata. +specific GNS3 project by project ID. Returns nodes, links, and project +metadata. """ @@ -57,7 +58,8 @@ class GNS3TopologyTool(BaseTool): Output: Dictionary with: - `project_id`, `name`, `status`: Project metadata - - `nodes`: Dict of node details (node_id, name, ports, console_port, type, etc.) + - `nodes`: Dict of node details (node_id, name, ports, console_port, + type, etc.) - `links`: List of link connections Use this to understand network structure before making changes. @@ -73,23 +75,31 @@ class GNS3TopologyTool(BaseTool): Synchronous method to retrieve the topology of a specific GNS3 project. Args: - tool_input : Input parameters, typically a dict or Pydantic model containing server_url. - run_manager : Callback manager for tool run. - project_id : The UUID of the specific GNS3 project to retrieve topology from. + tool_input: Input parameters, typically a dict or Pydantic model + containing server_url. + run_manager: Callback manager for tool run. + project_id: The UUID of the specific GNS3 project to retrieve + topology from. Returns: - dict: A dictionary containing the project ID, name, status, nodes, and links, - or an error dictionary if an exception occurs or project_id is not provided. + dict: A dictionary containing the project ID, name, status, nodes, + and links, or an error dictionary if an exception occurs + or project_id is not provided. """ # Log received input - logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id) + logger.info( + "Received tool_input: %s, project_id: %s", tool_input, project_id + ) try: # Validate project_id parameter if not project_id: logger.error("project_id parameter is required.") - return {"error": "project_id parameter is required. Please provide a valid project UUID."} + return { + "error": "project_id parameter is required. " + "Please provide a valid project UUID." + } # Initialize Gns3Connector using factory function logger.info("Connecting to GNS3 server...") @@ -97,7 +107,10 @@ class GNS3TopologyTool(BaseTool): if server is None: logger.error("Failed to create GNS3 connector") - return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + return { + "error": "Failed to connect to GNS3 server. Please check " + "your configuration." + } # Use the provided project_id directly logger.info(f"Retrieving topology for project_id: {project_id}") @@ -109,13 +122,16 @@ class GNS3TopologyTool(BaseTool): "project_id": project.project_id, "name": project.name, "status": project.status, - "nodes": self._clean_nodes_ports(copy.deepcopy(project.nodes_inventory())), + "nodes": self._clean_nodes_ports( + copy.deepcopy(project.nodes_inventory()) + ), "links": project.links_summary(is_print=False), } # Log topology result logger.info( - "Topology retrieved: project_id=%s, name=%s, nodes=%d, links=%d", + "Topology retrieved: project_id=%s, name=%s, nodes=%d, " + "links=%d", topology.get("project_id"), topology.get("name"), len(topology.get("nodes", {})), @@ -132,11 +148,16 @@ class GNS3TopologyTool(BaseTool): def _clean_nodes_ports(self, data: dict) -> dict: """ Clean and simplify the nodes data structure. - Simplify each node's ports list to only keep name and short_name fields. + + Simplify each node's ports list to only keep name and short_name + fields. """ for node in data.values(): # Iterate through R-1, R-2, R-3, R-4 if "ports" in node and isinstance(node["ports"], list): - node["ports"] = [{"name": port["name"], "short_name": port["short_name"]} for port in node["ports"]] + node["ports"] = [ + {"name": port["name"], "short_name": port["short_name"]} + for port in node["ports"] + ] return data diff --git a/gns3server/agent/gns3_copilot/project_agent_manager.py b/gns3server/agent/gns3_copilot/project_agent_manager.py index abc6505b2..edc452f32 100644 --- a/gns3server/agent/gns3_copilot/project_agent_manager.py +++ b/gns3server/agent/gns3_copilot/project_agent_manager.py @@ -27,7 +27,8 @@ Project Agent Manager Manages AgentService instances for GNS3 projects using a singleton pattern. -Each project has its own AgentService with a dedicated SQLite checkpoint database. +Each project has its own AgentService with a dedicated SQLite checkpoint +database. """ import asyncio @@ -60,7 +61,9 @@ class ProjectAgentManager: cls._instance._lock = asyncio.Lock() return cls._instance - async def get_agent(self, project_id: str, project_path: str) -> AgentService: + async def get_agent( + self, project_id: str, project_path: str + ) -> AgentService: """ Get or create an AgentService for a project. @@ -73,7 +76,11 @@ class ProjectAgentManager: """ async with self._lock: if project_id not in self._agents: - log.info("Creating new AgentService for project: %s at %s", project_id, project_path) + log.info( + "Creating new AgentService for project: %s at %s", + project_id, + project_path, + ) self._agents[project_id] = AgentService(project_path) return self._agents[project_id] @@ -99,7 +106,10 @@ class ProjectAgentManager: Should be called on server shutdown. """ async with self._lock: - log.info("Closing all AgentService instances (%d projects)", len(self._agents)) + log.info( + "Closing all AgentService instances (%d projects)", + len(self._agents), + ) for project_id, agent in self._agents.items(): log.debug("Closing AgentService for project: %s", project_id) await agent.close() diff --git a/gns3server/agent/gns3_copilot/prompts/__init__.py b/gns3server/agent/gns3_copilot/prompts/__init__.py index 43e810089..4a244d3b1 100644 --- a/gns3server/agent/gns3_copilot/prompts/__init__.py +++ b/gns3server/agent/gns3_copilot/prompts/__init__.py @@ -30,8 +30,8 @@ This package contains system prompts and prompt loading utilities for the GNS3-Copilot AI agent. Available prompts: -- teaching_assistant_prompt: Teaching assistant mode (diagnostics only, no configuration) -- lab_automation_assistant_prompt: Lab automation assistant mode (diagnostics + configuration) +- teaching_assistant_prompt: Teaching assistant mode (diagnostics only) +- lab_automation_assistant_prompt: Lab automation mode (diagnostics + config) """ diff --git a/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py b/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py index 2b8963ad5..84f6ef945 100644 --- a/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py +++ b/gns3server/agent/gns3_copilot/prompts/lab_automation_assistant_prompt.py @@ -52,21 +52,21 @@ You have access to the following tools to help users: | Tool | Purpose | Usage | |------|---------|-------| -| `gns3_template_reader` | Get available node templates | List templates for creating nodes | -| `gns3_create_node` | Create new nodes in topology | Add routers, switches, VPCS, etc. | -| `gns3_link_tool` | Create links between nodes | Connect network topology | -| `gns3_start_node_tool` | Start/stop nodes | Control device power state | +| `gns3_template_reader` | Get available node templates | List templates | +| `gns3_create_node` | Create new nodes in topology | Add routers, switches, VPCS | +| `gns3_link_tool` | Create links between nodes | Connect topology | +| `gns3_start_node_tool` | Start/stop nodes | Control power state | | `gns3_update_node_name_tool` | Update node names | Rename devices | -| `execute_multiple_device_commands` | Execute display commands | Read-only diagnostics (show/display/debug) | -| `execute_multiple_device_config_commands` | Execute configuration commands | Make configuration changes | +| `execute_multiple_device_commands` | Execute display commands | Diagnostics | +| `execute_multiple_device_config_commands` | Execute config commands | Config changes | | `vpcs_multi_commands` | Execute VPCS commands | Configure VPCS devices | --- # TOOL USAGE RULES -1. **Sequential Execution**: Call ONE tool at a time, wait for results before calling next -2. **Topology Awareness**: If topology is already in context, DO NOT call topology reader again +1. **Sequential Execution**: Call ONE tool at a time, wait for results +2. **Topology Awareness**: If topology is in context, DO NOT call reader again 3. **Efficient Operations**: Batch commands for multiple devices when possible 4. **Safety First**: Be cautious with destructive operations (reload, erase, format) @@ -176,7 +176,8 @@ While you have configuration permissions, exercise caution: {{topology_info}} -**Note**: Topology is already retrieved. DO NOT call topology reader again unless needed. +**Note**: Topology is already retrieved. DO NOT call topology reader again unless +needed. --- """ diff --git a/gns3server/agent/gns3_copilot/prompts/prompt_loader.py b/gns3server/agent/gns3_copilot/prompts/prompt_loader.py index b6f5917ab..dcc69df2e 100644 --- a/gns3server/agent/gns3_copilot/prompts/prompt_loader.py +++ b/gns3server/agent/gns3_copilot/prompts/prompt_loader.py @@ -30,8 +30,10 @@ This module provides utilities for loading system prompts. Supports multiple prompt variants based on LLM model configuration. Available Modes (controlled by config.copilot_mode in llm_model_configs): -- "teaching_assistant" (default): Teaching assistant mode - diagnostics only, no configuration -- "lab_automation_assistant": Full lab automation assistant mode - diagnostics and configuration enabled +- "teaching_assistant" (default): Teaching assistant mode - diagnostics only, + no configuration +- "lab_automation_assistant": Full lab automation assistant mode - diagnostics + and configuration enabled """ @@ -47,18 +49,25 @@ def load_system_prompt(llm_config: dict | None = None) -> str: """ Load the system prompt for GNS3-Copilot. - The prompt mode is controlled by the `copilot_mode` field in the LLM model config: - - "teaching_assistant" (default): Teaching assistant mode - diagnostics only, no configuration - - "lab_automation_assistant": Full lab automation assistant mode - diagnostics and configuration enabled + The prompt mode is controlled by the `copilot_mode` field in the LLM + model config: + - "teaching_assistant" (default): Teaching assistant mode - diagnostics + only, no configuration + - "lab_automation_assistant": Full lab automation assistant mode - + diagnostics and configuration enabled Args: - llm_config: LLM model configuration dictionary (flattened structure from get_user_llm_config_full) + llm_config: LLM model configuration dictionary (flattened structure + from get_user_llm_config_full) Returns: str: The system prompt string. """ if not llm_config: - logger.info("No LLM config provided, using default TEACHING_ASSISTANT prompt mode") + logger.info( + "No LLM config provided, using default TEACHING_ASSISTANT " + "prompt mode" + ) return TEACHING_ASSISTANT_PROMPT # llm_config is a flattened dict with copilot_mode at the top level @@ -66,7 +75,10 @@ def load_system_prompt(llm_config: dict | None = None) -> str: mode = llm_config.get("copilot_mode", "teaching_assistant").lower() if mode == "lab_automation_assistant": - logger.info("Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + configuration)") + logger.info( + "Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + " + "configuration)" + ) return LAB_AUTOMATION_ASSISTANT_PROMPT else: logger.info("Using TEACHING_ASSISTANT prompt mode (diagnostics only)") diff --git a/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py b/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py index e65b95a68..cb12e9ace 100644 --- a/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py +++ b/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py @@ -51,9 +51,11 @@ You are a **GNS3 Lab Teaching Assistant**. 1. ❌ **NEVER** call `execute_multiple_device_config_commands` 2. ❌ **NEVER** say "I've configured..." / "Configuration complete" -3. ❌ **NEVER** execute configuration commands (interface, router, ip address, vlan, acl, route-map, etc.) +3. ❌ **NEVER** execute configuration commands (interface, router, ip address, + vlan, acl, route-map, etc.) -**Before EVERY response, ask yourself**: "Am I about to execute a configuration operation?" +**Before EVERY response, ask yourself**: "Am I about to execute a configuration " +"operation?" → If YES → Stop and provide guidance instead → If NO → Proceed with diagnosis @@ -119,5 +121,6 @@ ip route, ip addr, tcpdump, ping, traceroute {{topology_info}} -**Note**: Topology is already retrieved. DO NOT call topology reader again unless needed. +**Note**: Topology is already retrieved. DO NOT call topology reader again unless +needed. """ diff --git a/gns3server/agent/gns3_copilot/tools_v2/__init__.py b/gns3server/agent/gns3_copilot/tools_v2/__init__.py index a24d42ab5..d81acb873 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/__init__.py +++ b/gns3server/agent/gns3_copilot/tools_v2/__init__.py @@ -26,7 +26,7 @@ GNS3-Copilot Tools Package -This package provides various tools for interacting with GNS3 network simulator, including: +This package provides various tools for interacting with GNS3 network simulator: - Device configuration command execution - Display command execution - Multiple device command execution using Nornir @@ -36,7 +36,7 @@ This package provides various tools for interacting with GNS3 network simulator, Main modules: - config_tools_nornir: Multiple device configuration command execution tool using Nornir - display_tools_nornir: Multiple device command execution tool using Nornir -- vpcs_tools_telnetlib3: VPCS device configuration tool using telnetlib3 (concurrent execution) +- vpcs_tools_telnetlib3: VPCS device configuration tool using telnetlib3 - gns3_create_node: GNS3 node creation tool - gns3_create_link: GNS3 link creation tool - gns3_start_node: GNS3 node startup tool diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py index 4d3ecb2cc..cff664baf 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -24,8 +24,8 @@ # """ -This module provides a tool to execute configuration commands on multiple devices - in a GNS3 topology using Nornir. +This module provides a tool to execute configuration commands on multiple + devices in a GNS3 topology using Nornir. """ import json @@ -45,14 +45,17 @@ from nornir_netmiko.tasks import netmiko_send_config from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology -from gns3server.agent.gns3_copilot.utils.command_filter import filter_forbidden_commands +from gns3server.agent.gns3_copilot.utils.command_filter import ( + filter_forbidden_commands, +) # config log logger = logging.getLogger(__name__) # Suppress nornir INFO logs in console (reduce verbosity) -# The logging={"enabled": False} in InitNornir only disables plugin internal logs, -# but nornir.core still logs task execution at INFO level. Set to WARNING to suppress these. +# The logging={"enabled": False} in InitNornir only disables plugin +# internal logs, but nornir.core still logs task execution at INFO level. +# Set to WARNING to suppress these. logging.getLogger("nornir.core").setLevel(logging.WARNING) logging.getLogger("nornir").setLevel(logging.WARNING) @@ -63,7 +66,9 @@ def _get_nornir_defaults() -> dict[str, Any]: return {"data": {"location": "gns3"}} -def _get_nornir_groups_config(device_type: str = "cisco_ios_telnet", platform: str = "cisco_ios") -> dict[str, Any]: +def _get_nornir_groups_config( + device_type: str = "cisco_ios_telnet", platform: str = "cisco_ios" +) -> dict[str, Any]: """ Get Nornir group configuration for Cisco IOS Telnet devices. @@ -80,12 +85,16 @@ def _get_nornir_groups_config(device_type: str = "cisco_ios_telnet", platform: s "timeout": 120, "username": "", "password": "", - "connection_options": {"netmiko": {"extras": {"device_type": device_type}}}, + "connection_options": { + "netmiko": {"extras": {"device_type": device_type}} + }, } def _get_nornir_group( - group_name: str = "cisco_IOSv_telnet", device_type: str = "cisco_ios_telnet", platform: str = "cisco_ios" + group_name: str = "cisco_IOSv_telnet", + device_type: str = "cisco_ios_telnet", + platform: str = "cisco_ios", ) -> dict[str, Any]: """ Get Nornir group configuration for a specific group. @@ -99,26 +108,31 @@ def _get_nornir_group( Dictionary containing group configuration """ # _get_nornir_groups_config now returns the group config directly - return _get_nornir_groups_config(device_type=device_type, platform=platform) + return _get_nornir_groups_config( + device_type=device_type, platform=platform + ) class ExecuteMultipleDeviceConfigCommands(BaseTool): """ - A tool to execute configuration commands on multiple devices in a GNS3 topology using Nornir. - This class uses Nornir to manage connections and execute configuration commands - on multiple devices concurrently. + A tool to execute configuration commands on multiple devices in a GNS3 + topology using Nornir. + This class uses Nornir to manage connections and execute configuration + commands on multiple devices concurrently. IMPORTANT SAFETY NOTE: - This tool is intended for configuration changes only. Use extreme caution when - executing configuration commands. + This tool is intended for configuration changes only. Use extreme caution + when executing configuration commands. """ name: str = "execute_multiple_device_config_commands" description: str = """ - Executes CONFIGURATION commands on multiple devices in the current GNS3 topology. - Use this tool ONLY for changing device settings (e.g., 'configure', 'interface', 'ip address', 'router ospf'). + Executes CONFIGURATION commands on multiple devices in the current GNS3 + topology. + Use this tool ONLY for changing device settings (e.g., 'configure', + 'interface', 'ip address', 'router ospf'). For viewing information, use the 'execute_multiple_device_commands' tool. - Input should be a JSON object containing project_id and device configurations. + Input should be a JSON object containing project_id and device configs. Example input: { "project_id": "", @@ -141,10 +155,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): } ] } - Returns a list of dictionaries, each containing the device name and configuration results. + Returns a list of dicts with device name and config results. IMPORTANT SAFETY WARNING: - Do NOT use this tool for dangerous operations that could reboot, erase, or factory-reset devices. + Do NOT use this tool for dangerous operations that could reboot, erase, + or factory-reset devices. Forbidden operations include but are not limited to: - reload / reboot commands - write erase / erase startup-config @@ -161,14 +176,14 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): **kwargs: Any, ) -> list[dict[str, Any]]: """ - Executes configuration commands on multiple devices in the current GNS3 topology. + Executes configuration commands on multiple devices in GNS3 topology. Args: tool_input (str): A JSON string containing project_id and device configuration commands to execute. Returns: - List[Dict[str, Any]]: A list of dictionaries containing device names and + List[Dict[str, Any]]: A list of dicts containing device names and configuration results. """ # Log received input @@ -176,12 +191,18 @@ 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 ( + isinstance(device_configs_list, list) + and len(device_configs_list) > 0 + and "error" in device_configs_list[0] + ): return device_configs_list # Filter forbidden commands and store blocked commands info - device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( - device_configs_list + device_configs_list, blocked_commands_map = ( + self._filter_forbidden_commands_from_device_configs( + device_configs_list + ) ) # Create a mapping of device names to their configuration commands @@ -189,7 +210,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Prepare device hosts data try: - hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id) + hosts_data = self._prepare_device_hosts_data( + device_configs_list, project_id + ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) return [{"error": str(e)}] @@ -211,11 +234,18 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): ) # Process results for all devices - results = self._process_task_results(device_configs_list, hosts_data, task_result, blocked_commands_map) + results = self._process_task_results( + device_configs_list, + hosts_data, + task_result, + blocked_commands_map, + ) except Exception as e: # Overall execution failed - logger.error("Error executing configurations on all devices: %s", e) + logger.error( + "Error executing configurations on all devices: %s", e + ) return [{"error": f"Execution error: {str(e)}"}] logger.info( @@ -225,16 +255,22 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return results - def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result: + def _run_all_device_configs_with_single_retry( + self, task: Task, device_configs_map: dict[str, list[str]] + ) -> Result: """Execute configuration commands with single retry mechanism.""" device_name = task.host.name config_commands = device_configs_map.get(device_name, []) if not config_commands: - return Result(host=task.host, result="No configuration commands to execute") + return Result( + host=task.host, result="No configuration commands to execute" + ) try: - _result = task.run(task=netmiko_send_config, config_commands=config_commands) + _result = task.run( + task=netmiko_send_config, config_commands=config_commands + ) return Result(host=task.host, result=_result.result) except ReadTimeout as e: @@ -251,10 +287,12 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): ) except Exception as e: - # Handle prompt detection issues with Cisco IOSv L2 images where the '#' prompt character - # may be delayed, causing Netmiko prompt detection failures. Implements retry logic. + # Handle Cisco IOSv L2 where '#' prompt char may be delayed, + # causing Netmiko failures. Implements retry logic. if "netmiko_send_config (failed)" in str(e): - _result = task.run(task=netmiko_send_config, config_commands=config_commands) + _result = task.run( + task=netmiko_send_config, config_commands=config_commands + ) return Result(host=task.host, result=_result.result) # Log any other exceptions with full details @@ -274,14 +312,14 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): self, tool_input: str | bytes | list[Any] | dict[str, Any] ) -> tuple[list[dict[str, Any]], str | None]: """ - Validate device configuration command input, handling both new and legacy input formats. - Supports new format with project_id and device_configs, as well as legacy array format. + Validate device config command input, handling new and legacy formats. + Supports new format with project_id and device_configs. Args: - tool_input: The input received from the LangChain/LangGraph tool call. + tool_input: Input from the LangChain/LangGraph tool call. Returns: - Tuple containing (device_configs_list, project_id) or (error_list, None) + Tuple of (device_configs_list, project_id) or (error_list, None) """ parsed_input = None @@ -289,18 +327,26 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Compatibility Check and Parsing --- # Check if the input is a string (or bytes) which needs to be parsed. if isinstance(tool_input, (str, bytes, bytearray)): - # Handle models (like potentially DeepSeek) that return a raw JSON string. + # Handle models (like DeepSeek) that return a raw JSON string. try: parsed_input = json.loads(tool_input) logger.info("Successfully parsed tool input from JSON string.") except json.JSONDecodeError as e: - logger.error("Invalid JSON string received as tool input: %s", e) - return ([{"error": f"Invalid JSON string input from model: {e}"}], None) + logger.error( + "Invalid JSON string received as tool input: %s", e + ) + return ( + [{"error": f"Invalid JSON string input from model: {e}"}], + None, + ) else: # Handle standard models (like GPT/OpenAI) where the framework # has already parsed the JSON into a Python object (dict or list). parsed_input = tool_input - logger.info("Using tool input directly as type: %s", type(parsed_input).__name__) + logger.info( + "Using tool input directly as type: %s", + type(parsed_input).__name__, + ) # Handle new format: {"project_id": "...", "device_configs": [...]} if isinstance(parsed_input, dict): @@ -314,7 +360,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return ([{"error": error_msg}], None) if not self._validate_project_id(project_id): - error_msg = f"Invalid project_id format: {project_id}. Expected UUID format." + error_msg = ( + f"Invalid project_id format: {project_id}. " + "Expected UUID format." + ) logger.error(error_msg) return ([{"error": error_msg}], None) @@ -332,13 +381,17 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Handle legacy format: [...] elif isinstance(parsed_input, list): - logger.warning("Using legacy input format without project_id. Please use new format with project_id.") + logger.warning( + "Using legacy format without project_id. " + "Please use new format with project_id." + ) return parsed_input, None else: error_msg = ( - "Tool input must be a JSON object with 'project_id' and 'device_configs' fields, " - f"or a legacy JSON array, but got {type(parsed_input).__name__}" + "Tool input must be a JSON object with 'project_id' and " + f"'device_configs' fields, or a legacy JSON array, but got " + f"{type(parsed_input).__name__}" ) logger.error(error_msg) return ([{"error": error_msg}], None) @@ -353,7 +406,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): Returns: True if valid UUID format, False otherwise """ - uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + uuid_pattern = ( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) def _filter_forbidden_commands_from_device_configs( @@ -363,12 +418,12 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): Filter out forbidden commands from device configurations. Args: - device_configs_list: List of device configurations with config_commands. + device_configs_list: List of device configs with config_commands. Returns: - A tuple of (filtered_device_configs_list, blocked_commands_map): - - filtered_device_configs_list: Device configs with forbidden commands removed. - - blocked_commands_map: Dict mapping device names to their blocked commands info. + Tuple of (filtered_configs, blocked_map): + - filtered_configs: Device configs with forbidden removed. + - blocked_map: Dict mapping device names to blocked info. """ filtered_list = [] blocked_commands_map: dict[str, dict[str, str]] = {} @@ -378,7 +433,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): commands = device_config["config_commands"] # Filter commands - allowed_commands, blocked_info = filter_forbidden_commands(commands) + allowed_commands, blocked_info = filter_forbidden_commands( + commands + ) # Update device config with allowed commands only filtered_config = device_config.copy() @@ -397,7 +454,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return filtered_list, blocked_commands_map - def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]: + def _configs_map( + self, device_config_list: list[dict[str, Any]] + ) -> dict[str, list[str]]: """Create a mapping of device names to their configuration commands.""" device_configs_map = {} for device_config in device_config_list: @@ -408,18 +467,23 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return device_configs_map def _prepare_device_hosts_data( - self, device_config_list: list[dict[str, Any]], project_id: str | None = None + self, + device_config_list: list[dict[str, Any]], + project_id: str | None = None, ) -> dict[str, dict[str, Any]]: """Prepare device hosts data from topology information.""" # Extract device names list - device_names = [device_config["device_name"] for device_config in device_config_list] + device_names = [ + device_config["device_name"] + for device_config in device_config_list + ] # Get device port information with project_id hosts_data = get_device_ports_from_topology(device_names, project_id) if not hosts_data: error_msg = ( - f"Failed to get device information from topology or no valid devices found. " + "Failed to get device info from topology or no valid devices. " f"Project ID: {project_id}, Devices: {device_names}" ) raise ValueError(error_msg) @@ -435,7 +499,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): return hosts_data - def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir: + def _initialize_nornir( + self, hosts_data: dict[str, dict[str, Any]] + ) -> Nornir: """Initialize Nornir with the provided hosts data.""" try: # Extract device_type and platform from hosts_data @@ -445,14 +511,21 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): if hosts_data: first_device_data = next(iter(hosts_data.values()), {}) - device_type = first_device_data.get("device_type", "cisco_ios_telnet") + device_type = first_device_data.get( + "device_type", "cisco_ios_telnet" + ) platform = first_device_data.get("platform", "cisco_ios") - logger.info("Extracted from tags: device_type=%s, platform=%s", device_type, platform) + logger.info( + "Extracted from tags: device_type=%s, platform=%s", + device_type, + platform, + ) - # Get latest environment configuration with dynamic device_type and platform + # Get environment config with dynamic device_type and platform groups_data = _get_nornir_groups_config( - device_type=device_type or "cisco_ios_telnet", platform=platform or "cisco_ios" + device_type=device_type or "cisco_ios_telnet", + platform=platform or "cisco_ios", ) defaults = _get_nornir_defaults() @@ -460,7 +533,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): gns3_host = get_gns3_server_host() logger.info( - "Initializing Nornir with account: host=%s, platform=%s, timeout=%d", + "Initializing Nornir: host=%s, platform=%s, timeout=%d", gns3_host, groups_data.get("platform"), groups_data.get("timeout"), @@ -505,11 +578,16 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": (f"Device '{device_name}' not found in topology or missing console_port"), + "error": ( + f"Device '{device_name}' not found in topology or " + "missing console_port" + ), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -519,11 +597,15 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": (f"Device '{device_name}' not found in task results"), + "error": ( + f"Device '{device_name}' not found in task results" + ), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -535,7 +617,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): if multi_result[0].failed: # Execution failed device_result["status"] = "failed" - device_result["error"] = f"Configuration execution failed: {multi_result[0].result}" + device_result["error"] = ( + f"Configuration execution failed: {multi_result[0].result}" + ) device_result["output"] = multi_result[0].result else: # Execution successful @@ -545,9 +629,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) device_result["blocked_info"] = blocked_commands_info - # Update status if some commands were blocked but execution succeeded + # Update status if some commands were blocked but succeeded if device_result["status"] == "success": device_result["status"] = "partial_success" diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py index 0fb9a703b..77f1369b3 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -45,14 +45,17 @@ from nornir_netmiko.tasks import netmiko_multiline from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology -from gns3server.agent.gns3_copilot.utils.command_filter import filter_forbidden_commands +from gns3server.agent.gns3_copilot.utils.command_filter import ( + filter_forbidden_commands, +) # config log logger = logging.getLogger(__name__) # Suppress nornir INFO logs in console (reduce verbosity) -# The logging={"enabled": False} in InitNornir only disables plugin internal logs, -# but nornir.core still logs task execution at INFO level. Set to WARNING to suppress these. +# The logging={"enabled": False} in InitNornir only disables plugin +# internal logs, but nornir.core still logs task execution at INFO level. +# Set to WARNING to suppress these. logging.getLogger("nornir.core").setLevel(logging.WARNING) logging.getLogger("nornir").setLevel(logging.WARNING) @@ -63,7 +66,9 @@ def _get_nornir_defaults() -> dict[str, Any]: return {"data": {"location": "gns3"}} -def _get_nornir_groups_config(device_type: str = "cisco_ios_telnet", platform: str = "cisco_ios") -> dict[str, Any]: +def _get_nornir_groups_config( + device_type: str = "cisco_ios_telnet", platform: str = "cisco_ios" +) -> dict[str, Any]: """ Get Nornir group configuration for Cisco IOS Telnet devices. @@ -80,12 +85,16 @@ def _get_nornir_groups_config(device_type: str = "cisco_ios_telnet", platform: s "timeout": 120, "username": "", "password": "", - "connection_options": {"netmiko": {"extras": {"device_type": device_type}}}, + "connection_options": { + "netmiko": {"extras": {"device_type": device_type}} + }, } def _get_nornir_group( - group_name: str = "cisco_IOSv_telnet", device_type: str = "cisco_ios_telnet", platform: str = "cisco_ios" + group_name: str = "cisco_IOSv_telnet", + device_type: str = "cisco_ios_telnet", + platform: str = "cisco_ios", ) -> dict[str, Any]: """ Get Nornir group configuration for a specific group. @@ -99,17 +108,19 @@ def _get_nornir_group( Dictionary containing group configuration """ # _get_nornir_groups_config now returns the group config directly - return _get_nornir_groups_config(device_type=device_type, platform=platform) + return _get_nornir_groups_config( + device_type=device_type, platform=platform + ) class ExecuteMultipleDeviceCommands(BaseTool): """ - A READ-ONLY diagnostic tool for viewing network device configurations and protocol states. + A READ-ONLY diagnostic tool for viewing network device configurations. **CRITICAL: DIAGNOSIS ONLY - NO CONFIGURATION PERMISSIONS** - This tool is exclusively designed for read-only operations to inspect and diagnose - network devices. It CANNOT and MUST NOT be used for any configuration changes. + This tool is exclusively designed for read-only operations to inspect and + diagnose network devices. It CANNOT and MUST NOT be used for configuration. **Allowed Command Types:** - Display commands: show, display (Cisco/Huawei) @@ -126,21 +137,21 @@ class ExecuteMultipleDeviceCommands(BaseTool): name: str = "execute_multiple_device_commands" description: str = """ - **READ-ONLY DIAGNOSTIC TOOL** - View network device configurations and protocol states. + **READ-ONLY DIAGNOSTIC TOOL** - View network device configurations. Use this tool to inspect device information without making any changes. **PERMITTED USE CASES:** - - View device status: show version, show running-config, show startup-config + - View device status: show version, show running-config - Check routing: show ip route, show ip ospf neighbor, show bgp summary - Interface status: show ip interface brief, show interfaces - Protocol diagnostics: show ospf database, show bgp routes, debug commands - Connectivity testing: ping, traceroute **STRICTLY FORBIDDEN:** - - NO configuration commands (configure terminal, interface, router, ip address, etc.) + - NO configuration commands (configure terminal, interface, router, etc.) - NO commands that modify device state - - If you need to configure devices, provide guidance to the student instead + - If you need to configure, provide guidance to the student instead **Input Format:** { @@ -167,28 +178,34 @@ class ExecuteMultipleDeviceCommands(BaseTool): **kwargs: Any, ) -> list[dict[str, Any]]: """ - Executes READ-ONLY diagnostic commands on multiple devices in current GNS3 topology. + Executes READ-ONLY diagnostic commands on multiple devices in GNS3. - This method only permits display/show commands and does not allow any configuration - changes to network devices. + This method only permits display/show commands and does not allow + configuration changes to network devices. Args: - tool_input (str): A JSON string containing project_id and diagnostic commands to execute. + tool_input: JSON string with project_id and diagnostic commands. Returns: - List[Dict[str, Any]]: A list of dictionaries containing device names and command outputs. + List[Dict]: A list of dicts with device names and outputs. """ # Log received input logger.debug("Received input: %s", tool_input) # 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 ( + isinstance(device_configs_list, list) + and len(device_configs_list) > 0 + and "error" in device_configs_list[0] + ): return device_configs_list # Filter forbidden commands and store blocked commands info - device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( - device_configs_list + device_configs_list, blocked_commands_map = ( + self._filter_forbidden_commands_from_device_configs( + device_configs_list + ) ) # Create a mapping of device names to their display commands @@ -196,7 +213,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Prepare device hosts data try: - hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id) + hosts_data = self._prepare_device_hosts_data( + device_configs_list, project_id + ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) return [{"error": str(e)}] @@ -218,7 +237,12 @@ class ExecuteMultipleDeviceCommands(BaseTool): ) # Process results for all devices - results = self._process_task_results(device_configs_list, hosts_data, task_result, blocked_commands_map) + results = self._process_task_results( + device_configs_list, + hosts_data, + task_result, + blocked_commands_map, + ) except Exception as e: # Overall execution failed @@ -232,13 +256,17 @@ class ExecuteMultipleDeviceCommands(BaseTool): return results - def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result: - """Execute READ-ONLY diagnostic commands with single retry mechanism.""" + def _run_all_device_configs_with_single_retry( + self, task: Task, device_configs_map: dict[str, list[str]] + ) -> Result: + """Execute READ-ONLY diagnostic commands with single retry.""" device_name = task.host.name diagnostic_commands = device_configs_map.get(device_name, []) if not diagnostic_commands: - return Result(host=task.host, result="No diagnostic commands to execute") + return Result( + host=task.host, result="No diagnostic commands to execute" + ) try: _result = task.run( @@ -258,13 +286,13 @@ class ExecuteMultipleDeviceCommands(BaseTool): ) return Result( host=task.host, - result=f"diagnostic command execution failed (ReadTimeout): {str(e)}", + result=f"diagnostic command failed (ReadTimeout): {str(e)}", failed=True, ) except Exception as e: - # Handle prompt detection issues with Cisco IOSv L2 images where the '#' prompt character - # may be delayed, causing Netmiko prompt detection failures. Implements retry logic. + # Handle Cisco IOSv L2 where '#' prompt char may be delayed, + # causing Netmiko failures. Implements retry logic. if "netmiko_multiline (failed)" in str(e): _result = task.run( task=netmiko_multiline, @@ -276,14 +304,14 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Log any other exceptions with full details logger.error( - "diagnostic command execution failed for device %s: %s (Exception type: %s)", + "diagnostic command failed for device %s: %s (Exception: %s)", device_name, str(e), type(e).__name__, ) return Result( host=task.host, - result=f"diagnostic command execution failed (Unhandled Exception): {str(e)}", + result=f"diagnostic command failed (Unhandled): {str(e)}", failed=True, ) @@ -293,14 +321,14 @@ class ExecuteMultipleDeviceCommands(BaseTool): """ Validate diagnostic command input for read-only device inspection. - Handles both new and legacy input formats. Supports new format with project_id - and device_configs, as well as legacy array format. + Handles both new and legacy input formats. Supports new format with + project_id and device_configs, as well as legacy array format. Args: - tool_input: The input received from the LangChain/LangGraph tool call. + tool_input: Input from LangChain/LangGraph tool call. Returns: - Tuple containing (device_configs_list, project_id) or (error_list, None) + Tuple of (device_configs_list, project_id) or (error_list, None) """ parsed_input = None @@ -308,18 +336,26 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Compatibility Check and Parsing --- # Check if the input is a string (or bytes) which needs to be parsed. if isinstance(tool_input, (str, bytes, bytearray)): - # Handle models (like potentially DeepSeek) that return a raw JSON string. + # Handle models (like DeepSeek) that return a raw JSON string. try: parsed_input = json.loads(tool_input) logger.info("Successfully parsed tool input from JSON string.") except json.JSONDecodeError as e: - logger.error("Invalid JSON string received as tool input: %s", e) - return ([{"error": f"Invalid JSON string input from model: {e}"}], None) + logger.error( + "Invalid JSON string received as tool input: %s", e + ) + return ( + [{"error": f"Invalid JSON string input from model: {e}"}], + None, + ) else: # Handle standard models (like GPT/OpenAI) where the framework # has already parsed the JSON into a Python object (dict or list). parsed_input = tool_input - logger.info("Using tool input directly as type: %s", type(parsed_input).__name__) + logger.info( + "Using tool input directly as type: %s", + type(parsed_input).__name__, + ) # Handle new format: {"project_id": "...", "device_configs": [...]} if isinstance(parsed_input, dict): @@ -333,7 +369,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): return ([{"error": error_msg}], None) if not self._validate_project_id(project_id): - error_msg = f"Invalid project_id format: {project_id}. Expected UUID format." + error_msg = f"Invalid project_id: {project_id}. Expected UUID." logger.error(error_msg) return ([{"error": error_msg}], None) @@ -351,13 +387,15 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Handle legacy format: [...] elif isinstance(parsed_input, list): - logger.warning("Using legacy input format without project_id. Please use new format with project_id.") + logger.warning( + "Legacy input format without project_id. Use new format." + ) return parsed_input, None else: error_msg = ( - "Tool input must be a JSON object with 'project_id' and 'device_configs' fields, " - f"or a legacy JSON array, but got {type(parsed_input).__name__}" + "Tool input must be JSON with project_id and device_configs, " + f"or legacy JSON array, got {type(parsed_input).__name__}" ) logger.error(error_msg) return ([{"error": error_msg}], None) @@ -372,7 +410,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): Returns: True if valid UUID format, False otherwise """ - uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + uuid_pattern = ( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) def _filter_forbidden_commands_from_device_configs( @@ -385,9 +425,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): device_configs_list: List of device configurations with commands. Returns: - A tuple of (filtered_device_configs_list, blocked_commands_map): - - filtered_device_configs_list: Device configs with forbidden commands removed. - - blocked_commands_map: Dict mapping device names to their blocked commands info. + Tuple of (filtered_configs, blocked_map): + - filtered_configs: Device configs with forbidden removed. + - blocked_map: Dict mapping device names to blocked commands. """ filtered_list = [] blocked_commands_map: dict[str, dict[str, str]] = {} @@ -397,7 +437,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): commands = device_config["commands"] # Filter commands - allowed_commands, blocked_info = filter_forbidden_commands(commands) + allowed_commands, blocked_info = filter_forbidden_commands( + commands + ) # Update device config with allowed commands only filtered_config = device_config.copy() @@ -416,7 +458,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): return filtered_list, blocked_commands_map - def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]: + def _configs_map( + self, device_config_list: list[dict[str, Any]] + ) -> dict[str, list[str]]: """Create a mapping of device names to their diagnostic commands.""" device_diagnostic_map = {} for device_config in device_config_list: @@ -427,19 +471,24 @@ class ExecuteMultipleDeviceCommands(BaseTool): return device_diagnostic_map def _prepare_device_hosts_data( - self, device_config_list: list[dict[str, Any]], project_id: str | None = None + self, + device_config_list: list[dict[str, Any]], + project_id: str | None = None, ) -> dict[str, dict[str, Any]]: """Prepare device hosts data from topology information.""" # Extract device names list - device_names = [device_config["device_name"] for device_config in device_config_list] + device_names = [ + device_config["device_name"] + for device_config in device_config_list + ] # Get device port information with project_id hosts_data = get_device_ports_from_topology(device_names, project_id) if not hosts_data: error_msg = ( - f"Failed to get device information from topology or no valid devices found. " - f"Project ID: {project_id}, Devices: {device_names}" + f"Failed to get device info from topology. " + f"Project: {project_id}, Devices: {device_names}" ) raise ValueError(error_msg) @@ -454,7 +503,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): return hosts_data - def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir: + def _initialize_nornir( + self, hosts_data: dict[str, dict[str, Any]] + ) -> Nornir: """Initialize Nornir with the provided hosts data.""" try: # Extract device_type and platform from hosts_data @@ -464,14 +515,21 @@ class ExecuteMultipleDeviceCommands(BaseTool): if hosts_data: first_device_data = next(iter(hosts_data.values()), {}) - device_type = first_device_data.get("device_type", "cisco_ios_telnet") + device_type = first_device_data.get( + "device_type", "cisco_ios_telnet" + ) platform = first_device_data.get("platform", "cisco_ios") - logger.info("Extracted from tags: device_type=%s, platform=%s", device_type, platform) + logger.info( + "Extracted from tags: device_type=%s, platform=%s", + device_type, + platform, + ) - # Get latest environment configuration with dynamic device_type and platform + # Get environment config with dynamic device_type and platform groups_data = _get_nornir_groups_config( - device_type=device_type or "cisco_ios_telnet", platform=platform or "cisco_ios" + device_type=device_type or "cisco_ios_telnet", + platform=platform or "cisco_ios", ) defaults = _get_nornir_defaults() @@ -479,7 +537,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): gns3_host = get_gns3_server_host() logger.info( - "Initializing Nornir with account: host=%s, platform=%s, timeout=%d", + "Initializing Nornir: host=%s, platform=%s, timeout=%d", gns3_host, groups_data.get("platform"), groups_data.get("timeout"), @@ -524,11 +582,16 @@ class ExecuteMultipleDeviceCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": (f"Device '{device_name}' not found in topology or missing console_port"), + "error": ( + f"Device '{device_name}' not found in topology " + "or missing console_port" + ), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -538,11 +601,15 @@ class ExecuteMultipleDeviceCommands(BaseTool): device_result = { "device_name": device_name, "status": "failed", - "error": (f"Device '{device_name}' not found in task results"), + "error": ( + f"Device '{device_name}' not found in task results" + ), } # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -554,7 +621,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): if multi_result[0].failed: # Execution failed device_result["status"] = "failed" - device_result["error"] = f"Diagnostic command execution failed: {multi_result[0].result}" + device_result["error"] = ( + f"Diagnostic command failed: {multi_result[0].result}" + ) device_result["output"] = multi_result[0].result else: # Execution successful @@ -564,9 +633,11 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Add blocked commands info if any if blocked_commands_info: - device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) device_result["blocked_info"] = blocked_commands_info - # Update status if some commands were blocked but execution succeeded + # Update status if some commands were blocked but succeeded if device_result["status"] == "success": device_result["status"] = "partial_success" diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py index 3703213c1..ca081b389 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py @@ -65,7 +65,7 @@ class GNS3LinkTool(BaseTool): - `port1` (str): Port name of the first node (e.g., 'Ethernet0/0'). - `node_id2` (str): UUID of the second node. - `port2` (str): Port name of the second node (e.g., 'Ethernet0/0'). - Note: Port names must match those retrieved from the `gns3_topology_reader` tool. + Note: Port names must match those from `gns3_topology_reader` tool. Example Input: { @@ -79,9 +79,9 @@ class GNS3LinkTool(BaseTool): } ] } - Output: A list of dictionaries, each containing: - - For successfule links: link_id(str),node_id1(str),port1(str),port1(str),node_id(str),port2(str). - - For failed links: error(str) with an error message(e.g., 'Missing required field: project_id') + Output: A list of dicts, each containing: + - For successful links: link_id, node_id1, port1, node_id2, port2. + - For failed links: error(str) with error message. Example Output: [ @@ -98,16 +98,20 @@ class GNS3LinkTool(BaseTool): ] """ - def _run(self, tool_input: str, run_manager: CallbackManagerForToolRun | None = None) -> list[dict[str, Any]]: + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> list[dict[str, Any]]: """ Creates one or multiple links between nodes in a GNS3 project. Args: - tool_input (str): A JSON string containing project_id and links array. + tool_input: JSON string with project_id and links array. run_manager: LangChain run manager (unused). Returns: - list: A list containing dictionaries with created link details or error messages. + list: A list with created link details or error messages. """ # Log received input logger.info("Received input: %s", tool_input) @@ -125,7 +129,9 @@ class GNS3LinkTool(BaseTool): if not isinstance(links_data, list) or len(links_data) == 0: logger.error("Invalid links data: must be a non-empty array") - return [{"error": "Invalid links data: must be a non-empty array"}] + return [ + {"error": "Invalid links data: must be a non-empty array"} + ] # Initialize Gns3Connector using factory function logger.info("Connecting to GNS3 server...") @@ -133,7 +139,14 @@ class GNS3LinkTool(BaseTool): if gns3_server is None: logger.error("Failed to create GNS3 connector") - return [{"error": "Failed to connect to GNS3 server. Please check your configuration."}] + return [ + { + "error": ( + "Failed to connect to GNS3 server. " + "Please check your configuration." + ) + } + ] created_links = [] @@ -150,14 +163,20 @@ class GNS3LinkTool(BaseTool): # Validate link parameters if not all([node_id1, port1, node_id2, port2]): - error_msg = f"Missing required fields in link definition {i}" + error_msg = ( + f"Missing required fields in link definition {i}" + ) logger.error(error_msg) created_links.append({"error": error_msg}) continue # Get node details - node1 = gns3_server.get_node(project_id=project_id, node_id=node_id1) - node2 = gns3_server.get_node(project_id=project_id, node_id=node_id2) + node1 = gns3_server.get_node( + project_id=project_id, node_id=node_id1 + ) + node2 = gns3_server.get_node( + project_id=project_id, node_id=node_id2 + ) if not node1 or not node2: error_msg = f"Node not found in link {i}" logger.error(error_msg) @@ -166,11 +185,19 @@ class GNS3LinkTool(BaseTool): # Find port information port1_info = next( - (port for port in node1.get("ports", []) if port.get("name") == port1), + ( + port + for port in node1.get("ports", []) + if port.get("name") == port1 + ), None, ) port2_info = next( - (port for port in node2.get("ports", []) if port.get("name") == port2), + ( + port + for port in node2.get("ports", []) + if port.get("name") == port2 + ), None, ) if not port1_info or not port2_info: @@ -186,14 +213,22 @@ class GNS3LinkTool(BaseTool): nodes=[ { "node_id": node_id1, - "adapter_number": port1_info.get("adapter_number", 0), - "port_number": port1_info.get("port_number", 0), + "adapter_number": port1_info.get( + "adapter_number", 0 + ), + "port_number": port1_info.get( + "port_number", 0 + ), "label": {"text": port1}, }, { "node_id": node_id2, - "adapter_number": port2_info.get("adapter_number", 0), - "port_number": port2_info.get("port_number", 0), + "adapter_number": port2_info.get( + "adapter_number", 0 + ), + "port_number": port2_info.get( + "port_number", 0 + ), "label": {"text": port2}, }, ], @@ -217,7 +252,9 @@ class GNS3LinkTool(BaseTool): created_links.append({"error": error_msg}) # Log final results - success_count = len([link for link in created_links if "error" not in link]) + success_count = len( + [link for link in created_links if "error" not in link] + ) logger.info( "Link creation completed: %d successful, %d failed", success_count, diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py index dd7586f15..62580b28f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py @@ -51,7 +51,7 @@ class GNS3CreateNodeTool(BaseTool): using specified templates and coordinates. **Input:** - A JSON object containing the project_id and an array of nodes with template_id, + A JSON object with project_id and array of nodes with template_id, x and y coordinates. Example input: @@ -92,13 +92,13 @@ class GNS3CreateNodeTool(BaseTool): "successful_nodes": 2, "failed_nodes": 0 } - If an error occurs during input validation, returns a dictionary with an error message. + If error occurs during validation, returns dict with error message. """ name: str = "create_gns3_node" description: str = """ - Creates multiple nodes in a GNS3 project using specified templates and coordinates. - Input is a JSON object with project_id and an array of nodes, each containing template_id, x, and y coordinates. + Creates multiple nodes in a GNS3 project using templates and coordinates. + Input is a JSON object with project_id and array of nodes. Example input: { "project_id": "uuid-of-project", @@ -115,10 +115,13 @@ class GNS3CreateNodeTool(BaseTool): } ] } - IMPORTANT: Ensure the distance between any two nodes is greater than 250 pixels. - This spacing is necessary to display interface numbers clearly for better topology visualization. - Returns a dictionary with creation results for all nodes, including success/failure status. - If the operation fails during input validation, returns a dictionary with an error message. + IMPORTANT: Ensure distance between any two nodes is greater than 250 px. + This spacing is necessary to display interface numbers clearly for better + topology visualization. + Returns a dictionary with creation results for all nodes, including + success/failure status. + If the operation fails during input validation, returns a dictionary with + an error message. """ def _run( @@ -128,14 +131,15 @@ class GNS3CreateNodeTool(BaseTool): **kwargs: Any, ) -> dict[str, Any]: """ - Creates multiple nodes in a GNS3 project using the provided templates and coordinates. + Creates nodes in a GNS3 project with templates and coordinates. Args: - tool_input (str): A JSON string containing project_id and an array of nodes. + tool_input: A JSON string with project_id and an array of nodes. run_manager: LangChain run manager (unused). Returns: - dict: A dictionary with creation results for all nodes or an error message. + dict: A dictionary with creation results for all nodes or an error + message. """ # Log received input logger.info("Received input: %s", tool_input) @@ -158,7 +162,9 @@ class GNS3CreateNodeTool(BaseTool): # Validate each node in the array for i, node_data in enumerate(nodes): if not isinstance(node_data, dict): - logger.error("Invalid input: Node %d must be a dictionary.", i + 1) + logger.error( + "Invalid input: Node %d must be a dictionary.", i + 1 + ) return {"error": f"Node {i + 1} must be a dictionary."} template_id = node_data.get("template_id") @@ -173,10 +179,14 @@ class GNS3CreateNodeTool(BaseTool): ] ): logger.error( - "Invalid input: Node %d missing or invalid template_id, x, or y.", + "Invalid input: Node %d missing or invalid " + "template_id, x, or y.", i + 1, ) - return {"error": f"Node {i + 1} missing or invalid template_id, x, or y."} + return { + "error": f"Node {i + 1} missing or invalid " + f"template_id, x, or y." + } # Initialize Gns3Connector using factory function logger.info("Connecting to GNS3 server...") @@ -184,10 +194,15 @@ class GNS3CreateNodeTool(BaseTool): if gns3_server is None: logger.error("Failed to create GNS3 connector") - return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } # Create nodes - logger.info("Creating %d nodes in project %s...", len(nodes), project_id) + logger.info( + "Creating %d nodes in project %s...", len(nodes), project_id + ) results: list[dict[str, Any]] = [] for i, node_data in enumerate(nodes): @@ -197,7 +212,7 @@ class GNS3CreateNodeTool(BaseTool): y = node_data.get("y") logger.info( - "Creating node %d/%d with template %s at coordinates (%s, %s)...", + "Creating node %d/%d with template %s at (%s, %s)...", i + 1, len(nodes), template_id, @@ -235,8 +250,12 @@ class GNS3CreateNodeTool(BaseTool): # Continue with next node even if one fails # Calculate summary statistics - successful_nodes = len([r for r in results if r.get("status") == "success"]) - failed_nodes = len([r for r in results if r.get("status") == "failed"]) + successful_nodes = len( + [r for r in results if r.get("status") == "success"] + ) + failed_nodes = len( + [r for r in results if r.get("status") == "failed"] + ) # Prepare final result final_result = { @@ -249,7 +268,7 @@ class GNS3CreateNodeTool(BaseTool): # Log the final result logger.info( - "Node creation completed: %d successful, %d failed out of %d total nodes.", + "Node creation completed: %d successful, %d failed, %d total.", successful_nodes, failed_nodes, len(nodes), @@ -263,22 +282,27 @@ class GNS3CreateNodeTool(BaseTool): return {"error": f"Invalid JSON input: {e}"} except Exception as e: logger.error("Failed to process node creation request: %s", e) - return {"error": f"Failed to process node creation request: {str(e)}"} + return { + "error": f"Failed to process node creation request: {str(e)}" + } if __name__ == "__main__": # Test the tool locally with multiple nodes test_input = json.dumps( { - "project_id": "d7fc094c-685e-4db1-ac11-5e33a1b2e066", # Replace with actual project UUID + # TODO: Replace with actual project UUID + "project_id": "d7fc094c-685e-4db1-ac11-5e33a1b2e066", "nodes": [ { - "template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7", # Replace with actual template UUID + # TODO: Replace with actual template UUID + "template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7", "x": 100, "y": -200, }, { - "template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7", # Replace with actual template UUID + # TODO: Replace with actual template UUID + "template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7", "x": 200, "y": -300, }, diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py index ff0306416..bcfff1bb0 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py @@ -46,39 +46,23 @@ logger = logging.getLogger(__name__) class GNS3TemplateTool(BaseTool): """ - A LangChain tool to retrieve all available device templates from a GNS3 server. - The tool connects to the GNS3 server and extracts the name, template_id, and template_type - for each template. + LangChain tool to retrieve available device templates from GNS3 server. + Connects to GNS3 server and extracts name, template_id, and template_type. **Input:** - No input is required for this tool. It connects to the GNS3 server at the default URL - (http://localhost:3080) and retrieves all templates. + No input required. Connects to GNS3 server at default URL. **Output:** - A dictionary containing a list of dictionaries, each with the name, template_id, and - template_type of a template. Example output: - { - "templates": [ - {"name": "Router1", "template_id": "uuid1", "template_type": "qemu"}, - {"name": "Switch1", "template_id": "uuid2", "template_type": "ethernet_switch"} - ] - } - If an error occurs, returns a dictionary with an error message. + Dict with list of dicts (name, template_id, template_type). + If error, returns dict with error message. """ name: str = "get_gns3_templates" description: str = """ - Retrieves all available device templates from a GNS3 server. - Returns a dictionary containing a list of dictionaries, each with the name, template_id, - and template_type of a template. No input is required. - Example output: - { - "templates": [ - {"name": "Router1", "template_id": "uuid1", "template_type": "qemu"}, - {"name": "Switch1", "template_id": "uuid2", "template_type": "ethernet_switch"} - ] - } - If the connection fails, returns a dictionary with an error message. + Retrieves available device templates from GNS3 server. + Returns dict with list of dicts (name, template_id, template_type). + No input required. + If connection fails, returns dict with error message. """ def _run( @@ -87,14 +71,14 @@ class GNS3TemplateTool(BaseTool): run_manager: CallbackManagerForToolRun | None = None, ) -> dict[str, Any]: """ - Connects to the GNS3 server and retrieves a list of all available device templates. + Connects to GNS3 server and retrieves available device templates. Args: - tool_input (str): Optional input (not used in this tool). + tool_input: Optional input (not used). run_manager: LangChain run manager (unused). Returns: - dict: A dictionary containing the list of templates or an error message. + dict: Dict with templates list or error message. """ try: # Initialize Gns3Connector using factory function @@ -103,7 +87,12 @@ class GNS3TemplateTool(BaseTool): if gns3_server is None: logger.error("Failed to create GNS3 connector") - return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + return { + "error": ( + "Failed to connect to GNS3 server. " + "Please check your configuration." + ) + } # Retrieve all available templates templates = gns3_server.get_templates() @@ -120,14 +109,16 @@ class GNS3TemplateTool(BaseTool): # Return JSON-formatted result with full logging result = {"templates": template_info} logger.info( - "Template retrieval completed. Total templates: %d. Result: %s", + "Template retrieval completed. Total: %d. Result: %s", len(template_info), json.dumps(result, indent=2, ensure_ascii=False), ) return result except Exception as e: - logger.error("Failed to connect to GNS3 server or retrieve templates: %s", e) + logger.error( + "Failed to connect to GNS3 server or retrieve templates: %s", e + ) return {"error": f"Failed to retrieve templates: {str(e)}"} diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py index e6a20210c..64ca4c80e 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py @@ -46,7 +46,9 @@ from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector logger = logging.getLogger(__name__) -def show_progress_bar(duration: int = 120, interval: int = 1, node_count: int = 1) -> None: +def show_progress_bar( + duration: int = 120, interval: int = 1, node_count: int = 1 +) -> None: """ Display a simple text progress bar for node startup. @@ -63,7 +65,9 @@ def show_progress_bar(duration: int = 120, interval: int = 1, node_count: int = # Create progress bar display bar_length = 30 filled_length = int(bar_length * elapsed // duration) - progress_string = "=" * filled_length + ">" + " " * (bar_length - filled_length - 1) + progress_string = ( + "=" * filled_length + ">" + " " * (bar_length - filled_length - 1) + ) # Print progress bar with node count print(f"\r[{progress_string}] {progress:.1f}%", end="", flush=True) @@ -102,10 +106,14 @@ class GNS3StartNodeTool(BaseTool): description: str = """ Starts one or multiple nodes in a GNS3 project. Input: JSON with project_id and node_ids (list of node IDs). - Returns: A dictionary with all nodes' details including success/failure status. + Returns: A dict with all nodes' details (success/failure status). """ - def _run(self, tool_input: str, run_manager: CallbackManagerForToolRun | None = None) -> dict[str, Any]: + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: try: # Parse input JSON input_data = json.loads(tool_input) @@ -114,8 +122,13 @@ class GNS3StartNodeTool(BaseTool): # Validate input if not project_id or not node_ids: - logger.error("Missing required fields: project_id or node_ids.") - return {"error": "Missing required fields: project_id and node_ids."} + logger.error( + "Missing required fields: project_id or node_ids." + ) + return { + "error": "Missing required fields: " + "project_id and node_ids." + } if not isinstance(node_ids, list): logger.error("node_ids must be a list.") @@ -127,7 +140,10 @@ class GNS3StartNodeTool(BaseTool): if gns3_server is None: logger.error("Failed to create GNS3 connector") - return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } # First loop: Send start commands for all nodes logger.info( @@ -137,31 +153,49 @@ class GNS3StartNodeTool(BaseTool): ) for node_id in node_ids: try: - node = Node(project_id=project_id, node_id=node_id, connector=gns3_server) + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) # Verify node exists node.get() if node.node_id: node.start() logger.info("Start command sent for node %s", node_id) else: - logger.error("Node %s not found in project %s", node_id, project_id) + logger.error( + "Node %s not found in project %s", + node_id, + project_id, + ) except Exception as e: - logger.error("Failed to send start command for node %s: %s", node_id, e) + logger.error( + "Failed to send start command for node %s: %s", + node_id, + e, + ) - # Calculate progress bar duration: 140s base + 10s per additional node + # Progress bar duration: 140s base + 10s per extra node base_duration = 140 extra_duration = max(0, len(node_ids) - 1) * 10 total_duration = base_duration + extra_duration # Show progress bar - show_progress_bar(duration=total_duration, interval=1, node_count=len(node_ids)) + show_progress_bar( + duration=total_duration, interval=1, node_count=len(node_ids) + ) # Second loop: Get status for all nodes results = [] logger.info("Retrieving status for %d nodes...", len(node_ids)) for node_id in node_ids: try: - node = Node(project_id=project_id, node_id=node_id, connector=gns3_server) + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) node.get() # Get latest status node_info = { "node_id": node.node_id, @@ -170,7 +204,9 @@ class GNS3StartNodeTool(BaseTool): } results.append(node_info) except Exception as e: - logger.error("Failed to get status for node %s: %s", node_id, e) + logger.error( + "Failed to get status for node %s: %s", node_id, e + ) results.append( { "node_id": node_id, @@ -181,7 +217,9 @@ class GNS3StartNodeTool(BaseTool): ) # Analyze results - successful_nodes = [r for r in results if r.get("status") != "error"] + successful_nodes = [ + r for r in results if r.get("status") != "error" + ] failed_nodes = [r for r in results if r.get("status") == "error"] # Construct final response @@ -211,7 +249,7 @@ class GNS3StartNodeTool(BaseTool): class GNS3StartNodeQuickTool(BaseTool): """ - A LangChain tool to start one or multiple nodes in a GNS3 project WITHOUT waiting. + A LangChain tool to start nodes in a GNS3 project WITHOUT waiting. This tool sends start commands to all nodes and immediately returns status, without blocking for startup completion. Suitable for automated deployment @@ -226,7 +264,7 @@ class GNS3StartNodeQuickTool(BaseTool): } **Output**: - A dictionary with all nodes' details immediately after sending start commands: + A dict with nodes' details immediately after sending start commands: { "project_id": "...", "total_nodes": 2, @@ -242,14 +280,18 @@ class GNS3StartNodeQuickTool(BaseTool): name: str = "start_gns3_node_quick" description: str = """ - Starts one or multiple nodes in a GNS3 project WITHOUT waiting for startup completion. + Starts nodes in a GNS3 project WITHOUT waiting for startup completion. Use this for automated deployments to avoid HTTP timeouts. Input: JSON with project_id and node_ids (list of node IDs). - Returns: Dictionary with nodes' details after start commands are sent. + Returns: Dict with nodes' details after start commands are sent. NOTE: Nodes will continue booting in background after this tool returns. """ - def _run(self, tool_input: str, run_manager: CallbackManagerForToolRun | None = None) -> dict[str, Any]: + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: try: # Parse input JSON input_data = json.loads(tool_input) @@ -258,8 +300,13 @@ class GNS3StartNodeQuickTool(BaseTool): # Validate input if not project_id or not node_ids: - logger.error("Missing required fields: project_id or node_ids.") - return {"error": "Missing required fields: project_id and node_ids."} + logger.error( + "Missing required fields: project_id or node_ids." + ) + return { + "error": "Missing required fields: " + "project_id and node_ids." + } if not isinstance(node_ids, list): logger.error("node_ids must be a list.") @@ -271,7 +318,10 @@ class GNS3StartNodeQuickTool(BaseTool): if gns3_server is None: logger.error("Failed to create GNS3 connector") - return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } # Send start commands for all nodes and collect initial status logger.info( @@ -283,11 +333,19 @@ class GNS3StartNodeQuickTool(BaseTool): for node_id in node_ids: try: - node = Node(project_id=project_id, node_id=node_id, connector=gns3_server) + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) # Verify node exists and get current info node.get() if not node.node_id: - logger.error("Node %s not found in project %s", node_id, project_id) + logger.error( + "Node %s not found in project %s", + node_id, + project_id, + ) results.append( { "node_id": node_id, @@ -300,9 +358,13 @@ class GNS3StartNodeQuickTool(BaseTool): # Send start command node.start() - logger.info("Start command sent for node %s (%s)", node_id, node.name) + logger.info( + "Start command sent for node %s (%s)", + node_id, + node.name, + ) - # Get immediate status (will likely still be 'starting' or 'stopped') + # Get immediate status (likely 'starting' or 'stopped') node.get() node_info = { "node_id": node.node_id, @@ -323,7 +385,9 @@ class GNS3StartNodeQuickTool(BaseTool): ) # Analyze results (count based on successful command sending) - successful_nodes = [r for r in results if r.get("status") != "error"] + successful_nodes = [ + r for r in results if r.get("status") != "error" + ] failed_nodes = [r for r in results if r.get("status") == "error"] # Construct final response @@ -333,7 +397,10 @@ class GNS3StartNodeQuickTool(BaseTool): "successful": len(successful_nodes), "failed": len(failed_nodes), "nodes": results, - "note": "Start commands sent. Nodes are booting in background. Check node status later.", + "note": ( + "Start commands sent. Nodes are booting in background. " + "Check node status later." + ), } logger.info( @@ -358,7 +425,9 @@ if __name__ == "__main__": test_input_single = json.dumps( { "project_id": "", # Replace with actual project UUID - "node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID + "node_ids": [ + "fbeda109-9a74-4d8c-a749-cc3847911a90" + ], # Replace with actual node UUID } ) tool = GNS3StartNodeTool() @@ -371,7 +440,8 @@ if __name__ == "__main__": { "project_id": "", # Replace with actual project UUID "node_ids": [ - "fbeda109-9a74-4d8c-a749-cc3847911a90", # Replace with actual node UUIDs + "fbeda109-9a74-4d8c-a749-cc3847911a90", + # Replace with actual node UUIDs "another-node-uuid-here", "third-node-uuid-here", ], diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py index bf8253320..eca74795f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py @@ -26,7 +26,8 @@ GNS3 node name update tool for renaming network devices. -Provides functionality to update the name of one or multiple nodes in GNS3 projects. +Provides functionality to update the name of one or multiple nodes + in GNS3 projects. """ import json @@ -46,10 +47,12 @@ logger = logging.getLogger(__name__) class GNS3UpdateNodeNameTool(BaseTool): """ - A LangChain tool to update the name of one or multiple nodes in a GNS3 project. + A LangChain tool to update the name of one or multiple nodes + in a GNS3 project. **Input**: - A JSON object with project_id and nodes array containing node_id and new_name. + A JSON object with project_id and nodes array containing + node_id and new_name. Example: { "project_id": "uuid-of-project", @@ -67,8 +70,18 @@ class GNS3UpdateNodeNameTool(BaseTool): "successful": 2, "failed": 0, "nodes": [ - {"node_id": "...", "old_name": "...", "new_name": "...", "status": "success"}, - {"node_id": "...", "old_name": "...", "new_name": "...", "status": "success"} + { + "node_id": "...", + "old_name": "...", + "new_name": "...", + "status": "success" + }, + { + "node_id": "...", + "old_name": "...", + "new_name": "...", + "status": "success" + } ] } """ @@ -76,11 +89,17 @@ class GNS3UpdateNodeNameTool(BaseTool): name: str = "update_gns3_node_name" description: str = """ Updates the name of one or multiple nodes in a GNS3 project. - Input: JSON with project_id and nodes array. Each node must have node_id and new_name. - Returns: A dictionary with all nodes' update results including success/failure status. + Input: JSON with project_id and nodes array. + Each node must have node_id and new_name. + Returns: A dictionary with all nodes' update results + including success/failure status. """ - def _run(self, tool_input: str, run_manager: CallbackManagerForToolRun | None = None) -> dict[str, Any]: + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: try: # Parse input JSON input_data = json.loads(tool_input) @@ -103,7 +122,9 @@ class GNS3UpdateNodeNameTool(BaseTool): return {"error": f"Node {i + 1} must be a dictionary."} if "node_id" not in node_data or "new_name" not in node_data: logger.error("Node %d missing node_id or new_name.", i + 1) - return {"error": f"Node {i + 1} missing node_id or new_name."} + return { + "error": f"Node {i + 1} missing node_id or new_name." + } # Initialize Gns3Connector logger.info("Connecting to GNS3 server...") @@ -111,10 +132,17 @@ class GNS3UpdateNodeNameTool(BaseTool): if gns3_server is None: logger.error("Failed to create GNS3 connector") - return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } # Update node names - logger.info("Updating names for %d nodes in project %s...", len(nodes), project_id) + logger.info( + "Updating names for %d nodes in project %s...", + len(nodes), + project_id, + ) results = [] for i, node_data in enumerate(nodes): @@ -131,7 +159,11 @@ class GNS3UpdateNodeNameTool(BaseTool): ) # Get node to retrieve current name - node = Node(project_id=project_id, node_id=node_id, connector=gns3_server) + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) node.get() old_name = node.name @@ -148,7 +180,11 @@ class GNS3UpdateNodeNameTool(BaseTool): "status": "success", } results.append(node_info) - logger.info("Successfully updated node name: %s -> %s", old_name, new_name) + logger.info( + "Successfully updated node name: %s -> %s", + old_name, + new_name, + ) else: error_info = { "node_id": node_id, @@ -159,7 +195,9 @@ class GNS3UpdateNodeNameTool(BaseTool): "error": "Name verification failed", } results.append(error_info) - logger.error("Failed to update node name for %s", node_id) + logger.error( + "Failed to update node name for %s", node_id + ) except Exception as e: error_info = { @@ -172,7 +210,9 @@ class GNS3UpdateNodeNameTool(BaseTool): logger.error("Failed to update node %d: %s", i + 1, e) # Analyze results - successful_nodes = [r for r in results if r.get("status") == "success"] + successful_nodes = [ + r for r in results if r.get("status") == "success" + ] failed_nodes = [r for r in results if r.get("status") == "failed"] # Construct final response @@ -205,10 +245,12 @@ if __name__ == "__main__": print("=== Testing single node name update ===") test_input_single = json.dumps( { - "project_id": "your-project-uuid", # Replace with actual project UUID + # Replace with actual project UUID + "project_id": "your-project-uuid", "nodes": [ { - "node_id": "your-node-uuid", # Replace with actual node UUID + # Replace with actual node UUID + "node_id": "your-node-uuid", "new_name": "Router1", } ], @@ -222,7 +264,8 @@ if __name__ == "__main__": print("\n=== Testing multiple nodes name update ===") test_input_multiple = json.dumps( { - "project_id": "your-project-uuid", # Replace with actual project UUID + # Replace with actual project UUID + "project_id": "your-project-uuid", "nodes": [ {"node_id": "node-uuid-1", "new_name": "Router1"}, {"node_id": "node-uuid-2", "new_name": "Switch1"}, diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_telnetlib3.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_telnetlib3.py index d81a10621..ed1c22f2d 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_telnetlib3.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_telnetlib3.py @@ -25,7 +25,7 @@ """ Multi-device VPCS command execution tool using telnetlib3 with threading. -Supports concurrent execution of multiple command groups across multiple VPCS devices. +Supports concurrent command execution across VPCS devices. """ import json @@ -47,14 +47,13 @@ logger = logging.getLogger(__name__) class VPCSMultiCommands(BaseTool): """ - A tool for VPCS (Virtual PC Simulator) devices to view PC configurations and test connectivity. + A tool for VPCS devices to view PC configs and test connectivity. **VPCS-SPECIFIC TOOL** - This tool ONLY works with VPCS virtual PC devices. **IMPORTANT DISTINCTION:** - Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs that - simulate basic network functionality. Commands like 'ip' are basic PC IP configuration, - NOT network device configuration. + Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs. + Commands like 'ip' are basic PC IP config, NOT network device config. **Allowed VPCS Commands:** - IP configuration: ip
/ (Basic PC IP setup) @@ -66,19 +65,19 @@ class VPCSMultiCommands(BaseTool): **Usage Context:** This tool is used in lab environments where students need to configure virtual PC IP - addresses and test network connectivity. It does NOT configure network infrastructure. + addresses and test network connectivity. It does NOT configure network infra. """ name: str = "execute_vpcs_multi_commands" description: str = """ **VPCS VIRTUAL PC TOOL** - Configure and test Virtual PC Simulator devices. - This tool ONLY works with VPCS (Virtual PC Simulator) devices, NOT routers/switches. + This tool ONLY works with VPCS devices, NOT routers/switches. **IMPORTANT: VPCS vs Network Devices** - - VPCS = Lightweight virtual PCs for lab testing (NOT network infrastructure) - - 'ip' command on VPCS = Basic PC IP configuration (like 'ipconfig' on Windows) - - This is NOT the same as configuring router interfaces or routing protocols + - VPCS = Lightweight virtual PCs for lab testing (NOT network infra) + - 'ip' command on VPCS = Basic PC IP config (like 'ipconfig' on Windows) + - NOT the same as configuring router interfaces or routing protocols **When to Use This Tool:** - Configure IP addresses on virtual PCs: ip 10.10.0.12/24 10.10.0.254 @@ -102,9 +101,9 @@ class VPCSMultiCommands(BaseTool): ] } - **Returns:** PC command outputs for IP configuration and connectivity testing. + **Returns:** PC command outputs for IP config and connectivity testing. - **Note:** For network devices (Cisco/Huawei routers), use execute_multiple_device_commands instead. + **Note:** For network devices, use execute_multiple_device_commands. """ def _connect_and_execute_commands( @@ -131,11 +130,17 @@ class VPCSMultiCommands(BaseTool): # Check if device has port information if device_name not in device_ports: - logger.warning("Device '%s' not found in topology or missing console port", device_name) + logger.warning( + "Device '%s' not found in topology or missing console port", + device_name, + ) results_list[index] = { "device_name": device_name, "status": "error", - "output": f"Device '{device_name}' not found in topology or missing console port", + "output": ( + f"Device '{device_name}' not found in topology " + "or missing console port" + ), "commands": commands, } return @@ -143,7 +148,12 @@ class VPCSMultiCommands(BaseTool): port = device_ports[device_name]["port"] host = gns3_host - logger.info("Connecting to device '%s' at %s:%d", device_name, host, port) + logger.info( + "Connecting to device '%s' at %s:%d", + device_name, + host, + port, + ) tn = Telnet() try: @@ -197,7 +207,11 @@ class VPCSMultiCommands(BaseTool): ) except Exception as e: - logger.error("Error executing commands on device '%s': %s", device_name, str(e)) + logger.error( + "Error executing commands on device '%s': %s", + device_name, + str(e), + ) results_list[index] = { "device_name": device_name, "status": "error", @@ -218,10 +232,14 @@ class VPCSMultiCommands(BaseTool): Returns: True if valid UUID format, False otherwise """ - uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + uuid_pattern = ( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) is_valid = bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) if not is_valid: - logger.warning("project_id '%s' is not a valid UUID format", project_id) + logger.warning( + "project_id '%s' is not a valid UUID format", project_id + ) return is_valid def _validate_tool_input( @@ -231,10 +249,10 @@ class VPCSMultiCommands(BaseTool): Validate device command input and extract project_id and device_configs. Args: - tool_input: The input received from the LangChain/LangGraph tool call. + tool_input: Input from the LangChain/LangGraph tool call. Returns: - Tuple containing (device_configs_list, project_id) or (error_list, "") + Tuple of (device_configs_list, project_id) or (error_list, "") """ parsed_input = None @@ -247,18 +265,23 @@ class VPCSMultiCommands(BaseTool): parsed_input = json.loads(tool_input) logger.info("Successfully parsed tool input from JSON string.") except json.JSONDecodeError as e: - logger.error("Invalid JSON string received as tool input: %s", e) + logger.error( + "Invalid JSON string received as tool input: %s", e + ) return ([{"error": f"Invalid JSON input: {e}"}], "") else: # Handle standard models where the framework has already parsed the JSON. parsed_input = tool_input - logger.info("Using tool input directly as type: %s", type(parsed_input).__name__) + logger.info( + "Using tool input directly as type: %s", + type(parsed_input).__name__, + ) # Validate input is a dictionary if not isinstance(parsed_input, dict): error_msg = ( - "Tool input must be a JSON object containing 'project_id' and 'device_configs', " - f"but got {type(parsed_input).__name__}" + "Tool input must be a JSON object containing 'project_id' " + f"and 'device_configs', but got {type(parsed_input).__name__}" ) logger.error(error_msg) return ([{"error": error_msg}], "") @@ -272,7 +295,7 @@ class VPCSMultiCommands(BaseTool): # Validate project_id format if not self._validate_project_id(project_id): - error_msg = f"Invalid project_id format: {project_id}. Expected UUID format." + error_msg = f"Invalid project_id format: {project_id}. Expected UUID." logger.error(error_msg) return ([{"error": error_msg}], "") @@ -285,7 +308,10 @@ class VPCSMultiCommands(BaseTool): # Validate device_configs is a list if not isinstance(device_configs, list): - error_msg = f"'device_configs' must be a list, but got {type(device_configs).__name__}" + error_msg = ( + f"'device_configs' must be a list, " + f"but got {type(device_configs).__name__}" + ) logger.error(error_msg) return ([{"error": error_msg}], "") @@ -297,24 +323,32 @@ class VPCSMultiCommands(BaseTool): # Validate each item in device_configs for i, item in enumerate(device_configs): if not isinstance(item, dict): - error_msg = f"Item at index {i} must be a dictionary, got {type(item).__name__}" + error_msg = ( + f"Item at index {i} must be a dictionary, " + f"got {type(item).__name__}" + ) logger.error(error_msg) return ([{"error": error_msg}], "") # Validate required fields in each device config if "device_name" not in item: - error_msg = f"Item at index {i} missing required field 'device_name'" + error_msg = ( + f"Item at index {i} missing required field 'device_name'" + ) logger.error(error_msg) return ([{"error": error_msg}], "") if "commands" not in item: - error_msg = f"Item at index {i} missing required field 'commands'" + error_msg = ( + f"Item at index {i} missing required field 'commands'" + ) logger.error(error_msg) return ([{"error": error_msg}], "") if not isinstance(item["commands"], list): error_msg = ( - f"'commands' in item at index {i} must be a list, " f"but got {type(item['commands']).__name__}" + f"'commands' in item at index {i} must be a list, " + f"but got {type(item['commands']).__name__}" ) logger.error(error_msg) return ([{"error": error_msg}], "") @@ -333,13 +367,14 @@ class VPCSMultiCommands(BaseTool): **kwargs: Any, ) -> list[dict[str, Any]]: """ - Main method to execute commands on multiple VPCS virtual PC devices concurrently. + Execute commands on multiple VPCS virtual PC devices concurrently. - VPCS (Virtual PC Simulator) devices are lightweight virtual machines that simulate - basic PC network functionality for lab testing. This is NOT network device configuration. + VPCS devices are lightweight virtual machines that simulate basic PC network + functionality for lab testing. This is NOT network device configuration. Args: - tool_input: JSON string containing project_id and device_configs with VPCS commands + tool_input: JSON string containing project_id and device_configs with + VPCS commands Returns: List of execution results for each VPCS device @@ -352,14 +387,20 @@ class VPCSMultiCommands(BaseTool): device_configs, project_id = self._validate_tool_input(tool_input) # Check if validation returned an error - if isinstance(device_configs, list) and len(device_configs) > 0 and "error" in device_configs[0]: + if ( + isinstance(device_configs, list) + and len(device_configs) > 0 + and "error" in device_configs[0] + ): return device_configs # Extract all device names from input using set comprehension device_names = {config["device_name"] for config in device_configs} # Get device port mapping with project_id - device_ports = get_device_ports_from_topology(list(device_names), project_id=project_id) + device_ports = get_device_ports_from_topology( + list(device_names), project_id=project_id + ) logger.info( "Retrieved port mappings for %d devices: %s", len(device_ports), @@ -400,7 +441,8 @@ class VPCSMultiCommands(BaseTool): error_count = sum(1 for r in results if r.get("status") == "error") logger.info( - "Multi-device command execution completed. Total: %d, Success: %d, Error: %d", + "Multi-device command execution completed. Total: %d, Success: %d, " + "Error: %d", len(results), success_count, error_count, diff --git a/gns3server/agent/gns3_copilot/utils/__init__.py b/gns3server/agent/gns3_copilot/utils/__init__.py index 983ce3b03..08d1e8b60 100644 --- a/gns3server/agent/gns3_copilot/utils/__init__.py +++ b/gns3server/agent/gns3_copilot/utils/__init__.py @@ -26,7 +26,7 @@ GNS3-Copilot Public Model Package -This package provides reusable public models and utilities for GNS3 network automation tasks. +This package provides reusable public models and utilities for GNS3 automation tasks. It contains common functionality that can be shared across different tools and modules. Main modules: diff --git a/gns3server/agent/gns3_copilot/utils/command_filter.py b/gns3server/agent/gns3_copilot/utils/command_filter.py index 44d8d53e0..7158209bf 100644 --- a/gns3server/agent/gns3_copilot/utils/command_filter.py +++ b/gns3server/agent/gns3_copilot/utils/command_filter.py @@ -32,7 +32,6 @@ availability. """ import logging -import os from pathlib import Path logger = logging.getLogger(__name__) @@ -45,6 +44,7 @@ def _get_gns3_copilot_root() -> Path: # Go up to the gns3_copilot directory (utils parent) return current_file.parent.parent + # Default forbidden commands (fallback if file not found) DEFAULT_FORBIDDEN_COMMANDS = [ "traceroute", @@ -101,7 +101,10 @@ def _load_forbidden_commands() -> list[str]: forbidden_commands.append(line.lower()) if not forbidden_commands: - logger.warning("No forbidden commands found in %s. Using default list.", file_path) + logger.warning( + "No forbidden commands found in %s. Using default list.", + file_path, + ) _forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy() else: logger.info( @@ -135,7 +138,9 @@ def reload_forbidden_commands() -> None: """ global _forbidden_commands_cache _forbidden_commands_cache = None - logger.info("Forbidden commands cache cleared. Will reload on next access.") + logger.info( + "Forbidden commands cache cleared. Will reload on next access." + ) def get_forbidden_commands() -> list[str]: diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index 951f7ba7f..7d9e8d49d 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -57,7 +57,9 @@ def get_device_ports_from_topology( Devices that don't exist or missing console_port will not be included """ # Log received parameters - logger.info("Called with device_names=%s, project_id=%s", device_names, project_id) + logger.info( + "Called with device_names=%s, project_id=%s", device_names, project_id + ) try: # Lazy import to avoid circular dependency @@ -77,7 +79,9 @@ def get_device_ports_from_topology( for device_name in device_names: # Check if device exists in topology if device_name not in topology.get("nodes", {}): - logger.warning("Device '%s' not found in topology", device_name) + logger.warning( + "Device '%s' not found in topology", device_name + ) continue node_info = topology["nodes"][device_name] @@ -99,15 +103,30 @@ def get_device_ports_from_topology( # Use defaults if not found in tags if device_type is None: device_type = "cisco_ios_telnet" - logger.debug("Device '%s': device_type not found in tags, using default: cisco_ios_telnet", device_name) + logger.debug( + "Device '%s': device_type not found in tags, using default: " + "cisco_ios_telnet", + device_name, + ) else: - logger.debug("Device '%s': extracted device_type=%s from tags", device_name, device_type) + logger.debug( + "Device '%s': extracted device_type=%s from tags", + device_name, + device_type, + ) if platform is None: platform = "cisco_ios" - logger.debug("Device '%s': platform not found in tags, using default: cisco_ios", device_name) + logger.debug( + "Device '%s': platform not found in tags, using default: cisco_ios", + device_name, + ) else: - logger.debug("Device '%s': extracted platform=%s from tags", device_name, platform) + logger.debug( + "Device '%s': extracted platform=%s from tags", + device_name, + platform, + ) # Add device to hosts_data hosts_data[device_name] = { diff --git a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py index 4fa48ee7d..8dc21a516 100644 --- a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py +++ b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py @@ -135,9 +135,14 @@ def calculate_two_node_shape( node2_center_x = node2["x"] + (node2_width / 2) node2_center_y = node2["y"] + (node2_height / 2) - distance = math.sqrt((node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2) + distance = math.sqrt( + (node2_center_x - node1_center_x) ** 2 + + (node2_center_y - node1_center_y) ** 2 + ) - angle_rad = math.atan2(node2_center_y - node1_center_y, node2_center_x - node1_center_x) + angle_rad = math.atan2( + node2_center_y - node1_center_y, node2_center_x - node1_center_x + ) angle_deg = round(math.degrees(angle_rad)) angle_rad = math.radians(angle_deg) @@ -157,10 +162,16 @@ def calculate_two_node_shape( shape_width = rx * 2 shape_height = ry * 2 - svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad)) - svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad)) + svg_x = center_x - ( + rx * math.cos(angle_rad) - ry * math.sin(angle_rad) + ) + svg_y = center_y - ( + rx * math.sin(angle_rad) + ry * math.cos(angle_rad) + ) - shape_svg = generate_ellipse_svg(int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)) + shape_svg = generate_ellipse_svg( + int(rx), int(ry), color_scheme, int(shape_width), int(shape_height) + ) offset_distance = ry * text_offset_ratio @@ -177,12 +188,22 @@ def calculate_two_node_shape( else: # rectangle shape_width = distance - shape_height = max(node1_width, node1_height, node2_width, node2_height) + shape_height = max( + node1_width, node1_height, node2_width, node2_height + ) - svg_x = center_x - ((shape_width / 2) * math.cos(angle_rad) - (shape_height / 2) * math.sin(angle_rad)) - svg_y = center_y - ((shape_width / 2) * math.sin(angle_rad) + (shape_height / 2) * math.cos(angle_rad)) + svg_x = center_x - ( + (shape_width / 2) * math.cos(angle_rad) + - (shape_height / 2) * math.sin(angle_rad) + ) + svg_y = center_y - ( + (shape_width / 2) * math.sin(angle_rad) + + (shape_height / 2) * math.cos(angle_rad) + ) - shape_svg = generate_rectangle_svg(int(shape_width), int(shape_height), color_scheme) + shape_svg = generate_rectangle_svg( + int(shape_width), int(shape_height), color_scheme + ) offset_distance = (shape_height / 2) * text_offset_ratio @@ -238,7 +259,7 @@ def generate_ellipse_svg( svg_height: int, ) -> str: """Generate SVG for ellipse.""" - return f"""""" + return f"""""" # noqa: E501 def generate_rectangle_svg( @@ -247,7 +268,7 @@ def generate_rectangle_svg( color_scheme: dict[str, Any], ) -> str: """Generate SVG for rectangle.""" - return f"""""" + return f"""""" # noqa: E501 def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str: @@ -255,7 +276,7 @@ def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str: text_width = len(text) * 8 + 20 text_height = DEFAULT_FONT_SIZE + 16 - return f"""{text}""" + return f"""{text}""" # noqa: E501 def _hsv_to_hex(h: int, s: int, v: int) -> str: @@ -333,15 +354,33 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]: return COLOR_SCHEMES["NORMAL_AREA"] # 3. Logical Isolation - if "VRF" in label or "VLAN" in label or "MSTP" in label or "VXLAN" in label or "MPLS" in label: + if ( + "VRF" in label + or "VLAN" in label + or "MSTP" in label + or "VXLAN" in label + or "MPLS" in label + ): return COLOR_SCHEMES["ISOLATION"] # 4. High Availability - if "VRRP" in label or "HSRP" in label or "HA" in label or "STACK" in label or "M-LAG" in label: + if ( + "VRRP" in label + or "HSRP" in label + or "HA" in label + or "STACK" in label + or "M-LAG" in label + ): return COLOR_SCHEMES["HIGH_AVAILABILITY"] # 5. External/Internet - if "INET" in label or "OUT" in label or "EXTERNAL" in label or "INTERNET" in label or "DMZ" in label: + if ( + "INET" in label + or "OUT" in label + or "EXTERNAL" in label + or "INTERNET" in label + or "DMZ" in label + ): return COLOR_SCHEMES["EXTERNAL"] # 6. Management @@ -353,7 +392,12 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]: return COLOR_SCHEMES["SECURITY_TRUSTED"] # 8. Cloud/Tunnel - if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label: + if ( + "TUNNEL" in label + or "CLOUD" in label + or "GRE" in label + or "IPSEC" in label + ): return COLOR_SCHEMES["CLOUD_TUNNEL"] # Legacy keyword matching for backward compatibility @@ -392,7 +436,9 @@ def calculate_two_node_ellipse( Wrapper around calculate_two_node_shape with shape_type="ellipse". """ - result = calculate_two_node_shape(node1, node2, area_name, "ellipse", text_offset_ratio) + result = calculate_two_node_shape( + node1, node2, area_name, "ellipse", text_offset_ratio + ) return { "ellipse": result["shape"], "text": result["text"], @@ -411,7 +457,9 @@ def calculate_two_node_rectangle( Wrapper around calculate_two_node_shape with shape_type="rectangle". """ - result = calculate_two_node_shape(node1, node2, area_name, "rectangle", text_offset_ratio) + result = calculate_two_node_shape( + node1, node2, area_name, "rectangle", text_offset_ratio + ) metadata = result["metadata"] return { "rectangle": result["shape"], diff --git a/gns3server/agent/gns3_copilot/utils/llm_config_helper.py b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py index 4aab3a72c..568a062cb 100644 --- a/gns3server/agent/gns3_copilot/utils/llm_config_helper.py +++ b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py @@ -30,7 +30,9 @@ This module provides utility functions to retrieve LLM model configurations with decrypted API keys by directly accessing the database. Usage: - from gns3server.agent.gns3_copilot.utils.llm_config_helper import get_user_llm_config_with_app + from gns3server.agent.gns3_copilot.utils.llm_config_helper import ( + get_user_llm_config_with_app, + ) # Get user's default LLM config (with API key) config = await get_user_llm_config_with_app(user_id, app) @@ -51,7 +53,9 @@ from fastapi import FastAPI logger = logging.getLogger(__name__) -async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[Dict[str, Any]]: +async def get_user_llm_config_with_app( + user_id: UUID, app: FastAPI +) -> Optional[Dict[str, Any]]: """ Get user's default LLM model configuration with decrypted API key. @@ -91,5 +95,8 @@ async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[ return config except Exception as e: - logger.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True) + logger.error( + f"Failed to retrieve LLM config for user {user_id}: {e}", + exc_info=True, + ) return None diff --git a/gns3server/agent/gns3_copilot/utils/message_converters.py b/gns3server/agent/gns3_copilot/utils/message_converters.py index d6d9dc2e7..8bf18f668 100644 --- a/gns3server/agent/gns3_copilot/utils/message_converters.py +++ b/gns3server/agent/gns3_copilot/utils/message_converters.py @@ -55,7 +55,8 @@ def convert_langchain_to_openai(lc_message) -> Dict[str, Any]: Convert LangChain message to OpenAI-compatible format. Args: - lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage, ToolMessage) + lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage, + ToolMessage) Returns: Dictionary in OpenAI-compatible format @@ -90,7 +91,10 @@ def convert_langchain_to_openai(lc_message) -> Dict[str, Any]: { "id": tc_dict.get("id", str(uuid.uuid4())), "type": "function", - "function": {"name": tc_dict.get("name", ""), "arguments": tc_dict.get("args", {})}, + "function": { + "name": tc_dict.get("name", ""), + "arguments": tc_dict.get("args", {}), + }, } ) msg["tool_calls"] = tool_calls @@ -149,7 +153,11 @@ def convert_openai_to_langchain(msg: Dict[str, Any]): return ai_msg elif role == "tool": - return ToolMessage(content=content, name=msg.get("name", ""), tool_call_id=msg.get("tool_call_id", "")) + return ToolMessage( + content=content, + name=msg.get("name", ""), + tool_call_id=msg.get("tool_call_id", ""), + ) elif role == "system": return SystemMessage(content=content) @@ -176,7 +184,11 @@ def convert_stream_event_to_openai(event: Dict[str, Any]) -> Dict[str, Any]: content = getattr(chunk, "content", "") if content: - return {"type": "content", "content": content, "message_id": event.get("metadata", {}).get("msg_id")} + return { + "type": "content", + "content": content, + "message_id": event.get("metadata", {}).get("msg_id"), + } # Check for tool call chunks if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks: @@ -191,12 +203,19 @@ def convert_stream_event_to_openai(event: Dict[str, Any]) -> Dict[str, Any]: "tool_call": { "id": tc_id, "type": "function", - "function": {"name": tc_name or "", "arguments": tc_args or ""}, + "function": { + "name": tc_name or "", + "arguments": tc_args or "", + }, }, } elif event_type == "on_tool_start": - return {"type": "tool_start", "tool_name": event.get("name", ""), "metadata": event.get("metadata", {})} + return { + "type": "tool_start", + "tool_name": event.get("name", ""), + "metadata": event.get("metadata", {}), + } elif event_type == "on_tool_end": tool_output = event.get("data", {}).get("output", "") diff --git a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py index 46ee4b7fe..92e60f216 100644 --- a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py +++ b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py @@ -85,12 +85,14 @@ def parse_tool_content( by json.dumps. Args: - content: Content returned by tools (can be str, dict, list, int, float, bool, or None) + content: Content returned by tools (can be str, dict, list, int, float, bool, + or None) fallback_to_raw: Whether to return raw content when parsing fails, default True strict_mode: Strict mode, raises exceptions when parsing fails, default False Returns: - Union[Dict[str, Any], List[Any], Any]: Parsed data that can be serialized by json.dumps: + Union[Dict[str, Any], List[Any], Any]: Parsed data that can be serialized by + json.dumps: - Successfully parsed JSON/Python literal data - Original dict/list objects (passed through) - Primitive types (int, float, bool, str) @@ -158,7 +160,9 @@ def parse_tool_content( # Empty string handling if not content.strip(): result = {} - logger.info("Content is empty or whitespace, returning: %s", result) + logger.info( + "Content is empty or whitespace, returning: %s", result + ) return result s = content.strip() @@ -166,14 +170,19 @@ def parse_tool_content( # Handle empty dictionary case if s == "{}": result = {} - logger.info("Content is empty dictionary, returning: %s", result) + logger.info( + "Content is empty dictionary, returning: %s", result + ) return result # Try to parse as Python literal # (higher priority as many tools return Python format strings) try: result = ast.literal_eval(s) - logger.info("Successfully parsed as Python literal, returning: %s", result) + logger.info( + "Successfully parsed as Python literal, returning: %s", + result, + ) return result except (ValueError, SyntaxError): pass @@ -181,7 +190,9 @@ def parse_tool_content( # Try to parse as JSON try: result = json.loads(s) - logger.info("Successfully parsed as JSON, returning: %s", result) + logger.info( + "Successfully parsed as JSON, returning: %s", result + ) return result except json.JSONDecodeError: pass @@ -210,7 +221,8 @@ def parse_tool_content( # Handle unsupported types error_msg = ( # type: ignore[unreachable] - "Content must be str, dict, list, int, float, bool, or None, got " f"{type(content).__name__}" + "Content must be str, dict, list, int, float, bool, or None, got " + f"{type(content).__name__}" ) logger.error(error_msg) @@ -226,7 +238,9 @@ def parse_tool_content( return result -def format_tool_response(content: str | dict | list | int | float | bool | None, indent: int = 2) -> str: +def format_tool_response( + content: str | dict | list | int | float | bool | None, indent: int = 2 +) -> str: """ Format tool response as a beautiful JSON string for UI display. @@ -234,7 +248,8 @@ def format_tool_response(content: str | dict | list | int | float | bool | None, properly displayed in UI interfaces. Args: - content: Content returned by tools (can be str, dict, list, int, float, bool, or None) + content: Content returned by tools (can be str, dict, list, int, float, bool, + or None) indent: JSON indentation spaces, default 2 Returns: @@ -244,7 +259,9 @@ def format_tool_response(content: str | dict | list | int | float | bool | None, logger.info("format_tool_response parameter indent: %s", indent) try: - parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False) + parsed = parse_tool_content( + content, fallback_to_raw=True, strict_mode=False + ) # Ensure the result can be serialized to JSON result = json.dumps(parsed, ensure_ascii=False, indent=indent) logger.info("format_tool_response returning: %s", result) @@ -253,7 +270,9 @@ def format_tool_response(content: str | dict | list | int | float | bool | None, # If the parsed result cannot be serialized, convert to string and wrap logger.error("Cannot serialize parsed result to JSON: %s", e) try: - result = json.dumps({"raw": str(content)}, ensure_ascii=False, indent=indent) + result = json.dumps( + {"raw": str(content)}, ensure_ascii=False, indent=indent + ) logger.info("format_tool_response returning fallback: %s", result) return result except Exception: @@ -267,12 +286,16 @@ def format_tool_response(content: str | dict | list | int | float | bool | None, return result except Exception as e: logger.error("Error formatting tool response: %s", e) - result = json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent) + result = json.dumps( + {"error": str(e)}, ensure_ascii=False, indent=indent + ) logger.info("format_tool_response returning error: %s", result) return result -def normalize_tool_response(response: dict | list | str, tool_name: str = "unknown") -> dict: +def normalize_tool_response( + response: dict | list | str, tool_name: str = "unknown" +) -> dict: """ Normalize tool response to standard format for consistent frontend display. @@ -298,15 +321,24 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno Examples: >>> normalize_tool_response({"status": "success", "output": "OK"}) - {'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}} + {'success': True, 'total': 1, 'successful': 1, 'failed': 0, + 'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}} >>> normalize_tool_response([{"device_name": "R1", "status": "success"}]) - {'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [...], 'metadata': {}} + {'success': True, 'total': 1, 'successful': 1, 'failed': 0, + 'data': [...], 'metadata': {}} """ - metadata = {"tool_name": tool_name, "normalized_at": datetime.utcnow().isoformat()} + metadata = { + "tool_name": tool_name, + "normalized_at": datetime.utcnow().isoformat(), + } # Handle error responses - if isinstance(response, dict) and "error" in response and len(response) == 1: + if ( + isinstance(response, dict) + and "error" in response + and len(response) == 1 + ): return { "success": False, "total": 0, @@ -319,29 +351,56 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno # Handle empty responses if not response: - return {"success": True, "total": 0, "successful": 0, "failed": 0, "data": [], "metadata": metadata} + return { + "success": True, + "total": 0, + "successful": 0, + "failed": 0, + "data": [], + "metadata": metadata, + } # Handle list responses (most tools return list of device results) if isinstance(response, list): - successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success") + successful = sum( + 1 + for item in response + if isinstance(item, dict) and item.get("status") == "success" + ) failed = len(response) - successful normalized_data = [] for item in response: if isinstance(item, dict): normalized_item = { - "id": item.get("device_id") or item.get("node_id") or item.get("id") or "", + "id": item.get("device_id") + or item.get("node_id") + or item.get("id") + or "", "name": item.get("device_name") or item.get("name") or "", "status": item.get("status", "unknown"), } if normalized_item["status"] == "success": - normalized_item["result"] = item.get("output") or item.get("result") or "" + normalized_item["result"] = ( + item.get("output") or item.get("result") or "" + ) else: - normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error" + normalized_item["error"] = ( + item.get("error") + or item.get("output") + or "Unknown error" + ) normalized_data.append(normalized_item) else: # Non-dict items in list - normalized_data.append({"id": "", "name": "", "status": "unknown", "result": str(item)}) + normalized_data.append( + { + "id": "", + "name": "", + "status": "unknown", + "result": str(item), + } + ) return { "success": failed == 0, @@ -367,8 +426,14 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno } # Legacy format: extract common fields - total = response.get("total_nodes") or response.get("total") or response.get("count", 1) - successful = response.get("successful_nodes") or response.get("successful") or 0 + total = ( + response.get("total_nodes") + or response.get("total") + or response.get("count", 1) + ) + successful = ( + response.get("successful_nodes") or response.get("successful") or 0 + ) failed = response.get("failed_nodes") or response.get("failed") or 0 # Extract data from various possible locations @@ -393,9 +458,13 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno if not data and "status" in response: data = [ { - "name": response.get("device_name") or response.get("name") or "", + "name": response.get("device_name") + or response.get("name") + or "", "status": response["status"], - "result": response.get("output") or response.get("result") or "", + "result": response.get("output") + or response.get("result") + or "", "error": response.get("error") or "", } ] @@ -425,14 +494,23 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno "total": 1, "successful": 1, "failed": 0, - "data": [{"id": "", "name": "", "status": "unknown", "result": str(response)}], + "data": [ + { + "id": "", + "name": "", + "status": "unknown", + "result": str(response), + } + ], "metadata": metadata, } # Test function to verify the implementation def _test_parse_tool_content() -> None: - """Test function to verify parse_tool_content works correctly with all input types""" + """Test function to verify parse_tool_content works correctly with all input + types + """ test_cases: list[tuple[Any, Any]] = [ # String inputs ( @@ -486,7 +564,9 @@ def _test_parse_tool_content() -> None: valid = "✓" except Exception: valid = "✗" - print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}") + print( + f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}" + ) if __name__ == "__main__": diff --git a/gns3server/agent/gns3_copilot/utils/tool_call_stream.py b/gns3server/agent/gns3_copilot/utils/tool_call_stream.py index 4fd5b51a1..8cd89a93e 100644 --- a/gns3server/agent/gns3_copilot/utils/tool_call_stream.py +++ b/gns3server/agent/gns3_copilot/utils/tool_call_stream.py @@ -79,10 +79,11 @@ class ToolCallStreamAccumulator: tc_id = getattr(tool_call, "id", None) tc_name = getattr(tool_call, "name", "") - # Only when ID is not empty, consider it as the start of a new tool call + # Only when ID is not empty, consider it as the start of a new + # tool call if tc_id: - # Initialize current tool state (this is the only time to get ID) - # Note: only one tool can be called at a time + # Initialize current tool state (this is the only time to + # get ID). Note: only one tool can be called at a time self._current_tool_call = { "id": tc_id, "name": tc_name if tc_name else "UNKNOWN_TOOL", @@ -90,19 +91,24 @@ class ToolCallStreamAccumulator: } # Send initial tool_call event with empty args - chunks.append({ - "type": "tool_call", - "tool_call": { - "id": tc_id, - "type": "function", - "function": { - "name": self._current_tool_call["name"], - "arguments": "" - } + chunks.append( + { + "type": "tool_call", + "tool_call": { + "id": tc_id, + "type": "function", + "function": { + "name": self._current_tool_call[ + "name" + ], + "arguments": "", + }, + }, } - }) + ) - # ========== Phase 2: Concatenate parameter strings from tool_call_chunks ========== + # ========== Phase 2: Concatenate parameter strings from + # tool_call_chunks ========== # Concatenate parameter strings from tool_call_chunk if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks: if self._current_tool_call: @@ -116,43 +122,58 @@ class ToolCallStreamAccumulator: # Core: string concatenation if isinstance(args_chunk, str): - tool_data["args_string"] += args_chunk + tool_data[ + "args_string" + ] += args_chunk # Send updated tool_call event with accumulated args - chunks.append({ - "type": "tool_call", - "tool_call": { - "id": tool_data["id"], - "type": "function", - "function": { - "name": tool_data["name"], - "arguments": tool_data["args_string"] - } + chunks.append( + { + "type": "tool_call", + "tool_call": { + "id": tool_data["id"], + "type": "function", + "function": { + "name": tool_data["name"], + "arguments": tool_data[ + "args_string" + ], + }, + }, } - }) + ) - # ========== Phase 3: Determine if tool_calls_chunks output is complete ========== + # ========== Phase 3: Determine if tool_calls_chunks output is + # complete ========== # Check finish_reason == "tool_calls" or "STOP" response_metadata = getattr(chunk, "response_metadata", {}) - finish_reason = response_metadata.get("finish_reason") if isinstance(response_metadata, dict) else None + finish_reason = ( + response_metadata.get("finish_reason") + if isinstance(response_metadata, dict) + else None + ) - if (finish_reason == "tool_calls") or (finish_reason == "stop" and self._current_tool_call is not None): + if (finish_reason == "tool_calls") or ( + finish_reason == "stop" and self._current_tool_call is not None + ): if self._current_tool_call: tool_data = self._current_tool_call # Send final complete tool_call event - chunks.append({ - "type": "tool_call", - "tool_call": { - "id": tool_data["id"], - "type": "function", - "function": { - "name": tool_data["name"], - "arguments": tool_data["args_string"], - "complete": True # Mark as complete - } + chunks.append( + { + "type": "tool_call", + "tool_call": { + "id": tool_data["id"], + "type": "function", + "function": { + "name": tool_data["name"], + "arguments": tool_data["args_string"], + "complete": True, # Mark as complete + }, + }, } - }) + ) # Clear the current tool call state self._current_tool_call = None @@ -160,11 +181,13 @@ class ToolCallStreamAccumulator: # Also handle regular content (when not in tool call mode) content = getattr(chunk, "content", "") if content and not self._current_tool_call: - chunks.append({ - "type": "content", - "content": content, - "message_id": event.get("metadata", {}).get("msg_id") - }) + chunks.append( + { + "type": "content", + "content": content, + "message_id": event.get("metadata", {}).get("msg_id"), + } + ) return chunks