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 <yueguobin@outlook.com>
This commit is contained in:
YueGuobin 2026-03-10 01:05:17 +08:00
parent 25f2d1b56b
commit 7c3b832bca
32 changed files with 1967 additions and 762 deletions

View File

@ -136,26 +136,37 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
if hasattr(tool, "args_schema") and tool.args_schema: if hasattr(tool, "args_schema") and tool.args_schema:
try: try:
# Try Pydantic v2 method (model_json_schema) # 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: except AttributeError:
# Fallback to Pydantic v1 method (schema) # Fallback to Pydantic v1 method (schema)
try: try:
tool_schema["function"]["parameters"] = tool.args_schema.schema() tool_schema["function"]["parameters"] = (
tool.args_schema.schema()
)
except Exception: except Exception:
# Both methods failed, use empty schema # Both methods failed, use empty schema
tool_name = getattr(tool, "name", "unknown")
logger.debug( 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"] = {} tool_schema["function"]["parameters"] = {}
except Exception as e: except Exception as e:
# model_json_schema() raised an exception # model_json_schema() raised an exception
tool_name = getattr(tool, "name", "unknown")
logger.debug( logger.debug(
"model_json_schema() failed for tool %s: %s, trying v1 fallback", "model_json_schema() failed for tool %s: %s, "
getattr(tool, "name", "unknown"), "trying v1 fallback",
tool_name,
e, e,
) )
try: try:
tool_schema["function"]["parameters"] = tool.args_schema.schema() tool_schema["function"]["parameters"] = (
tool.args_schema.schema()
)
except Exception: except Exception:
tool_schema["function"]["parameters"] = {} tool_schema["function"]["parameters"] = {}
@ -165,7 +176,12 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
total_tokens += tokens total_tokens += tokens
except Exception as e: 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 # Rough fallback: 1000 tokens per tool
total_tokens += 1000 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. This function is called by trim_messages for each message.
Args: Args:
message: A single message (HumanMessage, AIMessage, SystemMessage, etc.) message: A single message (HumanMessage, AIMessage, SystemMessage,
etc.)
Returns: Returns:
Estimated token count for the message Estimated token count for the message
@ -206,13 +223,16 @@ def create_pre_model_hook(
Create a pre_model_hook function for LangGraph agent. Create a pre_model_hook function for LangGraph agent.
This hook will be automatically called before each LLM invocation, 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: 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: CORRECT Usage:
model.invoke(messages, config={"configurable": {"pre_model_hook": pre_hook}}) model.invoke(messages,
config={"configurable": {"pre_model_hook": pre_hook}})
INCORRECT Usage: INCORRECT Usage:
# ❌ Don't use as Node return value # ❌ Don't use as Node return value
@ -224,56 +244,69 @@ def create_pre_model_hook(
messages: Annotated[list, add_messages] # Incompatible! messages: Annotated[list, add_messages] # Incompatible!
COMPATIBILITY: 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 as: StateGraph Node return value
- Does NOT work with: Annotated[list, add_messages] state reducers - Does NOT work with: Annotated[list, add_messages] state reducers
The hook returns a complete message list that overwrites the model input, The hook returns a complete message list that overwrites the model
which is correct for invoke() but wrong for state updates with add_messages. input, which is correct for invoke() but wrong for state updates with
add_messages.
Args: 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_topology_func: Function to extract topology from state
get_llm_config_func: Function to get LLM config from context 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: Returns:
A pre_model_hook function for use with model.invoke(config=...) A pre_model_hook function for use with model.invoke(config=...)
Thread Safety: 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: 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) - Overhead: ~1-2ms per call (negligible compared to LLM latency)
- Trade-off: Simplicity > micro-optimization - Trade-off: Simplicity > micro-optimization
- In ReAct loops with multiple LLM calls, topology is re-injected each time - In ReAct loops with multiple LLM calls, topology is re-injected
- This is acceptable for GNS3-Copilot's usage patterns (low concurrency, short conversations) each time
- This is acceptable for GNS3-Copilot's usage patterns (low
concurrency, short conversations)
""" """
def pre_model_hook(state: dict) -> dict: def pre_model_hook(state: dict) -> dict:
""" """
LangGraph pre_model_hook - called before each LLM invocation. LangGraph pre_model_hook - called before each LLM invocation.
This function is NOT a StateGraph Node. It is a preprocessing hook that This function is NOT a StateGraph Node. It is a preprocessing hook
modifies the input to the LLM, not the agent state. that modifies the input to the LLM, not the agent state.
Usage Context: Usage Context:
Called automatically by LangChain when passed via: 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: Args:
state: Current agent state containing messages and topology_info state: Current agent state containing messages and topology_info
Returns: Returns:
dict with 'messages' key containing prepared and trimmed messages dict with 'messages' key containing prepared and trimmed messages
Note: This return value is used by LangChain to replace the model input, Note: This return value is used by LangChain to replace the model
NOT to update the agent state. input, NOT to update the agent state.
Raises: Raises:
ValueError: If context_limit is missing or invalid 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", []) messages = state.get("messages", [])
if not messages: if not messages:
@ -285,11 +318,14 @@ def create_pre_model_hook(
if not llm_config: if not llm_config:
logger.error("LLM config not found. context_limit is required.") 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: if "context_limit" not in llm_config:
logger.error( 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") 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) strategy = llm_config.get("context_strategy", DEFAULT_CONTEXT_STRATEGY)
if strategy not in CONTEXT_STRATEGY_RATIOS: 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 strategy = DEFAULT_CONTEXT_STRATEGY
# Step 1: Estimate tool tokens # Step 1: Estimate tool tokens
@ -311,13 +351,20 @@ def create_pre_model_hook(
try: try:
tools = get_tools_func() tools = get_tools_func()
tool_tokens = estimate_tool_tokens(tools) 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: except Exception as e:
logger.warning("Failed to estimate tool tokens: %s", e) logger.warning("Failed to estimate tool tokens: %s", e)
# Step 2: Inject topology into system prompt # Step 2: Inject topology into system prompt
messages_with_system = _inject_topology_into_system( 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 # 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) system_tokens = _count_tokens_for_message(system_message)
# Calculate tokens for messages_with_system (including system) # 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 # Calculate available budget
model_limit_tokens = context_limit_k * TOKENS_PER_K 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) max_input_tokens = int(model_limit_tokens * strategy_ratio)
# Calculate max tokens for trim_messages # Calculate max tokens for trim_messages
# Important: trim_messages counts ALL messages (including system), so we only # Important: trim_messages counts ALL messages (including system),
# subtract tool_tokens here, NOT system_tokens. Let trim_messages handle 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 max_tokens_for_trim = max_input_tokens - tool_tokens
# Validate budget and provide actionable warnings # 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( logger.error(
"System prompt (%d tokens) + tools (%d tokens) EXCEED input budget (%d tokens). " "System prompt (%d tokens) + tools (%d tokens) EXCEED input "
"This will likely cause LLM call failures. " "budget (%d tokens). This will likely cause LLM call failures. " # noqa: E501
"Recommendations: 1) Reduce system prompt length, 2) Reduce number of tools, " "Recommendations: 1) Reduce system prompt length, 2) Reduce "
"3) Use a model with larger context window, or 4) Switch to 'conservative' strategy.", "number of tools, 3) Use a model with larger context window, "
"or 4) Switch to 'conservative' strategy.",
system_tokens, system_tokens,
tool_tokens, tool_tokens,
max_input_tokens, max_input_tokens,
@ -351,7 +402,8 @@ def create_pre_model_hook(
elif max_tokens_for_trim < system_tokens * 1.5: elif max_tokens_for_trim < system_tokens * 1.5:
# Less than 1.5x system tokens means very little room for history # Less than 1.5x system tokens means very little room for history
logger.warning( 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.", "Consider reducing system prompt length or number of tools.",
system_tokens, system_tokens,
tool_tokens, tool_tokens,
@ -360,7 +412,8 @@ def create_pre_model_hook(
# Debug log with clear terminology # Debug log with clear terminology
logger.debug( 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, system_tokens,
messages_with_system_tokens, messages_with_system_tokens,
tool_tokens, tool_tokens,
@ -370,7 +423,8 @@ def create_pre_model_hook(
) )
# Step 4: Trim messages to fit # 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: try:
trimmed = trim_messages( trimmed = trim_messages(
messages=messages_with_system, messages=messages_with_system,
@ -382,11 +436,14 @@ def create_pre_model_hook(
# Calculate final token counts # Calculate final token counts
final_total = sum(_count_tokens_for_message(m) for m in trimmed) 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): if len(trimmed) < len(messages_with_system):
logger.info( 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(messages_with_system),
len(trimmed), len(trimmed),
final_total, final_total,
@ -398,7 +455,8 @@ def create_pre_model_hook(
) )
else: else:
logger.info( 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), len(trimmed),
final_total, final_total,
tool_tokens, tool_tokens,
@ -449,23 +507,33 @@ def _inject_topology_into_system(
if topology_data: if topology_data:
topology_str = str(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( logger.info(
"✓ Topology injected: %d chars, nodes: %s", "✓ Topology injected: %d chars, nodes: %s",
len(topology_str), len(topology_str),
list(topology_data.get("nodes", {}).keys())[:5], list(topology_data.get("nodes", {}).keys())[:5],
) # Show first 5 node names ) # 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: 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") logger.warning("✗ Topology data is None, injecting placeholder")
# Filter out existing SystemMessage instances # 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) filtered_count = len(messages) - len(non_system_messages)
if filtered_count > 0: 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 return [SystemMessage(content=formatted_prompt)] + non_system_messages
@ -487,7 +555,8 @@ def prepare_context_messages(
**DEPRECATED**: Use create_pre_model_hook() instead. **DEPRECATED**: Use create_pre_model_hook() instead.
""" """
warnings.warn( 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, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
@ -495,9 +564,13 @@ def prepare_context_messages(
if "{{topology_info}}" not in system_prompt: if "{{topology_info}}" not in system_prompt:
formatted_prompt = system_prompt formatted_prompt = system_prompt
elif topology_context: 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: 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 return [SystemMessage(content=formatted_prompt)] + state_messages
@ -543,7 +616,9 @@ if __name__ == "__main__":
# Test invocation # Test invocation
print("\nTest 3: Invoke pre_model_hook") print("\nTest 3: Invoke pre_model_hook")
test_state = { 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}, "topology_info": {"project_id": "test123", "nodes": 3},
} }

View File

@ -39,7 +39,8 @@ The agent provides:
- Integration with GNS3 topology management - Integration with GNS3 topology management
Copilot Modes: 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 - "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 from typing_extensions import TypedDict
# Local imports # 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 ( from gns3server.agent.gns3_copilot.agent.model_factory import (
create_base_model_with_tools, 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 import GNS3TopologyTool
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
get_current_llm_config, get_current_llm_config,
) )
from gns3server.agent.gns3_copilot.prompts import TITLE_PROMPT from gns3server.agent.gns3_copilot.prompts import TITLE_PROMPT
from gns3server.agent.gns3_copilot.prompts import load_system_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 (
from gns3server.agent.gns3_copilot.tools_v2 import ExecuteMultipleDeviceConfigCommands 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 GNS3CreateNodeTool
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool 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 # Set up logger for GNS3-Copilot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Note: LLM model configuration is now managed by the new llm_model_configs system. # Note: LLM model configuration is now managed by the new llm_model_configs
# The model_factory module handles model creation with configuration from the database. # system. The model_factory module handles model creation with configuration
# from the database.
# Define tools for different copilot modes # Define tools for different copilot modes
# Teaching assistant mode: READ-ONLY diagnostic tools only # Teaching assistant mode: READ-ONLY diagnostic tools only
@ -98,7 +108,8 @@ TEACHING_ASSISTANT_MODE_TOOLS = [
GNS3LinkTool(), # Create links between nodes GNS3LinkTool(), # Create links between nodes
GNS3StartNodeTool(), # Start GNS3 nodes GNS3StartNodeTool(), # Start GNS3 nodes
GNS3UpdateNodeNameTool(), # Update node name 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 # Lab automation assistant mode: Full diagnostic AND configuration tools
@ -108,7 +119,8 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
GNS3LinkTool(), # Create links between nodes GNS3LinkTool(), # Create links between nodes
GNS3StartNodeTool(), # Start GNS3 nodes GNS3StartNodeTool(), # Start GNS3 nodes
GNS3UpdateNodeNameTool(), # Update node name GNS3UpdateNodeNameTool(), # Update node name
ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands (READ-ONLY) ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands
# (READ-ONLY)
ExecuteMultipleDeviceConfigCommands(), # Execute configuration commands ExecuteMultipleDeviceConfigCommands(), # Execute configuration commands
VPCSMultiCommands(), # Execute VPCS commands on multiple devices VPCSMultiCommands(), # Execute VPCS commands on multiple devices
] ]
@ -117,7 +129,8 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
# Create combined tool lookup for tool_node (supports both modes) # 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 ALL_TOOLS = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
tools_by_name = {tool.name: tool for tool in ALL_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. GNS3-Copilot conversation state management class.
Maintains the conversation state for the LangGraph workflow, including message history, Maintains the conversation state for the LangGraph workflow, including
call counters, and session titles for comprehensive dialogue management. message history, call counters, and session titles for comprehensive
dialogue management.
Attributes: 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 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. remaining_steps: Is automatically managed by LangGraph's RemainingSteps
conversation_title: Optional conversation title for session identification and management to track and limit recursion depth.
conversation_title: Optional conversation title for session
identification and management
topology_info: Dictionary containing GNS3 project topology information topology_info: Dictionary containing GNS3 project topology information
""" """
@ -183,7 +200,9 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
} }
logger.debug( 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 # 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: if topology and "error" not in topology:
topology_info = topology topology_info = topology
logger.info( 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: else:
logger.warning( logger.warning(
"Failed to retrieve topology for project_id %s: %s", "Failed to retrieve topology for project_id %s: %s",
project_id, 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: 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 # Store topology_info in state for pre_model_hook to access
state["topology_info"] = topology_info 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() copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
if copilot_mode == "lab_automation_assistant": if copilot_mode == "lab_automation_assistant":
mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS 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) else: # teaching_assistant mode (default)
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS 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 # Create pre_model_hook for automatic topology injection and trimming
# Load system prompt based on copilot_mode configuration # 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, system_prompt=system_prompt,
get_topology_func=lambda s: s.get("topology_info"), get_topology_func=lambda s: s.get("topology_info"),
get_llm_config_func=get_current_llm_config, 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 # Create fresh model with tools for each LLM call
@ -252,14 +286,22 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
copilot_mode, copilot_mode,
len(mode_tools), 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) # Call pre_hook directly to prepare messages (topology injection +
# Note: LangGraph's pre_model_hook only works with prebuilt agents, not custom StateGraph # 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)) 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"] 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 # Invoke model with prepared messages
response = model_with_tools.invoke(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 # Add metadata with created_at timestamp to AI response
if hasattr(response, "metadata"): if hasattr(response, "metadata"):
existing_metadata = response.metadata or {} existing_metadata = response.metadata or {}
response.metadata = {**existing_metadata, "created_at": datetime.utcnow().isoformat()} response.metadata = {
**existing_metadata,
"created_at": datetime.utcnow().isoformat(),
}
else: else:
# LangChain messages should have metadata attribute, but defensive fallback # LangChain messages should have metadata attribute, but defensive
# fallback
try: try:
response.metadata = {"created_at": datetime.utcnow().isoformat()} response.metadata = {"created_at": datetime.utcnow().isoformat()}
except Exception: except Exception:
logger.warning("Could not add metadata to AI response") 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 { return {
"messages": [response], "messages": [response],
@ -285,10 +334,13 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
# Define generate title node # 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). Generate a conversation title using a lightweight assistant LLM
This node is only executed when no title has been set yet (first round only). (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 # 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) # Call the title generation model (create fresh instance for each call)
try: try:
title_model = create_title_model(llm_config=llm_config) 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 raw_content = response.content
new_title = raw_content.strip() new_title = raw_content.strip()
# Validate the generated title # Validate the generated title
if not new_title or len(new_title) < 3: 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"]: if new_title in [
raise ValueError(f"Generated title is a default value: '{new_title}'") "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 # Safety: truncate long titles and avoid line breaks
if len(new_title) > TITLE_MAX_LENGTH: if len(new_title) > TITLE_MAX_LENGTH:
new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..." new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..."
# Remove unwanted characters # 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) logger.info("Generated new title: %s", new_title)
return {"conversation_title": 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] + ".." fallback_title = fallback_title[:28] + ".."
if fallback_title: 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} return {"conversation_title": fallback_title}
# Final fallback # 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} return {"conversation_title": UNTITLED_SESSION_FALLBACK}
# Title already exists → no update needed # Title already exists → no update needed
@ -376,17 +447,24 @@ def tool_node(state: dict, config: RunnableConfig | None = None):
result = [] result = []
for tool_call in tool_calls: for tool_call in tool_calls:
tool_name = tool_call["name"] 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] tool = tools_by_name[tool_name]
try: try:
observation = tool.invoke(tool_call["args"]) 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: except Exception as e:
logger.error("Tool %s failed: %s", tool_name, e, exc_info=True) logger.error("Tool %s failed: %s", tool_name, e, exc_info=True)
observation = f"Error: {str(e)}" observation = f"Error: {str(e)}"
# Serialize observation to JSON string if it's not already a string # 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): if not isinstance(observation, str):
observation = json.dumps(observation, ensure_ascii=False, indent=2) observation = json.dumps(observation, ensure_ascii=False, indent=2)
@ -395,7 +473,7 @@ def tool_node(state: dict, config: RunnableConfig | None = None):
content=observation, content=observation,
tool_call_id=tool_call["id"], tool_call_id=tool_call["id"],
name=tool_call["name"], name=tool_call["name"],
metadata={"created_at": datetime.utcnow().isoformat()} metadata={"created_at": datetime.utcnow().isoformat()},
) )
result.append(tool_msg) result.append(tool_msg)
@ -410,7 +488,8 @@ def should_continue(
Determine the next step after the LLM has produced a response. Determine the next step after the LLM has produced a response.
- If the LLM requested any tool calls → route to tool_node - 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 - Otherwise → conversation is complete, go to END
""" """
last_message = state["messages"][-1] 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. Routing logic after tool execution to prevent infinite recursion.
Determines whether to continue with another LLM call or end the conversation Determines whether to continue with another LLM call or end the
based on remaining steps and message type. conversation based on remaining steps and message type.
Args: Args:
state: Current conversation state with messages and remaining steps 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 # Add edges to connect nodes
agent_builder.add_edge(START, "llm_call") agent_builder.add_edge(START, "llm_call")
# Conditional routing after LLM response # 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( agent_builder.add_conditional_edges(
"llm_call", "llm_call",
should_continue, should_continue,
{ {
"tool_node": "tool_node", # Route to tool execution if LLM requested tools "tool_node": "tool_node", # Route to tool execution if LLM requested
"title_generator_node": "title_generator_node", # Generate title on first interaction # tools
"title_generator_node": "title_generator_node", # Generate title on
# first interaction
END: END, # End conversation if no tools needed END: END, # End conversation if no tools needed
}, },
) )
@ -483,7 +565,8 @@ agent_builder.add_conditional_edges(
"tool_node", "tool_node",
recursion_limit_continue, 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 END: END, # End conversation to prevent infinite loops
}, },
) )

View File

@ -58,7 +58,11 @@ def _load_llm_config(
if not llm_config: if not llm_config:
raise ValueError("LLM configuration is required") 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 { return {
"model_name": llm_config.get("model", ""), "model_name": llm_config.get("model", ""),
@ -90,7 +94,8 @@ def create_base_model(
# Log the loaded configuration (mask sensitive data) # Log the loaded configuration (mask sensitive data)
logger.info( 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_name"],
config_vars["model_provider"], config_vars["model_provider"],
config_vars["base_url"] if config_vars["base_url"] else "default", 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. Create a fresh title generation model instance.
This creates a model instance suitable for generating conversation titles. This creates a model instance suitable for generating conversation titles.
It uses the same configuration as the base model but with a higher temperature It uses the same configuration as the base model but with a higher
for more creative output. temperature for more creative output.
Args: Args:
llm_config: Configuration dictionary from database llm_config: Configuration dictionary from database
@ -147,7 +152,8 @@ def create_title_model(
config_vars = _load_llm_config(llm_config) config_vars = _load_llm_config(llm_config)
logger.info( 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_name"],
config_vars["model_provider"], config_vars["model_provider"],
config_vars["base_url"] if config_vars["base_url"] else "default", config_vars["base_url"] if config_vars["base_url"] else "default",

View File

@ -108,7 +108,9 @@ class AgentService:
return self._checkpointer return self._checkpointer
checkpoint_dir = self._get_checkpoint_dir() 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) log.debug("Creating checkpointer at: %s", checkpointer_path)
@ -118,13 +120,17 @@ class AgentService:
await self._checkpointer_conn.close() await self._checkpointer_conn.close()
log.debug("Closed previous checkpointer connection") log.debug("Closed previous checkpointer connection")
except Exception as e: 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 # Create new connection
conn = await aiosqlite.connect(checkpointer_path) conn = await aiosqlite.connect(checkpointer_path)
# Enable WAL mode for better concurrent performance # Enable WAL mode for better concurrent performance
await conn.execute("PRAGMA journal_mode=WAL;") 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) self._checkpointer = AsyncSqliteSaver(conn)
# CRITICAL: Initialize database schema # CRITICAL: Initialize database schema
@ -176,22 +182,33 @@ class AgentService:
""") """)
# Create indexes # Create indexes
await conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_id ON chat_sessions(thread_id)") await conn.execute(
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_project ON chat_sessions(user_id, project_id)") "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)") cursor = await conn.execute("PRAGMA table_info(chat_sessions)")
columns = await cursor.fetchall() columns = await cursor.fetchall()
column_names = [col[1] for col in columns] column_names = [col[1] for col in columns]
if "pinned" not in column_names: if "pinned" not in column_names:
log.debug("Adding pinned column to existing chat_sessions table") 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() await conn.commit()
# Create pinned index (after column is guaranteed to exist) # Create pinned index (after column is guaranteed to exist)
await conn.execute( 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() await conn.commit()
@ -202,7 +219,9 @@ class AgentService:
if self._graph is None: if self._graph is None:
checkpointer = await self._get_checkpointer() checkpointer = await self._get_checkpointer()
self._graph = agent_builder.compile(checkpointer=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 return self._graph
async def stream_chat( async def stream_chat(
@ -225,13 +244,15 @@ class AgentService:
user_id: User ID for metadata tracking user_id: User ID for metadata tracking
jwt_token: JWT token for API authentication (optional) jwt_token: JWT token for API authentication (optional)
mode: Interaction mode (default: "text") 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: Yields:
Dict containing SSE-compatible response chunks Dict containing SSE-compatible response chunks
""" """
log.info( 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, project_id,
user_id, user_id,
session_id, session_id,
@ -250,7 +271,10 @@ class AgentService:
if is_new_session: if is_new_session:
# Create new session # Create new session
session = await repo.create_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) log.debug("Created new chat session: thread_id=%s", session_id)
@ -261,7 +285,9 @@ class AgentService:
if llm_config: if llm_config:
set_current_llm_config(llm_config) set_current_llm_config(llm_config)
log.debug( 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 # Build config - only thread-safe identifiers
@ -304,33 +330,49 @@ class AgentService:
ai_response_counted = False ai_response_counted = False
tool_messages_counted = 0 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() tool_call_accumulator = ToolCallStreamAccumulator()
# Stream events # Stream events
try: 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", "") event_type = event.get("event", "")
data = event.get("data", {}) data = event.get("data", {})
# Track LLM calls and tokens # Track LLM calls and tokens
if event_type == "on_chat_model_start": if event_type == "on_chat_model_start":
# Filter out title_generator_node from statistics # 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": if langgraph_node != "title_generator_node":
llm_calls_count += 1 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: 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": elif event_type == "on_chat_model_end":
# Filter out title_generator_node from token counting # 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": 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: else:
# Extract token usage from response metadata # 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 token_info_found = False
# Method 1: response.usage_metadata # Method 1: response.usage_metadata
@ -348,8 +390,12 @@ class AgentService:
if hasattr(output_msg, "usage_metadata"): if hasattr(output_msg, "usage_metadata"):
usage = output_msg.usage_metadata usage = output_msg.usage_metadata
if usage: if usage:
input_tokens += usage.get("input_tokens", 0) input_tokens += usage.get(
output_tokens += usage.get("output_tokens", 0) "input_tokens", 0
)
output_tokens += usage.get(
"output_tokens", 0
)
token_info_found = True token_info_found = True
# Method 3: Check data directly for token usage fields # Method 3: Check data directly for token usage fields
@ -358,7 +404,10 @@ class AgentService:
input_tokens += data.get("input_tokens", 0) input_tokens += data.get("input_tokens", 0)
if "output_tokens" in data: if "output_tokens" in data:
output_tokens += data.get("output_tokens", 0) 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 token_info_found = True
# Count AI response as one message (only once per turn) # Count AI response as one message (only once per turn)
@ -370,16 +419,26 @@ class AgentService:
elif event_type == "on_tool_end": elif event_type == "on_tool_end":
message_count += 1 # Tool result message message_count += 1 # Tool result message
tool_messages_counted += 1 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 # 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 # Filter out internal nodes (title_generator_node) from
langgraph_node = event.get("metadata", {}).get("langgraph_node", "") # streaming to frontend
langgraph_node = event.get("metadata", {}).get(
"langgraph_node", ""
)
if langgraph_node == "title_generator_node": if langgraph_node == "title_generator_node":
# Skip all events from the title_generator_node (internal use only) # Skip all events from the title_generator_node (internal
log.debug("Skipping event from internal node: title_generator_node") # use only)
log.debug(
"Skipping event from internal node: "
"title_generator_node"
)
continue continue
if event_type == "on_chat_model_stream": if event_type == "on_chat_model_stream":
@ -387,7 +446,10 @@ class AgentService:
for chunk in chunks: for chunk in chunks:
# Add session_id to each chunk # Add session_id to each chunk
chunk["session_id"] = session_id 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 yield chunk
else: else:
# Use stateless converter for other events # Use stateless converter for other events
@ -407,7 +469,8 @@ class AgentService:
last_message_at=last_message_at, last_message_at=last_message_at,
) )
log.info( 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, session_id,
message_count, message_count,
llm_calls_count, llm_calls_count,
@ -421,15 +484,26 @@ class AgentService:
if final_state and "conversation_title" in final_state.values: if final_state and "conversation_title" in final_state.values:
generated_title = final_state.values["conversation_title"] generated_title = final_state.values["conversation_title"]
current_session = await repo.get_session_by_thread(session_id) current_session = await repo.get_session_by_thread(session_id)
if current_session and current_session.title != generated_title: if (
await repo.update_session(thread_id=session_id, title=generated_title) current_session
log.info("Auto-generated title synced: thread_id=%s, title=%s", session_id, generated_title) 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: except Exception as e:
log.error("Error in stream_chat: %s", e, exc_info=True) log.error("Error in stream_chat: %s", e, exc_info=True)
yield {"type": "error", "error": str(e), "session_id": session_id} 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. Convert LangGraph event to API response chunk.
@ -441,15 +515,17 @@ class AgentService:
Dict for SSE response or None if event should be filtered Dict for SSE response or None if event should be filtered
Note: Note:
on_chat_model_stream events are handled by ToolCallStreamAccumulator on_chat_model_stream events are handled by
before calling this method, so they are not processed here. ToolCallStreamAccumulator before calling this method, so they are
not processed here.
""" """
event_type = event.get("event", "") event_type = event.get("event", "")
data = event.get("data", {}) data = event.get("data", {})
if event_type == "on_tool_start": if event_type == "on_tool_start":
# Tool execution started # 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", "") tool_call_id = event.get("metadata", {}).get("tool_call_id", "")
return { return {
"type": "tool_start", "type": "tool_start",
@ -460,9 +536,11 @@ class AgentService:
elif event_type == "on_tool_end": elif event_type == "on_tool_end":
# Tool execution completed # Tool execution completed
# Extract tool output and convert to JSON string
output = data.get("output", "") output = data.get("output", "")
# Convert output to JSON string if it's not already a string # 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): if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False, indent=2) output = json.dumps(output, ensure_ascii=False, indent=2)
return { return {
@ -474,7 +552,9 @@ class AgentService:
return None 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. Get conversation history for a session.
@ -496,19 +576,31 @@ class AgentService:
for msg in state.values["messages"][-limit:]: for msg in state.values["messages"][-limit:]:
messages.append(self._convert_message_to_dict(msg)) 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: except Exception as e:
log.error("Error getting history: %s", e, exc_info=True) 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]: def _convert_message_to_dict(self, msg) -> Dict[str, Any]:
"""Convert a LangChain message to OpenAI-compatible dict format.""" """Convert a LangChain message to OpenAI-compatible dict format."""
return convert_langchain_to_openai(msg) 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. List chat sessions for this project.
@ -542,7 +634,9 @@ class AgentService:
repo = ChatSessionsRepository(self._checkpointer_conn) repo = ChatSessionsRepository(self._checkpointer_conn)
return await repo.delete_session(session_id) 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. Rename a chat session.
@ -557,10 +651,14 @@ class AgentService:
await self._get_checkpointer() await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn) 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 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. Pin or unpin a chat session.
@ -586,7 +684,10 @@ class AgentService:
if self._checkpointer_conn: if self._checkpointer_conn:
try: try:
await self._checkpointer_conn.close() 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: except Exception as e:
log.warning("Error closing checkpointer connection: %s", e) log.warning("Error closing checkpointer connection: %s", e)
finally: finally:

View File

@ -119,7 +119,11 @@ class ChatSessionsRepository:
self.conn = conn self.conn = conn
async def create_session( 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: ) -> ChatSession:
""" """
Create a new chat session. Create a new chat session.
@ -146,11 +150,15 @@ class ChatSessionsRepository:
await self.conn.commit() await self.conn.commit()
session_id = cursor.lastrowid 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) 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. Get a session by its database ID.
@ -160,14 +168,18 @@ class ChatSessionsRepository:
Returns: Returns:
ChatSession or None 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() row = await cursor.fetchone()
if row: if row:
return self._row_to_session(row) return self._row_to_session(row)
return None 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. Get a session by thread_id.
@ -177,7 +189,9 @@ class ChatSessionsRepository:
Returns: Returns:
ChatSession or None 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() row = await cursor.fetchone()
if row: if row:
@ -185,7 +199,10 @@ class ChatSessionsRepository:
return None return None
async def list_sessions( 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[ChatSession]:
""" """
List sessions with optional filters. List sessions with optional filters.
@ -288,7 +305,10 @@ class ChatSessionsRepository:
params.append(now) params.append(now)
params.append(thread_id) 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.execute(query, params)
await self.conn.commit() await self.conn.commit()
@ -307,15 +327,21 @@ class ChatSessionsRepository:
True if deleted, False if not found True if deleted, False if not found
""" """
# First, delete the checkpoint data # 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 # 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() await self.conn.commit()
deleted = cursor.rowcount > 0 deleted = cursor.rowcount > 0
if deleted: 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 return deleted
@ -330,24 +356,37 @@ class ChatSessionsRepository:
Number of sessions deleted Number of sessions deleted
""" """
# Get all thread_ids for this project # 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() rows = await cursor.fetchall()
thread_ids = [row[0] for row in rows] thread_ids = [row[0] for row in rows]
# Delete checkpoints and sessions # Delete checkpoints and sessions
for thread_id in thread_ids: 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() await self.conn.commit()
deleted_count = cursor.rowcount deleted_count = cursor.rowcount
if deleted_count > 0: 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 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. Pin or unpin a session.
@ -360,12 +399,17 @@ class ChatSessionsRepository:
""" """
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
await self.conn.execute( 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), (1 if pinned else 0, now, thread_id),
) )
await self.conn.commit() 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) return await self.get_session_by_thread(thread_id)
def _row_to_session(self, row) -> ChatSession: def _row_to_session(self, row) -> ChatSession:

View File

@ -30,7 +30,8 @@ This module provides factory functions for creating Gns3Connector instances
with JWT token authentication and context-aware configuration management. with JWT token authentication and context-aware configuration management.
Features: 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 - Auto-detection of GNS3 server URL from Controller/Config
- Fallback URL strategy for flexible deployment - Fallback URL strategy for flexible deployment
- LLM configuration retrieval for users - LLM configuration retrieval for users
@ -49,7 +50,9 @@ from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
) )
# Local imports # 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__) logger = logging.getLogger(__name__)
@ -69,7 +72,10 @@ def _get_url_from_controller() -> Optional[str]:
controller = Controller.instance() controller = Controller.instance()
local_compute = controller.get_compute("local") 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( logger.debug(
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)", "Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
url, url,
@ -88,7 +94,9 @@ def _get_url_from_controller() -> Optional[str]:
logger.debug("Local compute not found in Controller: %s", str(e)) logger.debug("Local compute not found in Controller: %s", str(e))
return None return None
except Exception as e: 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 return None
@ -102,7 +110,10 @@ def _get_url_from_config() -> Optional[str]:
from gns3server.config import Config from gns3server.config import Config
server_config = Config.instance().settings.Server 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( logger.debug(
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)", "Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
url, url,
@ -122,7 +133,9 @@ def _get_url_from_config() -> Optional[str]:
return None 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. """Create and return a Gns3Connector instance with JWT authentication.
URL Resolution Strategy (in order): 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) 4. Fallback to DEFAULT_GNS3_URL (http://127.0.0.1:3080)
Args: 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) url: GNS3 server URL (optional, auto-detected if not provided)
Returns: 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") logger.debug("Config not available, using default URL")
url = DEFAULT_GNS3_URL url = DEFAULT_GNS3_URL
logger.warning( logger.warning(
"Using fallback default URL: %s. " "Using fallback default URL: %s. This may not be "
"This may not be correct if your GNS3 server is configured differently. " "correct if your GNS3 server is configured "
"Consider providing the URL explicitly or ensuring gns3server is running.", "differently. Consider providing the URL explicitly.",
url, url,
) )
else: else:
@ -205,11 +219,15 @@ def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = Non
return connector return connector
except Exception as e: 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 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. 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) user_id: User UUID (can be string or UUID object)
jwt_token: JWT token for authentication jwt_token: JWT token for authentication
url: GNS3 server URL (optional, auto-detected if not provided) 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: Returns:
Dictionary with keys: 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() url = _detect_url_for_api()
# Step 3: Get LLM config # 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: if not llm_config:
logger.warning(f"No LLM config found for user {user_id}") 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} return {"connector": connector, "llm_config": None}
logger.info( logger.info(
f"Successfully initialized GNS3 connector and LLM config for user {user_id}: " f"Successfully initialized GNS3 connector and LLM config for "
f"connector_url={connector.url}, " f"user {user_id}: connector_url={connector.url}, "
f"llm_provider={llm_config.get('provider')}, " f"llm_provider={llm_config.get('provider')}, "
f"llm_model={llm_config.get('model')}" 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} return {"connector": connector, "llm_config": llm_config}
except Exception as e: 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 return None
@ -317,8 +340,8 @@ def get_gns3_server_host() -> str:
""" """
Get GNS3 server hostname from Controller or Config. Get GNS3 server hostname from Controller or Config.
This is a convenience function for extracting the hostname only, This is a convenience function for extracting the hostname only, useful
useful for Nornir tools that need the GNS3 server address. for Nornir tools that need the GNS3 server address.
Uses the same priority order as get_gns3_connector: Uses the same priority order as get_gns3_connector:
1. Controller.instance().compute("local") 1. Controller.instance().compute("local")
@ -329,7 +352,9 @@ def get_gns3_server_host() -> str:
Hostname/IP address string Hostname/IP address string
Example: 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() host = get_gns3_server_host()
print(f"GNS3 server host: {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) logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
return host return host
except Exception as e: 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] 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. Get LLM model configuration for a user.
This function retrieves the user's LLM configuration from the database. This function retrieves the user's LLM configuration from the database.
When `app` is provided, it directly accesses the database (bypassing API restrictions). When `app` is provided, it directly accesses the database (bypassing API
Otherwise, it falls back to API call (which may mask group config API keys). restrictions). Otherwise, it falls back to API call (which may mask
group config API keys).
Args: Args:
user_id: User UUID (can be string or UUID object) user_id: User UUID (can be string or UUID object)
jwt_token: JWT token for authentication 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: Returns:
Dictionary with LLM configuration keys (provider, model, api_key, etc.), Dictionary with LLM configuration keys (provider, model, api_key,
or None if not found. etc.), or None if not found.
Example: 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) config = get_llm_config(user_id, jwt_token)
if config: 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 # We're in an async context with a running loop
# This shouldn't happen since this is a sync function # This shouldn't happen since this is a sync function
with concurrent.futures.ThreadPoolExecutor() as executor: 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) return future.result(timeout=10)
except RuntimeError: except RuntimeError:
# No running event loop - we're in a sync context # 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(): if loop.is_running():
# Loop is running but not the running loop (edge case) # Loop is running but not the running loop (edge case)
with concurrent.futures.ThreadPoolExecutor() as executor: 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) return future.result(timeout=10)
else: else:
# Loop exists but not running - use it # 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: except RuntimeError:
# No event loop exists - create a new one # No event loop exists - create a new one
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: 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: finally:
loop.close() loop.close()
# Fallback: No app provided, try API call (will mask group config API keys) # Fallback: No app provided, try API call (will mask group config API
logger.warning("No app provided for get_llm_config, group config API keys may be masked") # keys)
logger.warning(
"No app provided for get_llm_config, group config API keys may be "
"masked"
)
return None return None
except Exception as e: except Exception as e:

View File

@ -62,8 +62,12 @@ logger = logging.getLogger(__name__)
# Context variables for request-scoped data # Context variables for request-scoped data
# Automatically cleaned up when request context ends # Automatically cleaned up when request context ends
_jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None) _jwt_token_context: ContextVar[Optional[str]] = ContextVar(
_llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None) "_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: 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. """Set the LLM config for the current request context.
Args: 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) _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]: def get_current_llm_config() -> Optional[dict]:
@ -109,7 +118,9 @@ def get_current_llm_config() -> Optional[dict]:
config = _llm_config_context.get() config = _llm_config_context.get()
if config: if config:
logger.debug( 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: else:
logger.warning("LLM config not found in context") logger.warning("LLM config not found in context")

View File

@ -26,12 +26,14 @@
""" """
Adapted gns3fy module for GNS3-Copilot 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: Modifications made for GNS3-Copilot:
- Adjusted pydantic usages and dataclass configuration to reduce dependency conflicts - Adjusted pydantic usages and dataclass configuration to reduce dependency
with langchain (pydantic version/api differences) conflicts with langchain (pydantic version/api differences)
- Kept the original API surface where possible but simplified validators/config - Kept the original API surface where possible but simplified
validators/config
- Added JWT token authentication support - Added JWT token authentication support
- Integrated with context-aware connector factory - Integrated with context-aware connector factory
@ -124,11 +126,21 @@ class Gns3Connector:
```python ```python
>>> # API v2 with basic auth >>> # API v2 with basic auth
>>> server = Gns3Connector(url="http://<address>:3080", user="admin", cred="password", api_version=2) >>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=2
... )
>>> # API v3 with username/password (auto-fetches JWT token) >>> # API v3 with username/password (auto-fetches JWT token)
>>> server = Gns3Connector(url="http://<address>:3080", user="admin", cred="password", api_version=3) >>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=3
... )
>>> # API v3 with direct JWT token >>> # API v3 with direct JWT token
>>> server = Gns3Connector(url="http://<address>:3080", jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", api_version=3) >>> server = Gns3Connector(
... url="http://<address>:3080",
... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
... api_version=3
... )
>>> print(server.get_version()) >>> print(server.get_version())
{'local': False, 'version': '2.2.0b4'} {'local': False, 'version': '2.2.0b4'}
``` ```
@ -151,7 +163,7 @@ class Gns3Connector:
if url is None: if url is None:
raise ValueError("URL is required for Gns3Connector") 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.base_url = f"{self.url}/v{api_version}"
self.user = user self.user = user
self.cred = cred self.cred = cred
@ -160,7 +172,8 @@ class Gns3Connector:
self.api_calls = 0 self.api_calls = 0
# v3 authentication attributes # 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.access_token = jwt_token
self.token_expiry = None self.token_expiry = None
self.auth_type = "basic" if api_version == 2 else "jwt" 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 self.session.headers["Accept"] = "application/json" # pragma: no cover
# Set authentication based on API version # 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 self.session.auth = (self.user, self.cred) # pragma: no cover
elif self.auth_type == "jwt" and self.access_token: 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: def _authenticate_v3(self) -> None:
""" """
@ -193,10 +212,15 @@ class Gns3Connector:
return return
if not self.user or not self.cred: 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) # 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} auth_data = {"username": self.user, "password": self.cred}
# Use temporary session for authentication # Use temporary session for authentication
@ -204,15 +228,22 @@ class Gns3Connector:
temp_session.headers["Content-Type"] = "application/json" temp_session.headers["Content-Type"] = "application/json"
try: 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: if response.status_code == 200:
auth_result = response.json() auth_result = response.json()
self.access_token = auth_result["access_token"] self.access_token = auth_result["access_token"]
# Update session with new 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") # print(f"Successfully authenticated to v3 API, token obtained")
else: 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: except Exception as e:
raise HTTPError(f"v3 API authentication error: {str(e)}") from e raise HTTPError(f"v3 API authentication error: {str(e)}") from e
@ -226,7 +257,9 @@ class Gns3Connector:
try: try:
# Decode token without verification to check expiry # 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") exp = decoded.get("exp")
if exp is not None: if exp is not None:
return time.time() > float(exp) return time.time() > float(exp)
@ -255,7 +288,12 @@ class Gns3Connector:
Executes HTTP operations and handles GNS3-specific error logic. Executes HTTP operations and handles GNS3-specific error logic.
""" """
# Handle JWT authentication # 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() self._authenticate_v3()
# Get request function (e.g., session.get, session.post) # Get request function (e.g., session.get, session.post)
@ -298,10 +336,15 @@ class Gns3Connector:
try: try:
# Only attempt parsing when Content-Type is JSON # 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() error_json = response.json()
status = error_json.get("status", "Unknown Status") 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 # Construct a more descriptive new error
new_err = HTTPError( new_err = HTTPError(
f"{status}: {message} (Original {response.status_code} Error)", 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") response = self.http_call("get", url=f"{self.base_url}/version")
return cast(dict[str, Any], response.json()) 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 Returns a summary of the projects in the server. If `is_print` is `False`, it
will return a list of tuples like: will return a list of tuples like:
@ -333,7 +378,9 @@ class Gns3Connector:
_projects_summary = [] _projects_summary = []
for _p in self.get_projects(): for _p in self.get_projects():
# Retrieve the project stats # 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: if is_print:
print( print(
f"{_p['name']}: {_p['project_id']} -- Nodes: {_stats['nodes']} -- " f"{_p['name']}: {_p['project_id']} -- Nodes: {_stats['nodes']} -- "
@ -355,10 +402,14 @@ class Gns3Connector:
""" """
Returns the list of the projects on the server 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) 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 Retrieves a project from either a name or ID
@ -367,18 +418,24 @@ class Gns3Connector:
- `name` or `project_id` - `name` or `project_id`
""" """
if 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()) return cast(dict[str, Any], _response.json())
elif name: elif name:
try: 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: except StopIteration:
# Project not found # Project not found
return None return None
else: else:
raise ValueError("Must provide either a name or project_id") 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 Returns a summary of the templates in the server. If `is_print` is `False`, it
will return a list of tuples like: will return a list of tuples like:
@ -412,10 +469,14 @@ class Gns3Connector:
""" """
Returns the templates defined on the server. 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) 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 Retrieves a template from either a name or ID
@ -424,11 +485,15 @@ class Gns3Connector:
- `name` or `template_id` - `name` or `template_id`
""" """
if 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) return cast(dict[str, Any], _response_json)
elif name: elif name:
try: 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: except StopIteration:
# Template name not found # Template name not found
return None return None
@ -452,14 +517,17 @@ class Gns3Connector:
**Optional Attributes (can be passed via kwargs):** **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 - Any other template attributes supported by GNS3 API
""" """
# Get existing template # Get existing template
_template = self.get_template(name=name, template_id=template_id) _template = self.get_template(name=name, template_id=template_id)
# Type check: handle case where get_template might return None # Type check: handle case where get_template might return None
if _template is 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 # Update local dictionary and send request
_template.update(**kwargs) _template.update(**kwargs)
@ -485,7 +553,8 @@ class Gns3Connector:
**Optional Attributes (can be passed via kwargs):** **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 - Any other template attributes supported by GNS3 API
**Example:** **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") template_name = kwargs.get("name")
if not template_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 # Check if template already exists
_template = self.get_template(name=kwargs["name"]) _template = self.get_template(name=kwargs["name"])
@ -513,11 +585,15 @@ class Gns3Connector:
kwargs["compute_id"] = "local" kwargs["compute_id"] = "local"
# Send request # 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 and convert type
return cast(dict[str, Any], response.json()) 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] Deletes a template by giving its attributes. For more information [API INFO]
(http://api.gns3.net/en/2.2/api/v2/controller/template/ (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 # Final check: ensure template_id has a value at this point
if not template_id: 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]]: def get_nodes(self, project_id: str) -> list[dict[str, Any]]:
""" """
@ -550,7 +630,9 @@ class Gns3Connector:
- `project_id` - `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) return cast(list[dict[str, Any]], _response_data)
@ -575,7 +657,9 @@ class Gns3Connector:
- `project_id` - `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) return cast(list[dict[str, Any]], _response_data)
@ -650,7 +734,9 @@ class Gns3Connector:
return cast(dict[str, Any], _response_data) 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. Returns a list of images available for a compute.
@ -669,7 +755,9 @@ class Gns3Connector:
return cast(list[dict[str, Any]], _response_data) 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. 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" _url = f"{_conn.base_url}/projects/{_project_id}/nodes"
_response = _conn.http_call("get", _url) _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 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"] self.node_id = extracted[0]["node_id"]
# Checks for Link # Checks for Link
if self.__class__.__name__ == "Link": if self.__class__.__name__ == "Link":
@ -814,7 +908,9 @@ class Link:
@field_validator("filters") @field_validator("filters")
@classmethod @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: if type(value) is not dict and value is not None:
raise ValueError(f"Not a valid filters - {value}") raise ValueError(f"Not a valid filters - {value}")
return value return value
@ -870,7 +966,9 @@ class Link:
if _project_id is None: if _project_id is None:
raise ValueError("Need to submit project_id") raise ValueError("Need to submit project_id")
if _link_id is None: 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}" _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" _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) _response = self.connector.http_call("post", _url, json_data=data)
@ -928,7 +1031,10 @@ class Link:
if not self.project_id: if not self.project_id:
raise ValueError("Need to submit 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 # TODO: Verify that the passed kwargs are supported ones
_response = self.connector.http_call("put", _url, json_data=kwargs) _response = self.connector.http_call("put", _url, json_data=kwargs)
@ -1042,7 +1148,10 @@ class Node:
@field_validator("status") @field_validator("status")
@classmethod @classmethod
def _valid_status(cls, value: Any) -> Any: 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}") raise ValueError(f"Not a valid status - {value}")
return value return value
@ -1068,7 +1177,10 @@ class Node:
if not self.project_id: if not self.project_id:
raise ValueError("Need to submit 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) _response = self.connector.http_call("get", _url)
# Update object # Update object
@ -1094,7 +1206,10 @@ class Node:
if not self.project_id: if not self.project_id:
raise ValueError("Need to submit 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) _response = self.connector.http_call("get", _url)
# Create the Link array but cleanup cache if there is one # Create the Link array but cleanup cache if there is one
@ -1136,7 +1251,9 @@ class Node:
else: else:
# api_version 3 # 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 # successful response code 204
if _response.status_code in (204,): if _response.status_code in (204,):
self.get() self.get()
@ -1145,7 +1262,9 @@ class Node:
try: try:
error_detail = _response.json() error_detail = _response.json()
except Exception: except Exception:
error_detail = getattr(_response, "text", "No response body") error_detail = getattr(
_response, "text", "No response body"
)
_msg = ( _msg = (
"Failed to start node: " "Failed to start node: "
@ -1186,7 +1305,9 @@ class Node:
return True return True
else: else:
# api_version 3 # 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 # successful response code 204
if _response.status_code in (204,): if _response.status_code in (204,):
self.get() self.get()
@ -1196,7 +1317,10 @@ class Node:
error_detail = _response.json() error_detail = _response.json()
except Exception: except Exception:
error_detail = _response.text 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 raise RuntimeError(_msg) from None
@verify_connector_and_id @verify_connector_and_id
@ -1217,7 +1341,9 @@ class Node:
_node_id = self.node_id _node_id = self.node_id
assert _node_id is not None 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) _response = _conn.http_call("post", _url)
if "v2" in _url.lower(): # api_version 2 if "v2" in _url.lower(): # api_version 2
@ -1235,7 +1361,9 @@ class Node:
else: else:
# api_version 3 # 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 # successful response code 204
if _response.status_code in (204,): if _response.status_code in (204,):
self.get() self.get()
@ -1245,7 +1373,10 @@ class Node:
error_detail = _response.json() error_detail = _response.json()
except Exception: except Exception:
error_detail = _response.text 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 raise RuntimeError(_msg) from None
@verify_connector_and_id @verify_connector_and_id
@ -1266,7 +1397,9 @@ class Node:
_node_id = self.node_id _node_id = self.node_id
assert _node_id is not None 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) _response = _conn.http_call("post", _url)
# Update object or perform get if change was not reflected # Update object or perform get if change was not reflected
@ -1335,7 +1468,9 @@ class Node:
_template = self.connector.get_template(name=self.template) _template = self.connector.get_template(name=self.template)
if _template is None: if _template is None:
raise ValueError(f"Template {self.template} not found") 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: else:
raise ValueError("Need either 'template' of 'template_id'") raise ValueError("Need either 'template' of 'template_id'")
@ -1354,9 +1489,16 @@ class Node:
if v is not None 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()) self._update(_response.json())
@ -1535,7 +1677,12 @@ class Project:
if k in self.__dict__: if k in self.__dict__:
setattr(self, k, v) 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. Retrieves the projects information.
@ -1955,7 +2102,9 @@ class Project:
time.sleep(poll_wait_time) time.sleep(poll_wait_time)
self.get_nodes() 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 Returns a summary of the nodes insode the project. If `is_print` is `False`, it
will return a list of tuples like: will return a list of tuples like:
@ -1974,7 +2123,10 @@ class Project:
_nodes_summary = [] _nodes_summary = []
for _n in self.nodes: for _n in self.nodes:
if is_print: 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)) _nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id))
return _nodes_summary if not is_print else None return _nodes_summary if not is_print else None
@ -2010,7 +2162,9 @@ class Project:
_nodes_inventory = {} _nodes_inventory = {}
conn = self.connector conn = self.connector
if not conn: 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 _server = urlparse(conn.base_url).hostname
@ -2036,7 +2190,9 @@ class Project:
return _nodes_inventory 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 Returns a summary of the links insode the project. If `is_print` is False, it
will return a list of tuples like: will return a list of tuples like:
@ -2067,7 +2223,9 @@ class Project:
try: try:
# Add type-safe lookup logic # 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 # Ensure getting str to resolve [return-value] error
_port_a = str( _port_a = str(
next( 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( _port_b = str(
next( next(
x["name"] x["name"]
@ -2116,7 +2276,9 @@ class Project:
except IndexError: except IndexError:
return None 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`. Returns the Node object by searching for the `name` or the `node_id`.
@ -2185,13 +2347,20 @@ class Project:
if not self.nodes: if not self.nodes:
self.get_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() _node.create()
self.nodes.append(_node) 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. Creates a link.
@ -2244,7 +2413,9 @@ class Project:
): ):
_matches.append(_l) # pragma: no cover _matches.append(_l) # pragma: no cover
if _matches: 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! # Now create the link!
_link = Link( _link = Link(
@ -2270,7 +2441,9 @@ class Project:
self.links.append(_link) self.links.append(_link)
print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") 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. Deletes a link.
@ -2324,7 +2497,9 @@ class Project:
): ):
_matches.append(_l) _matches.append(_l)
if not _matches: 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 # now to delete the link via GNS3_api
_link = _matches[0] _link = _matches[0]
@ -2332,7 +2507,8 @@ class Project:
_link_id = _link.link_id _link_id = _link.link_id
_link.delete() _link.delete()
print( 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 @verify_connector_and_id
@ -2361,11 +2537,15 @@ class Project:
self.get_snapshots() self.get_snapshots()
try: 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: except StopIteration:
return None 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`. Returns the Snapshot by searching for the `name` or the `snapshot_id`.
@ -2422,7 +2602,9 @@ class Project:
print(f"Created snapshot: {_snapshot['name']}") print(f"Created snapshot: {_snapshot['name']}")
@verify_connector_and_id @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 Deletes a snapshot of the project
@ -2446,14 +2628,19 @@ class Project:
if not _snapshot: if not _snapshot:
raise ValueError("Snapshot not found") 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) _conn.http_call("delete", _url)
self.get_snapshots() self.get_snapshots()
@verify_connector_and_id @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 Restore a snapshot from disk
@ -2477,7 +2664,10 @@ class Project:
if not _snapshot: if not _snapshot:
raise ValueError("Snapshot not found") 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) _conn.http_call("post", _url)
@ -2512,7 +2702,9 @@ class Project:
_y = int(radius * (-cos(_angle * index))) _y = int(radius * (-cos(_angle * index)))
n.update(x=_x, y=_y) 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`. Returns the drawing by searching for the `svg` or the `drawing_id`.
@ -2528,7 +2720,11 @@ class Project:
self.get_drawings() self.get_drawings()
try: 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): except (StopIteration, KeyError, TypeError):
return None return None
@ -2633,17 +2829,25 @@ class Project:
# Type guard: inform Mypy that self.drawings is now an iterable list # Type guard: inform Mypy that self.drawings is now an iterable list
# Use or [] with next to find target object # Use or [] with next to find target object
current_drawing = next( 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, None,
) )
if current_drawing is 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 # If parameter is None, get original value from current object
# This way, Mypy won't report errors for list comprehensions of each field # 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_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_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_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") final_z = z if z is not None else current_drawing.get("z")
@ -2691,7 +2895,10 @@ class Project:
if not _drawing: if not _drawing:
raise ValueError("drawing not found") 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) _conn.http_call("delete", _url)

View File

@ -50,7 +50,8 @@ class GNS3ProjectInfoTool(BaseTool):
name: str = "gns3_project_info" name: str = "gns3_project_info"
description: str = """ 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. 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. project_id : The UUID of the specific GNS3 project.
Returns: Returns:
dict: A dictionary containing project info (name, project_id, node_count, link_count, status), dict: A dictionary containing project info (name, project_id, node_count,
or an error dictionary if an exception occurs or project_id is not provided. link_count, status), or an error dictionary if an exception occurs
or project_id is not provided.
""" """
# Log received input # 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: try:
# Validate project_id parameter # Validate project_id parameter
if not project_id: if not project_id:
logger.error("project_id parameter is required.") 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 # Initialize Gns3Connector using factory function
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -98,10 +107,17 @@ class GNS3ProjectInfoTool(BaseTool):
if server is None: if server is None:
logger.error("Failed to create GNS3 connector") 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 # 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 = Project(project_id=project_id, connector=server)
project.get() # Load project details project.get() # Load project details
@ -119,7 +135,13 @@ class GNS3ProjectInfoTool(BaseTool):
"status": project.status, "status": project.status,
"node_count": node_count, "node_count": node_count,
"link_count": link_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 # Log result

View File

@ -27,7 +27,8 @@
GNS3 Topology Reader Tool GNS3 Topology Reader Tool
This module provides a LangChain BaseTool to retrieve the topology of a 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: Output: Dictionary with:
- `project_id`, `name`, `status`: Project metadata - `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 - `links`: List of link connections
Use this to understand network structure before making changes. 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. Synchronous method to retrieve the topology of a specific GNS3 project.
Args: Args:
tool_input : Input parameters, typically a dict or Pydantic model containing server_url. tool_input: Input parameters, typically a dict or Pydantic model
run_manager : Callback manager for tool run. containing server_url.
project_id : The UUID of the specific GNS3 project to retrieve topology from. run_manager: Callback manager for tool run.
project_id: The UUID of the specific GNS3 project to retrieve
topology from.
Returns: Returns:
dict: A dictionary containing the project ID, name, status, nodes, and links, dict: A dictionary containing the project ID, name, status, nodes,
or an error dictionary if an exception occurs or project_id is not provided. and links, or an error dictionary if an exception occurs
or project_id is not provided.
""" """
# Log received input # 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: try:
# Validate project_id parameter # Validate project_id parameter
if not project_id: if not project_id:
logger.error("project_id parameter is required.") 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 # Initialize Gns3Connector using factory function
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -97,7 +107,10 @@ class GNS3TopologyTool(BaseTool):
if server is None: if server is None:
logger.error("Failed to create GNS3 connector") 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 # Use the provided project_id directly
logger.info(f"Retrieving topology for project_id: {project_id}") logger.info(f"Retrieving topology for project_id: {project_id}")
@ -109,13 +122,16 @@ class GNS3TopologyTool(BaseTool):
"project_id": project.project_id, "project_id": project.project_id,
"name": project.name, "name": project.name,
"status": project.status, "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), "links": project.links_summary(is_print=False),
} }
# Log topology result # Log topology result
logger.info( 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("project_id"),
topology.get("name"), topology.get("name"),
len(topology.get("nodes", {})), len(topology.get("nodes", {})),
@ -132,11 +148,16 @@ class GNS3TopologyTool(BaseTool):
def _clean_nodes_ports(self, data: dict) -> dict: def _clean_nodes_ports(self, data: dict) -> dict:
""" """
Clean and simplify the nodes data structure. 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 for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
if "ports" in node and isinstance(node["ports"], list): 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 return data

View File

@ -27,7 +27,8 @@
Project Agent Manager Project Agent Manager
Manages AgentService instances for GNS3 projects using a singleton pattern. 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 import asyncio
@ -60,7 +61,9 @@ class ProjectAgentManager:
cls._instance._lock = asyncio.Lock() cls._instance._lock = asyncio.Lock()
return cls._instance 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. Get or create an AgentService for a project.
@ -73,7 +76,11 @@ class ProjectAgentManager:
""" """
async with self._lock: async with self._lock:
if project_id not in self._agents: 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) self._agents[project_id] = AgentService(project_path)
return self._agents[project_id] return self._agents[project_id]
@ -99,7 +106,10 @@ class ProjectAgentManager:
Should be called on server shutdown. Should be called on server shutdown.
""" """
async with self._lock: 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(): for project_id, agent in self._agents.items():
log.debug("Closing AgentService for project: %s", project_id) log.debug("Closing AgentService for project: %s", project_id)
await agent.close() await agent.close()

View File

@ -30,8 +30,8 @@ This package contains system prompts and prompt loading utilities for
the GNS3-Copilot AI agent. the GNS3-Copilot AI agent.
Available prompts: Available prompts:
- teaching_assistant_prompt: Teaching assistant mode (diagnostics only, no configuration) - teaching_assistant_prompt: Teaching assistant mode (diagnostics only)
- lab_automation_assistant_prompt: Lab automation assistant mode (diagnostics + configuration) - lab_automation_assistant_prompt: Lab automation mode (diagnostics + config)
""" """

View File

@ -52,21 +52,21 @@ You have access to the following tools to help users:
| Tool | Purpose | Usage | | Tool | Purpose | Usage |
|------|---------|-------| |------|---------|-------|
| `gns3_template_reader` | Get available node templates | List templates for creating nodes | | `gns3_template_reader` | Get available node templates | List templates |
| `gns3_create_node` | Create new nodes in topology | Add routers, switches, VPCS, etc. | | `gns3_create_node` | Create new nodes in topology | Add routers, switches, VPCS |
| `gns3_link_tool` | Create links between nodes | Connect network topology | | `gns3_link_tool` | Create links between nodes | Connect topology |
| `gns3_start_node_tool` | Start/stop nodes | Control device power state | | `gns3_start_node_tool` | Start/stop nodes | Control power state |
| `gns3_update_node_name_tool` | Update node names | Rename devices | | `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_commands` | Execute display commands | Diagnostics |
| `execute_multiple_device_config_commands` | Execute configuration commands | Make configuration changes | | `execute_multiple_device_config_commands` | Execute config commands | Config changes |
| `vpcs_multi_commands` | Execute VPCS commands | Configure VPCS devices | | `vpcs_multi_commands` | Execute VPCS commands | Configure VPCS devices |
--- ---
# TOOL USAGE RULES # TOOL USAGE RULES
1. **Sequential Execution**: Call ONE tool at a time, wait for results before calling next 1. **Sequential Execution**: Call ONE tool at a time, wait for results
2. **Topology Awareness**: If topology is already in context, DO NOT call topology reader again 2. **Topology Awareness**: If topology is in context, DO NOT call reader again
3. **Efficient Operations**: Batch commands for multiple devices when possible 3. **Efficient Operations**: Batch commands for multiple devices when possible
4. **Safety First**: Be cautious with destructive operations (reload, erase, format) 4. **Safety First**: Be cautious with destructive operations (reload, erase, format)
@ -176,7 +176,8 @@ While you have configuration permissions, exercise caution:
{{topology_info}} {{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.
--- ---
""" """

View File

@ -30,8 +30,10 @@ This module provides utilities for loading system prompts.
Supports multiple prompt variants based on LLM model configuration. Supports multiple prompt variants based on LLM model configuration.
Available Modes (controlled by config.copilot_mode in llm_model_configs): Available Modes (controlled by config.copilot_mode in llm_model_configs):
- "teaching_assistant" (default): Teaching assistant mode - diagnostics only, no configuration - "teaching_assistant" (default): Teaching assistant mode - diagnostics only,
- "lab_automation_assistant": Full lab automation assistant mode - diagnostics and configuration enabled 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. Load the system prompt for GNS3-Copilot.
The prompt mode is controlled by the `copilot_mode` field in the LLM model config: The prompt mode is controlled by the `copilot_mode` field in the LLM
- "teaching_assistant" (default): Teaching assistant mode - diagnostics only, no configuration model config:
- "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
Args: 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: Returns:
str: The system prompt string. str: The system prompt string.
""" """
if not llm_config: 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 return TEACHING_ASSISTANT_PROMPT
# llm_config is a flattened dict with copilot_mode at the top level # 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() mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
if mode == "lab_automation_assistant": 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 return LAB_AUTOMATION_ASSISTANT_PROMPT
else: else:
logger.info("Using TEACHING_ASSISTANT prompt mode (diagnostics only)") logger.info("Using TEACHING_ASSISTANT prompt mode (diagnostics only)")

View File

@ -51,9 +51,11 @@ You are a **GNS3 Lab Teaching Assistant**.
1. ❌ **NEVER** call `execute_multiple_device_config_commands` 1. ❌ **NEVER** call `execute_multiple_device_config_commands`
2. ❌ **NEVER** say "I've configured..." / "Configuration complete" 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 YES → Stop and provide guidance instead
→ If NO → Proceed with diagnosis → If NO → Proceed with diagnosis
@ -119,5 +121,6 @@ ip route, ip addr, tcpdump, ping, traceroute
{{topology_info}} {{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.
""" """

View File

@ -26,7 +26,7 @@
GNS3-Copilot Tools Package 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 - Device configuration command execution
- Display command execution - Display command execution
- Multiple device command execution using Nornir - Multiple device command execution using Nornir
@ -36,7 +36,7 @@ This package provides various tools for interacting with GNS3 network simulator,
Main modules: Main modules:
- config_tools_nornir: Multiple device configuration command execution tool using Nornir - config_tools_nornir: Multiple device configuration command execution tool using Nornir
- display_tools_nornir: Multiple device 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_node: GNS3 node creation tool
- gns3_create_link: GNS3 link creation tool - gns3_create_link: GNS3 link creation tool
- gns3_start_node: GNS3 node startup tool - gns3_start_node: GNS3 node startup tool

View File

@ -24,8 +24,8 @@
# #
""" """
This module provides a tool to execute configuration commands on multiple devices This module provides a tool to execute configuration commands on multiple
in a GNS3 topology using Nornir. devices in a GNS3 topology using Nornir.
""" """
import json 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.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 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 # config log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Suppress nornir INFO logs in console (reduce verbosity) # Suppress nornir INFO logs in console (reduce verbosity)
# The logging={"enabled": False} in InitNornir only disables plugin internal logs, # The logging={"enabled": False} in InitNornir only disables plugin
# but nornir.core still logs task execution at INFO level. Set to WARNING to suppress these. # 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.core").setLevel(logging.WARNING)
logging.getLogger("nornir").setLevel(logging.WARNING) logging.getLogger("nornir").setLevel(logging.WARNING)
@ -63,7 +66,9 @@ def _get_nornir_defaults() -> dict[str, Any]:
return {"data": {"location": "gns3"}} 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. 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, "timeout": 120,
"username": "", "username": "",
"password": "", "password": "",
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}}, "connection_options": {
"netmiko": {"extras": {"device_type": device_type}}
},
} }
def _get_nornir_group( 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]: ) -> dict[str, Any]:
""" """
Get Nornir group configuration for a specific group. Get Nornir group configuration for a specific group.
@ -99,26 +108,31 @@ def _get_nornir_group(
Dictionary containing group configuration Dictionary containing group configuration
""" """
# _get_nornir_groups_config now returns the group config directly # _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): class ExecuteMultipleDeviceConfigCommands(BaseTool):
""" """
A tool to execute configuration commands on multiple devices in a GNS3 topology using Nornir. A tool to execute configuration commands on multiple devices in a GNS3
This class uses Nornir to manage connections and execute configuration commands topology using Nornir.
on multiple devices concurrently. This class uses Nornir to manage connections and execute configuration
commands on multiple devices concurrently.
IMPORTANT SAFETY NOTE: IMPORTANT SAFETY NOTE:
This tool is intended for configuration changes only. Use extreme caution when This tool is intended for configuration changes only. Use extreme caution
executing configuration commands. when executing configuration commands.
""" """
name: str = "execute_multiple_device_config_commands" name: str = "execute_multiple_device_config_commands"
description: str = """ description: str = """
Executes CONFIGURATION commands on multiple devices in the current GNS3 topology. Executes CONFIGURATION commands on multiple devices in the current GNS3
Use this tool ONLY for changing device settings (e.g., 'configure', 'interface', 'ip address', 'router ospf'). 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. 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: Example input:
{ {
"project_id": "<PROJECT_UUID>", "project_id": "<PROJECT_UUID>",
@ -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: 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: Forbidden operations include but are not limited to:
- reload / reboot commands - reload / reboot commands
- write erase / erase startup-config - write erase / erase startup-config
@ -161,14 +176,14 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
**kwargs: Any, **kwargs: Any,
) -> list[dict[str, 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: Args:
tool_input (str): A JSON string containing project_id and device tool_input (str): A JSON string containing project_id and device
configuration commands to execute. configuration commands to execute.
Returns: 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. configuration results.
""" """
# Log received input # Log received input
@ -176,12 +191,18 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Validate input # Validate input
device_configs_list, project_id = self._validate_tool_input(tool_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 return device_configs_list
# Filter forbidden commands and store blocked commands info # Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( device_configs_list, blocked_commands_map = (
device_configs_list self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
) )
# Create a mapping of device names to their configuration commands # Create a mapping of device names to their configuration commands
@ -189,7 +210,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Prepare device hosts data # Prepare device hosts data
try: 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: except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e) logger.error("Failed to prepare device hosts data: %s", e)
return [{"error": str(e)}] return [{"error": str(e)}]
@ -211,11 +234,18 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
) )
# Process results for all devices # 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: except Exception as e:
# Overall execution failed # 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)}"}] return [{"error": f"Execution error: {str(e)}"}]
logger.info( logger.info(
@ -225,16 +255,22 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return results 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.""" """Execute configuration commands with single retry mechanism."""
device_name = task.host.name device_name = task.host.name
config_commands = device_configs_map.get(device_name, []) config_commands = device_configs_map.get(device_name, [])
if not config_commands: 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: 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) return Result(host=task.host, result=_result.result)
except ReadTimeout as e: except ReadTimeout as e:
@ -251,10 +287,12 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
) )
except Exception as e: except Exception as e:
# Handle prompt detection issues with Cisco IOSv L2 images where the '#' prompt character # Handle Cisco IOSv L2 where '#' prompt char may be delayed,
# may be delayed, causing Netmiko prompt detection failures. Implements retry logic. # causing Netmiko failures. Implements retry logic.
if "netmiko_send_config (failed)" in str(e): 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) return Result(host=task.host, result=_result.result)
# Log any other exceptions with full details # Log any other exceptions with full details
@ -274,14 +312,14 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
self, tool_input: str | bytes | list[Any] | dict[str, Any] self, tool_input: str | bytes | list[Any] | dict[str, Any]
) -> tuple[list[dict[str, Any]], str | None]: ) -> tuple[list[dict[str, Any]], str | None]:
""" """
Validate device configuration command input, handling both new and legacy input formats. Validate device config command input, handling new and legacy formats.
Supports new format with project_id and device_configs, as well as legacy array format. Supports new format with project_id and device_configs.
Args: Args:
tool_input: The input received from the LangChain/LangGraph tool call. tool_input: Input from the LangChain/LangGraph tool call.
Returns: 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 parsed_input = None
@ -289,18 +327,26 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Compatibility Check and Parsing --- # Compatibility Check and Parsing ---
# Check if the input is a string (or bytes) which needs to be parsed. # Check if the input is a string (or bytes) which needs to be parsed.
if isinstance(tool_input, (str, bytes, bytearray)): 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: try:
parsed_input = json.loads(tool_input) parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.") logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error("Invalid JSON string received as tool input: %s", e) logger.error(
return ([{"error": f"Invalid JSON string input from model: {e}"}], None) "Invalid JSON string received as tool input: %s", e
)
return (
[{"error": f"Invalid JSON string input from model: {e}"}],
None,
)
else: else:
# Handle standard models (like GPT/OpenAI) where the framework # Handle standard models (like GPT/OpenAI) where the framework
# has already parsed the JSON into a Python object (dict or list). # has already parsed the JSON into a Python object (dict or list).
parsed_input = tool_input 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": [...]} # Handle new format: {"project_id": "...", "device_configs": [...]}
if isinstance(parsed_input, dict): if isinstance(parsed_input, dict):
@ -314,7 +360,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return ([{"error": error_msg}], None) return ([{"error": error_msg}], None)
if not self._validate_project_id(project_id): 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) logger.error(error_msg)
return ([{"error": error_msg}], None) return ([{"error": error_msg}], None)
@ -332,13 +381,17 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Handle legacy format: [...] # Handle legacy format: [...]
elif isinstance(parsed_input, list): 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 return parsed_input, None
else: else:
error_msg = ( error_msg = (
"Tool input must be a JSON object with 'project_id' and 'device_configs' fields, " "Tool input must be a JSON object with 'project_id' and "
f"or a legacy JSON array, but got {type(parsed_input).__name__}" f"'device_configs' fields, or a legacy JSON array, but got "
f"{type(parsed_input).__name__}"
) )
logger.error(error_msg) logger.error(error_msg)
return ([{"error": error_msg}], None) return ([{"error": error_msg}], None)
@ -353,7 +406,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
Returns: Returns:
True if valid UUID format, False otherwise 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)) return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _filter_forbidden_commands_from_device_configs( def _filter_forbidden_commands_from_device_configs(
@ -363,12 +418,12 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
Filter out forbidden commands from device configurations. Filter out forbidden commands from device configurations.
Args: Args:
device_configs_list: List of device configurations with config_commands. device_configs_list: List of device configs with config_commands.
Returns: Returns:
A tuple of (filtered_device_configs_list, blocked_commands_map): Tuple of (filtered_configs, blocked_map):
- filtered_device_configs_list: Device configs with forbidden commands removed. - filtered_configs: Device configs with forbidden removed.
- blocked_commands_map: Dict mapping device names to their blocked commands info. - blocked_map: Dict mapping device names to blocked info.
""" """
filtered_list = [] filtered_list = []
blocked_commands_map: dict[str, dict[str, str]] = {} blocked_commands_map: dict[str, dict[str, str]] = {}
@ -378,7 +433,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
commands = device_config["config_commands"] commands = device_config["config_commands"]
# Filter 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 # Update device config with allowed commands only
filtered_config = device_config.copy() filtered_config = device_config.copy()
@ -397,7 +454,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return filtered_list, blocked_commands_map 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.""" """Create a mapping of device names to their configuration commands."""
device_configs_map = {} device_configs_map = {}
for device_config in device_config_list: for device_config in device_config_list:
@ -408,18 +467,23 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return device_configs_map return device_configs_map
def _prepare_device_hosts_data( 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]]: ) -> dict[str, dict[str, Any]]:
"""Prepare device hosts data from topology information.""" """Prepare device hosts data from topology information."""
# Extract device names list # 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 # Get device port information with project_id
hosts_data = get_device_ports_from_topology(device_names, project_id) hosts_data = get_device_ports_from_topology(device_names, project_id)
if not hosts_data: if not hosts_data:
error_msg = ( 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}" f"Project ID: {project_id}, Devices: {device_names}"
) )
raise ValueError(error_msg) raise ValueError(error_msg)
@ -435,7 +499,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return hosts_data 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.""" """Initialize Nornir with the provided hosts data."""
try: try:
# Extract device_type and platform from hosts_data # Extract device_type and platform from hosts_data
@ -445,14 +511,21 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
if hosts_data: if hosts_data:
first_device_data = next(iter(hosts_data.values()), {}) 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") 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( 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() defaults = _get_nornir_defaults()
@ -460,7 +533,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
gns3_host = get_gns3_server_host() gns3_host = get_gns3_server_host()
logger.info( logger.info(
"Initializing Nornir with account: host=%s, platform=%s, timeout=%d", "Initializing Nornir: host=%s, platform=%s, timeout=%d",
gns3_host, gns3_host,
groups_data.get("platform"), groups_data.get("platform"),
groups_data.get("timeout"), groups_data.get("timeout"),
@ -505,11 +578,16 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "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 # Add blocked commands info if any
if blocked_commands_info: 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 device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -519,11 +597,15 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "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 # Add blocked commands info if any
if blocked_commands_info: 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 device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -535,7 +617,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
if multi_result[0].failed: if multi_result[0].failed:
# Execution failed # Execution failed
device_result["status"] = "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 device_result["output"] = multi_result[0].result
else: else:
# Execution successful # Execution successful
@ -545,9 +629,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: 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 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": if device_result["status"] == "success":
device_result["status"] = "partial_success" device_result["status"] = "partial_success"

View File

@ -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.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 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 # config log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Suppress nornir INFO logs in console (reduce verbosity) # Suppress nornir INFO logs in console (reduce verbosity)
# The logging={"enabled": False} in InitNornir only disables plugin internal logs, # The logging={"enabled": False} in InitNornir only disables plugin
# but nornir.core still logs task execution at INFO level. Set to WARNING to suppress these. # 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.core").setLevel(logging.WARNING)
logging.getLogger("nornir").setLevel(logging.WARNING) logging.getLogger("nornir").setLevel(logging.WARNING)
@ -63,7 +66,9 @@ def _get_nornir_defaults() -> dict[str, Any]:
return {"data": {"location": "gns3"}} 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. 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, "timeout": 120,
"username": "", "username": "",
"password": "", "password": "",
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}}, "connection_options": {
"netmiko": {"extras": {"device_type": device_type}}
},
} }
def _get_nornir_group( 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]: ) -> dict[str, Any]:
""" """
Get Nornir group configuration for a specific group. Get Nornir group configuration for a specific group.
@ -99,17 +108,19 @@ def _get_nornir_group(
Dictionary containing group configuration Dictionary containing group configuration
""" """
# _get_nornir_groups_config now returns the group config directly # _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): 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** **CRITICAL: DIAGNOSIS ONLY - NO CONFIGURATION PERMISSIONS**
This tool is exclusively designed for read-only operations to inspect and diagnose This tool is exclusively designed for read-only operations to inspect and
network devices. It CANNOT and MUST NOT be used for any configuration changes. diagnose network devices. It CANNOT and MUST NOT be used for configuration.
**Allowed Command Types:** **Allowed Command Types:**
- Display commands: show, display (Cisco/Huawei) - Display commands: show, display (Cisco/Huawei)
@ -126,21 +137,21 @@ class ExecuteMultipleDeviceCommands(BaseTool):
name: str = "execute_multiple_device_commands" name: str = "execute_multiple_device_commands"
description: str = """ 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. Use this tool to inspect device information without making any changes.
**PERMITTED USE CASES:** **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 - Check routing: show ip route, show ip ospf neighbor, show bgp summary
- Interface status: show ip interface brief, show interfaces - Interface status: show ip interface brief, show interfaces
- Protocol diagnostics: show ospf database, show bgp routes, debug commands - Protocol diagnostics: show ospf database, show bgp routes, debug commands
- Connectivity testing: ping, traceroute - Connectivity testing: ping, traceroute
**STRICTLY FORBIDDEN:** **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 - 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:** **Input Format:**
{ {
@ -167,28 +178,34 @@ class ExecuteMultipleDeviceCommands(BaseTool):
**kwargs: Any, **kwargs: Any,
) -> list[dict[str, 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 This method only permits display/show commands and does not allow
changes to network devices. configuration changes to network devices.
Args: 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: 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 # Log received input
logger.debug("Received input: %s", tool_input) logger.debug("Received input: %s", tool_input)
# Validate input # Validate input
device_configs_list, project_id = self._validate_tool_input(tool_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 return device_configs_list
# Filter forbidden commands and store blocked commands info # Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( device_configs_list, blocked_commands_map = (
device_configs_list self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
) )
# Create a mapping of device names to their display commands # Create a mapping of device names to their display commands
@ -196,7 +213,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Prepare device hosts data # Prepare device hosts data
try: 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: except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e) logger.error("Failed to prepare device hosts data: %s", e)
return [{"error": str(e)}] return [{"error": str(e)}]
@ -218,7 +237,12 @@ class ExecuteMultipleDeviceCommands(BaseTool):
) )
# Process results for all devices # 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: except Exception as e:
# Overall execution failed # Overall execution failed
@ -232,13 +256,17 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return results 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(
"""Execute READ-ONLY diagnostic commands with single retry mechanism.""" self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
"""Execute READ-ONLY diagnostic commands with single retry."""
device_name = task.host.name device_name = task.host.name
diagnostic_commands = device_configs_map.get(device_name, []) diagnostic_commands = device_configs_map.get(device_name, [])
if not diagnostic_commands: 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: try:
_result = task.run( _result = task.run(
@ -258,13 +286,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
) )
return Result( return Result(
host=task.host, host=task.host,
result=f"diagnostic command execution failed (ReadTimeout): {str(e)}", result=f"diagnostic command failed (ReadTimeout): {str(e)}",
failed=True, failed=True,
) )
except Exception as e: except Exception as e:
# Handle prompt detection issues with Cisco IOSv L2 images where the '#' prompt character # Handle Cisco IOSv L2 where '#' prompt char may be delayed,
# may be delayed, causing Netmiko prompt detection failures. Implements retry logic. # causing Netmiko failures. Implements retry logic.
if "netmiko_multiline (failed)" in str(e): if "netmiko_multiline (failed)" in str(e):
_result = task.run( _result = task.run(
task=netmiko_multiline, task=netmiko_multiline,
@ -276,14 +304,14 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Log any other exceptions with full details # Log any other exceptions with full details
logger.error( logger.error(
"diagnostic command execution failed for device %s: %s (Exception type: %s)", "diagnostic command failed for device %s: %s (Exception: %s)",
device_name, device_name,
str(e), str(e),
type(e).__name__, type(e).__name__,
) )
return Result( return Result(
host=task.host, host=task.host,
result=f"diagnostic command execution failed (Unhandled Exception): {str(e)}", result=f"diagnostic command failed (Unhandled): {str(e)}",
failed=True, failed=True,
) )
@ -293,14 +321,14 @@ class ExecuteMultipleDeviceCommands(BaseTool):
""" """
Validate diagnostic command input for read-only device inspection. Validate diagnostic command input for read-only device inspection.
Handles both new and legacy input formats. Supports new format with project_id Handles both new and legacy input formats. Supports new format with
and device_configs, as well as legacy array format. project_id and device_configs, as well as legacy array format.
Args: Args:
tool_input: The input received from the LangChain/LangGraph tool call. tool_input: Input from LangChain/LangGraph tool call.
Returns: 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 parsed_input = None
@ -308,18 +336,26 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Compatibility Check and Parsing --- # Compatibility Check and Parsing ---
# Check if the input is a string (or bytes) which needs to be parsed. # Check if the input is a string (or bytes) which needs to be parsed.
if isinstance(tool_input, (str, bytes, bytearray)): 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: try:
parsed_input = json.loads(tool_input) parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.") logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error("Invalid JSON string received as tool input: %s", e) logger.error(
return ([{"error": f"Invalid JSON string input from model: {e}"}], None) "Invalid JSON string received as tool input: %s", e
)
return (
[{"error": f"Invalid JSON string input from model: {e}"}],
None,
)
else: else:
# Handle standard models (like GPT/OpenAI) where the framework # Handle standard models (like GPT/OpenAI) where the framework
# has already parsed the JSON into a Python object (dict or list). # has already parsed the JSON into a Python object (dict or list).
parsed_input = tool_input 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": [...]} # Handle new format: {"project_id": "...", "device_configs": [...]}
if isinstance(parsed_input, dict): if isinstance(parsed_input, dict):
@ -333,7 +369,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return ([{"error": error_msg}], None) return ([{"error": error_msg}], None)
if not self._validate_project_id(project_id): 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) logger.error(error_msg)
return ([{"error": error_msg}], None) return ([{"error": error_msg}], None)
@ -351,13 +387,15 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Handle legacy format: [...] # Handle legacy format: [...]
elif isinstance(parsed_input, list): 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 return parsed_input, None
else: else:
error_msg = ( error_msg = (
"Tool input must be a JSON object with 'project_id' and 'device_configs' fields, " "Tool input must be JSON with project_id and device_configs, "
f"or a legacy JSON array, but got {type(parsed_input).__name__}" f"or legacy JSON array, got {type(parsed_input).__name__}"
) )
logger.error(error_msg) logger.error(error_msg)
return ([{"error": error_msg}], None) return ([{"error": error_msg}], None)
@ -372,7 +410,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
Returns: Returns:
True if valid UUID format, False otherwise 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)) return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _filter_forbidden_commands_from_device_configs( def _filter_forbidden_commands_from_device_configs(
@ -385,9 +425,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_configs_list: List of device configurations with commands. device_configs_list: List of device configurations with commands.
Returns: Returns:
A tuple of (filtered_device_configs_list, blocked_commands_map): Tuple of (filtered_configs, blocked_map):
- filtered_device_configs_list: Device configs with forbidden commands removed. - filtered_configs: Device configs with forbidden removed.
- blocked_commands_map: Dict mapping device names to their blocked commands info. - blocked_map: Dict mapping device names to blocked commands.
""" """
filtered_list = [] filtered_list = []
blocked_commands_map: dict[str, dict[str, str]] = {} blocked_commands_map: dict[str, dict[str, str]] = {}
@ -397,7 +437,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
commands = device_config["commands"] commands = device_config["commands"]
# Filter 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 # Update device config with allowed commands only
filtered_config = device_config.copy() filtered_config = device_config.copy()
@ -416,7 +458,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return filtered_list, blocked_commands_map 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.""" """Create a mapping of device names to their diagnostic commands."""
device_diagnostic_map = {} device_diagnostic_map = {}
for device_config in device_config_list: for device_config in device_config_list:
@ -427,19 +471,24 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return device_diagnostic_map return device_diagnostic_map
def _prepare_device_hosts_data( 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]]: ) -> dict[str, dict[str, Any]]:
"""Prepare device hosts data from topology information.""" """Prepare device hosts data from topology information."""
# Extract device names list # 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 # Get device port information with project_id
hosts_data = get_device_ports_from_topology(device_names, project_id) hosts_data = get_device_ports_from_topology(device_names, project_id)
if not hosts_data: if not hosts_data:
error_msg = ( error_msg = (
f"Failed to get device information from topology or no valid devices found. " f"Failed to get device info from topology. "
f"Project ID: {project_id}, Devices: {device_names}" f"Project: {project_id}, Devices: {device_names}"
) )
raise ValueError(error_msg) raise ValueError(error_msg)
@ -454,7 +503,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return hosts_data 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.""" """Initialize Nornir with the provided hosts data."""
try: try:
# Extract device_type and platform from hosts_data # Extract device_type and platform from hosts_data
@ -464,14 +515,21 @@ class ExecuteMultipleDeviceCommands(BaseTool):
if hosts_data: if hosts_data:
first_device_data = next(iter(hosts_data.values()), {}) 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") 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( 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() defaults = _get_nornir_defaults()
@ -479,7 +537,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
gns3_host = get_gns3_server_host() gns3_host = get_gns3_server_host()
logger.info( logger.info(
"Initializing Nornir with account: host=%s, platform=%s, timeout=%d", "Initializing Nornir: host=%s, platform=%s, timeout=%d",
gns3_host, gns3_host,
groups_data.get("platform"), groups_data.get("platform"),
groups_data.get("timeout"), groups_data.get("timeout"),
@ -524,11 +582,16 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "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 # Add blocked commands info if any
if blocked_commands_info: 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 device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -538,11 +601,15 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = { device_result = {
"device_name": device_name, "device_name": device_name,
"status": "failed", "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 # Add blocked commands info if any
if blocked_commands_info: 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 device_result["blocked_info"] = blocked_commands_info
results.append(device_result) results.append(device_result)
continue continue
@ -554,7 +621,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
if multi_result[0].failed: if multi_result[0].failed:
# Execution failed # Execution failed
device_result["status"] = "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 device_result["output"] = multi_result[0].result
else: else:
# Execution successful # Execution successful
@ -564,9 +633,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Add blocked commands info if any # Add blocked commands info if any
if blocked_commands_info: 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 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": if device_result["status"] == "success":
device_result["status"] = "partial_success" device_result["status"] = "partial_success"

View File

@ -65,7 +65,7 @@ class GNS3LinkTool(BaseTool):
- `port1` (str): Port name of the first node (e.g., 'Ethernet0/0'). - `port1` (str): Port name of the first node (e.g., 'Ethernet0/0').
- `node_id2` (str): UUID of the second node. - `node_id2` (str): UUID of the second node.
- `port2` (str): Port name of the second node (e.g., 'Ethernet0/0'). - `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: Example Input:
{ {
@ -79,9 +79,9 @@ class GNS3LinkTool(BaseTool):
} }
] ]
} }
Output: A list of dictionaries, each containing: Output: A list of dicts, each containing:
- For successfule links: link_id(str),node_id1(str),port1(str),port1(str),node_id(str),port2(str). - For successful links: link_id, node_id1, port1, node_id2, port2.
- For failed links: error(str) with an error message(e.g., 'Missing required field: project_id') - For failed links: error(str) with error message.
Example Output: 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. Creates one or multiple links between nodes in a GNS3 project.
Args: 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). run_manager: LangChain run manager (unused).
Returns: 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 # Log received input
logger.info("Received input: %s", tool_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: if not isinstance(links_data, list) or len(links_data) == 0:
logger.error("Invalid links data: must be a non-empty array") 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 # Initialize Gns3Connector using factory function
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -133,7 +139,14 @@ class GNS3LinkTool(BaseTool):
if gns3_server is None: if gns3_server is None:
logger.error("Failed to create GNS3 connector") 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 = [] created_links = []
@ -150,14 +163,20 @@ class GNS3LinkTool(BaseTool):
# Validate link parameters # Validate link parameters
if not all([node_id1, port1, node_id2, port2]): 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) logger.error(error_msg)
created_links.append({"error": error_msg}) created_links.append({"error": error_msg})
continue continue
# Get node details # Get node details
node1 = gns3_server.get_node(project_id=project_id, node_id=node_id1) node1 = gns3_server.get_node(
node2 = gns3_server.get_node(project_id=project_id, node_id=node_id2) 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: if not node1 or not node2:
error_msg = f"Node not found in link {i}" error_msg = f"Node not found in link {i}"
logger.error(error_msg) logger.error(error_msg)
@ -166,11 +185,19 @@ class GNS3LinkTool(BaseTool):
# Find port information # Find port information
port1_info = next( 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, None,
) )
port2_info = next( 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, None,
) )
if not port1_info or not port2_info: if not port1_info or not port2_info:
@ -186,14 +213,22 @@ class GNS3LinkTool(BaseTool):
nodes=[ nodes=[
{ {
"node_id": node_id1, "node_id": node_id1,
"adapter_number": port1_info.get("adapter_number", 0), "adapter_number": port1_info.get(
"port_number": port1_info.get("port_number", 0), "adapter_number", 0
),
"port_number": port1_info.get(
"port_number", 0
),
"label": {"text": port1}, "label": {"text": port1},
}, },
{ {
"node_id": node_id2, "node_id": node_id2,
"adapter_number": port2_info.get("adapter_number", 0), "adapter_number": port2_info.get(
"port_number": port2_info.get("port_number", 0), "adapter_number", 0
),
"port_number": port2_info.get(
"port_number", 0
),
"label": {"text": port2}, "label": {"text": port2},
}, },
], ],
@ -217,7 +252,9 @@ class GNS3LinkTool(BaseTool):
created_links.append({"error": error_msg}) created_links.append({"error": error_msg})
# Log final results # 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( logger.info(
"Link creation completed: %d successful, %d failed", "Link creation completed: %d successful, %d failed",
success_count, success_count,

View File

@ -51,7 +51,7 @@ class GNS3CreateNodeTool(BaseTool):
using specified templates and coordinates. using specified templates and coordinates.
**Input:** **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. x and y coordinates.
Example input: Example input:
@ -92,13 +92,13 @@ class GNS3CreateNodeTool(BaseTool):
"successful_nodes": 2, "successful_nodes": 2,
"failed_nodes": 0 "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" name: str = "create_gns3_node"
description: str = """ description: str = """
Creates multiple nodes in a GNS3 project using specified templates and coordinates. Creates multiple nodes in a GNS3 project using templates and coordinates.
Input is a JSON object with project_id and an array of nodes, each containing template_id, x, and y coordinates. Input is a JSON object with project_id and array of nodes.
Example input: Example input:
{ {
"project_id": "uuid-of-project", "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. 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. This spacing is necessary to display interface numbers clearly for better
Returns a dictionary with creation results for all nodes, including success/failure status. topology visualization.
If the operation fails during input validation, returns a dictionary with an error message. 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( def _run(
@ -128,14 +131,15 @@ class GNS3CreateNodeTool(BaseTool):
**kwargs: Any, **kwargs: Any,
) -> dict[str, 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: 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). run_manager: LangChain run manager (unused).
Returns: 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 # Log received input
logger.info("Received input: %s", tool_input) logger.info("Received input: %s", tool_input)
@ -158,7 +162,9 @@ class GNS3CreateNodeTool(BaseTool):
# Validate each node in the array # Validate each node in the array
for i, node_data in enumerate(nodes): for i, node_data in enumerate(nodes):
if not isinstance(node_data, dict): 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."} return {"error": f"Node {i + 1} must be a dictionary."}
template_id = node_data.get("template_id") template_id = node_data.get("template_id")
@ -173,10 +179,14 @@ class GNS3CreateNodeTool(BaseTool):
] ]
): ):
logger.error( 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, 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 # Initialize Gns3Connector using factory function
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -184,10 +194,15 @@ class GNS3CreateNodeTool(BaseTool):
if gns3_server is None: if gns3_server is None:
logger.error("Failed to create GNS3 connector") 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 # 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]] = [] results: list[dict[str, Any]] = []
for i, node_data in enumerate(nodes): for i, node_data in enumerate(nodes):
@ -197,7 +212,7 @@ class GNS3CreateNodeTool(BaseTool):
y = node_data.get("y") y = node_data.get("y")
logger.info( 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, i + 1,
len(nodes), len(nodes),
template_id, template_id,
@ -235,8 +250,12 @@ class GNS3CreateNodeTool(BaseTool):
# Continue with next node even if one fails # Continue with next node even if one fails
# Calculate summary statistics # Calculate summary statistics
successful_nodes = len([r for r in results if r.get("status") == "success"]) successful_nodes = len(
failed_nodes = len([r for r in results if r.get("status") == "failed"]) [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 # Prepare final result
final_result = { final_result = {
@ -249,7 +268,7 @@ class GNS3CreateNodeTool(BaseTool):
# Log the final result # Log the final result
logger.info( 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, successful_nodes,
failed_nodes, failed_nodes,
len(nodes), len(nodes),
@ -263,22 +282,27 @@ class GNS3CreateNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"} return {"error": f"Invalid JSON input: {e}"}
except Exception as e: except Exception as e:
logger.error("Failed to process node creation request: %s", 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__": if __name__ == "__main__":
# Test the tool locally with multiple nodes # Test the tool locally with multiple nodes
test_input = json.dumps( 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": [ "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, "x": 100,
"y": -200, "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, "x": 200,
"y": -300, "y": -300,
}, },

View File

@ -46,39 +46,23 @@ logger = logging.getLogger(__name__)
class GNS3TemplateTool(BaseTool): class GNS3TemplateTool(BaseTool):
""" """
A LangChain tool to retrieve all available device templates from a GNS3 server. LangChain tool to retrieve available device templates from GNS3 server.
The tool connects to the GNS3 server and extracts the name, template_id, and template_type Connects to GNS3 server and extracts name, template_id, and template_type.
for each template.
**Input:** **Input:**
No input is required for this tool. It connects to the GNS3 server at the default URL No input required. Connects to GNS3 server at default URL.
(http://localhost:3080) and retrieves all templates.
**Output:** **Output:**
A dictionary containing a list of dictionaries, each with the name, template_id, and Dict with list of dicts (name, template_id, template_type).
template_type of a template. Example output: If error, returns dict with error message.
{
"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.
""" """
name: str = "get_gns3_templates" name: str = "get_gns3_templates"
description: str = """ description: str = """
Retrieves all available device templates from a GNS3 server. Retrieves available device templates from GNS3 server.
Returns a dictionary containing a list of dictionaries, each with the name, template_id, Returns dict with list of dicts (name, template_id, template_type).
and template_type of a template. No input is required. No input required.
Example output: If connection fails, returns dict with error message.
{
"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.
""" """
def _run( def _run(
@ -87,14 +71,14 @@ class GNS3TemplateTool(BaseTool):
run_manager: CallbackManagerForToolRun | None = None, run_manager: CallbackManagerForToolRun | None = None,
) -> dict[str, Any]: ) -> 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: Args:
tool_input (str): Optional input (not used in this tool). tool_input: Optional input (not used).
run_manager: LangChain run manager (unused). run_manager: LangChain run manager (unused).
Returns: Returns:
dict: A dictionary containing the list of templates or an error message. dict: Dict with templates list or error message.
""" """
try: try:
# Initialize Gns3Connector using factory function # Initialize Gns3Connector using factory function
@ -103,7 +87,12 @@ class GNS3TemplateTool(BaseTool):
if gns3_server is None: if gns3_server is None:
logger.error("Failed to create GNS3 connector") 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 # Retrieve all available templates
templates = gns3_server.get_templates() templates = gns3_server.get_templates()
@ -120,14 +109,16 @@ class GNS3TemplateTool(BaseTool):
# Return JSON-formatted result with full logging # Return JSON-formatted result with full logging
result = {"templates": template_info} result = {"templates": template_info}
logger.info( logger.info(
"Template retrieval completed. Total templates: %d. Result: %s", "Template retrieval completed. Total: %d. Result: %s",
len(template_info), len(template_info),
json.dumps(result, indent=2, ensure_ascii=False), json.dumps(result, indent=2, ensure_ascii=False),
) )
return result return result
except Exception as e: 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)}"} return {"error": f"Failed to retrieve templates: {str(e)}"}

View File

@ -46,7 +46,9 @@ from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
logger = logging.getLogger(__name__) 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. 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 # Create progress bar display
bar_length = 30 bar_length = 30
filled_length = int(bar_length * elapsed // duration) 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 progress bar with node count
print(f"\r[{progress_string}] {progress:.1f}%", end="", flush=True) print(f"\r[{progress_string}] {progress:.1f}%", end="", flush=True)
@ -102,10 +106,14 @@ class GNS3StartNodeTool(BaseTool):
description: str = """ description: str = """
Starts one or multiple nodes in a GNS3 project. Starts one or multiple nodes in a GNS3 project.
Input: JSON with project_id and node_ids (list of node IDs). 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: try:
# Parse input JSON # Parse input JSON
input_data = json.loads(tool_input) input_data = json.loads(tool_input)
@ -114,8 +122,13 @@ class GNS3StartNodeTool(BaseTool):
# Validate input # Validate input
if not project_id or not node_ids: if not project_id or not node_ids:
logger.error("Missing required fields: project_id or node_ids.") logger.error(
return {"error": "Missing required fields: project_id and node_ids."} "Missing required fields: project_id or node_ids."
)
return {
"error": "Missing required fields: "
"project_id and node_ids."
}
if not isinstance(node_ids, list): if not isinstance(node_ids, list):
logger.error("node_ids must be a list.") logger.error("node_ids must be a list.")
@ -127,7 +140,10 @@ class GNS3StartNodeTool(BaseTool):
if gns3_server is None: if gns3_server is None:
logger.error("Failed to create GNS3 connector") 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 # First loop: Send start commands for all nodes
logger.info( logger.info(
@ -137,31 +153,49 @@ class GNS3StartNodeTool(BaseTool):
) )
for node_id in node_ids: for node_id in node_ids:
try: 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 # Verify node exists
node.get() node.get()
if node.node_id: if node.node_id:
node.start() node.start()
logger.info("Start command sent for node %s", node_id) logger.info("Start command sent for node %s", node_id)
else: 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: 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 base_duration = 140
extra_duration = max(0, len(node_ids) - 1) * 10 extra_duration = max(0, len(node_ids) - 1) * 10
total_duration = base_duration + extra_duration total_duration = base_duration + extra_duration
# Show progress bar # 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 # Second loop: Get status for all nodes
results = [] results = []
logger.info("Retrieving status for %d nodes...", len(node_ids)) logger.info("Retrieving status for %d nodes...", len(node_ids))
for node_id in node_ids: for node_id in node_ids:
try: 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.get() # Get latest status
node_info = { node_info = {
"node_id": node.node_id, "node_id": node.node_id,
@ -170,7 +204,9 @@ class GNS3StartNodeTool(BaseTool):
} }
results.append(node_info) results.append(node_info)
except Exception as e: 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( results.append(
{ {
"node_id": node_id, "node_id": node_id,
@ -181,7 +217,9 @@ class GNS3StartNodeTool(BaseTool):
) )
# Analyze results # 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"] failed_nodes = [r for r in results if r.get("status") == "error"]
# Construct final response # Construct final response
@ -211,7 +249,7 @@ class GNS3StartNodeTool(BaseTool):
class GNS3StartNodeQuickTool(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, This tool sends start commands to all nodes and immediately returns status,
without blocking for startup completion. Suitable for automated deployment without blocking for startup completion. Suitable for automated deployment
@ -226,7 +264,7 @@ class GNS3StartNodeQuickTool(BaseTool):
} }
**Output**: **Output**:
A dictionary with all nodes' details immediately after sending start commands: A dict with nodes' details immediately after sending start commands:
{ {
"project_id": "...", "project_id": "...",
"total_nodes": 2, "total_nodes": 2,
@ -242,14 +280,18 @@ class GNS3StartNodeQuickTool(BaseTool):
name: str = "start_gns3_node_quick" name: str = "start_gns3_node_quick"
description: str = """ 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. Use this for automated deployments to avoid HTTP timeouts.
Input: JSON with project_id and node_ids (list of node IDs). 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. 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: try:
# Parse input JSON # Parse input JSON
input_data = json.loads(tool_input) input_data = json.loads(tool_input)
@ -258,8 +300,13 @@ class GNS3StartNodeQuickTool(BaseTool):
# Validate input # Validate input
if not project_id or not node_ids: if not project_id or not node_ids:
logger.error("Missing required fields: project_id or node_ids.") logger.error(
return {"error": "Missing required fields: project_id and node_ids."} "Missing required fields: project_id or node_ids."
)
return {
"error": "Missing required fields: "
"project_id and node_ids."
}
if not isinstance(node_ids, list): if not isinstance(node_ids, list):
logger.error("node_ids must be a list.") logger.error("node_ids must be a list.")
@ -271,7 +318,10 @@ class GNS3StartNodeQuickTool(BaseTool):
if gns3_server is None: if gns3_server is None:
logger.error("Failed to create GNS3 connector") 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 # Send start commands for all nodes and collect initial status
logger.info( logger.info(
@ -283,11 +333,19 @@ class GNS3StartNodeQuickTool(BaseTool):
for node_id in node_ids: for node_id in node_ids:
try: 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 # Verify node exists and get current info
node.get() node.get()
if not node.node_id: 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( results.append(
{ {
"node_id": node_id, "node_id": node_id,
@ -300,9 +358,13 @@ class GNS3StartNodeQuickTool(BaseTool):
# Send start command # Send start command
node.start() 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.get()
node_info = { node_info = {
"node_id": node.node_id, "node_id": node.node_id,
@ -323,7 +385,9 @@ class GNS3StartNodeQuickTool(BaseTool):
) )
# Analyze results (count based on successful command sending) # 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"] failed_nodes = [r for r in results if r.get("status") == "error"]
# Construct final response # Construct final response
@ -333,7 +397,10 @@ class GNS3StartNodeQuickTool(BaseTool):
"successful": len(successful_nodes), "successful": len(successful_nodes),
"failed": len(failed_nodes), "failed": len(failed_nodes),
"nodes": results, "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( logger.info(
@ -358,7 +425,9 @@ if __name__ == "__main__":
test_input_single = json.dumps( test_input_single = json.dumps(
{ {
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID "project_id": "<PROJECT_UUID>", # 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() tool = GNS3StartNodeTool()
@ -371,7 +440,8 @@ if __name__ == "__main__":
{ {
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID "project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [ "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", "another-node-uuid-here",
"third-node-uuid-here", "third-node-uuid-here",
], ],

View File

@ -26,7 +26,8 @@
GNS3 node name update tool for renaming network devices. 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 import json
@ -46,10 +47,12 @@ logger = logging.getLogger(__name__)
class GNS3UpdateNodeNameTool(BaseTool): 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**: **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: Example:
{ {
"project_id": "uuid-of-project", "project_id": "uuid-of-project",
@ -67,8 +70,18 @@ class GNS3UpdateNodeNameTool(BaseTool):
"successful": 2, "successful": 2,
"failed": 0, "failed": 0,
"nodes": [ "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" name: str = "update_gns3_node_name"
description: str = """ description: str = """
Updates the name of one or multiple nodes in a GNS3 project. 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. Input: JSON with project_id and nodes array.
Returns: A dictionary with all nodes' update results including success/failure status. 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: try:
# Parse input JSON # Parse input JSON
input_data = json.loads(tool_input) input_data = json.loads(tool_input)
@ -103,7 +122,9 @@ class GNS3UpdateNodeNameTool(BaseTool):
return {"error": f"Node {i + 1} must be a dictionary."} return {"error": f"Node {i + 1} must be a dictionary."}
if "node_id" not in node_data or "new_name" not in node_data: 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) 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 # Initialize Gns3Connector
logger.info("Connecting to GNS3 server...") logger.info("Connecting to GNS3 server...")
@ -111,10 +132,17 @@ class GNS3UpdateNodeNameTool(BaseTool):
if gns3_server is None: if gns3_server is None:
logger.error("Failed to create GNS3 connector") 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 # 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 = [] results = []
for i, node_data in enumerate(nodes): for i, node_data in enumerate(nodes):
@ -131,7 +159,11 @@ class GNS3UpdateNodeNameTool(BaseTool):
) )
# Get node to retrieve current name # 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() node.get()
old_name = node.name old_name = node.name
@ -148,7 +180,11 @@ class GNS3UpdateNodeNameTool(BaseTool):
"status": "success", "status": "success",
} }
results.append(node_info) 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: else:
error_info = { error_info = {
"node_id": node_id, "node_id": node_id,
@ -159,7 +195,9 @@ class GNS3UpdateNodeNameTool(BaseTool):
"error": "Name verification failed", "error": "Name verification failed",
} }
results.append(error_info) 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: except Exception as e:
error_info = { error_info = {
@ -172,7 +210,9 @@ class GNS3UpdateNodeNameTool(BaseTool):
logger.error("Failed to update node %d: %s", i + 1, e) logger.error("Failed to update node %d: %s", i + 1, e)
# Analyze results # 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"] failed_nodes = [r for r in results if r.get("status") == "failed"]
# Construct final response # Construct final response
@ -205,10 +245,12 @@ if __name__ == "__main__":
print("=== Testing single node name update ===") print("=== Testing single node name update ===")
test_input_single = json.dumps( 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": [ "nodes": [
{ {
"node_id": "your-node-uuid", # Replace with actual node UUID # Replace with actual node UUID
"node_id": "your-node-uuid",
"new_name": "Router1", "new_name": "Router1",
} }
], ],
@ -222,7 +264,8 @@ if __name__ == "__main__":
print("\n=== Testing multiple nodes name update ===") print("\n=== Testing multiple nodes name update ===")
test_input_multiple = json.dumps( 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": [ "nodes": [
{"node_id": "node-uuid-1", "new_name": "Router1"}, {"node_id": "node-uuid-1", "new_name": "Router1"},
{"node_id": "node-uuid-2", "new_name": "Switch1"}, {"node_id": "node-uuid-2", "new_name": "Switch1"},

View File

@ -25,7 +25,7 @@
""" """
Multi-device VPCS command execution tool using telnetlib3 with threading. 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 import json
@ -47,14 +47,13 @@ logger = logging.getLogger(__name__)
class VPCSMultiCommands(BaseTool): 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. **VPCS-SPECIFIC TOOL** - This tool ONLY works with VPCS virtual PC devices.
**IMPORTANT DISTINCTION:** **IMPORTANT DISTINCTION:**
Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs that Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs.
simulate basic network functionality. Commands like 'ip' are basic PC IP configuration, Commands like 'ip' are basic PC IP config, NOT network device config.
NOT network device configuration.
**Allowed VPCS Commands:** **Allowed VPCS Commands:**
- IP configuration: ip <address>/<mask> <gateway> (Basic PC IP setup) - IP configuration: ip <address>/<mask> <gateway> (Basic PC IP setup)
@ -66,19 +65,19 @@ class VPCSMultiCommands(BaseTool):
**Usage Context:** **Usage Context:**
This tool is used in lab environments where students need to configure virtual PC IP 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" name: str = "execute_vpcs_multi_commands"
description: str = """ description: str = """
**VPCS VIRTUAL PC TOOL** - Configure and test Virtual PC Simulator devices. **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** **IMPORTANT: VPCS vs Network Devices**
- VPCS = Lightweight virtual PCs for lab testing (NOT network infrastructure) - VPCS = Lightweight virtual PCs for lab testing (NOT network infra)
- 'ip' command on VPCS = Basic PC IP configuration (like 'ipconfig' on Windows) - 'ip' command on VPCS = Basic PC IP config (like 'ipconfig' on Windows)
- This is NOT the same as configuring router interfaces or routing protocols - NOT the same as configuring router interfaces or routing protocols
**When to Use This Tool:** **When to Use This Tool:**
- Configure IP addresses on virtual PCs: ip 10.10.0.12/24 10.10.0.254 - 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( def _connect_and_execute_commands(
@ -131,11 +130,17 @@ class VPCSMultiCommands(BaseTool):
# Check if device has port information # Check if device has port information
if device_name not in device_ports: 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] = { results_list[index] = {
"device_name": device_name, "device_name": device_name,
"status": "error", "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, "commands": commands,
} }
return return
@ -143,7 +148,12 @@ class VPCSMultiCommands(BaseTool):
port = device_ports[device_name]["port"] port = device_ports[device_name]["port"]
host = gns3_host 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() tn = Telnet()
try: try:
@ -197,7 +207,11 @@ class VPCSMultiCommands(BaseTool):
) )
except Exception as e: 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] = { results_list[index] = {
"device_name": device_name, "device_name": device_name,
"status": "error", "status": "error",
@ -218,10 +232,14 @@ class VPCSMultiCommands(BaseTool):
Returns: Returns:
True if valid UUID format, False otherwise 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)) is_valid = bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
if not is_valid: 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 return is_valid
def _validate_tool_input( def _validate_tool_input(
@ -231,10 +249,10 @@ class VPCSMultiCommands(BaseTool):
Validate device command input and extract project_id and device_configs. Validate device command input and extract project_id and device_configs.
Args: Args:
tool_input: The input received from the LangChain/LangGraph tool call. tool_input: Input from the LangChain/LangGraph tool call.
Returns: Returns:
Tuple containing (device_configs_list, project_id) or (error_list, "") Tuple of (device_configs_list, project_id) or (error_list, "")
""" """
parsed_input = None parsed_input = None
@ -247,18 +265,23 @@ class VPCSMultiCommands(BaseTool):
parsed_input = json.loads(tool_input) parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.") logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e: 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}"}], "") return ([{"error": f"Invalid JSON input: {e}"}], "")
else: else:
# Handle standard models where the framework has already parsed the JSON. # Handle standard models where the framework has already parsed the JSON.
parsed_input = tool_input 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 # Validate input is a dictionary
if not isinstance(parsed_input, dict): if not isinstance(parsed_input, dict):
error_msg = ( error_msg = (
"Tool input must be a JSON object containing 'project_id' and 'device_configs', " "Tool input must be a JSON object containing 'project_id' "
f"but got {type(parsed_input).__name__}" f"and 'device_configs', but got {type(parsed_input).__name__}"
) )
logger.error(error_msg) logger.error(error_msg)
return ([{"error": error_msg}], "") return ([{"error": error_msg}], "")
@ -272,7 +295,7 @@ class VPCSMultiCommands(BaseTool):
# Validate project_id format # Validate project_id format
if not self._validate_project_id(project_id): 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) logger.error(error_msg)
return ([{"error": error_msg}], "") return ([{"error": error_msg}], "")
@ -285,7 +308,10 @@ class VPCSMultiCommands(BaseTool):
# Validate device_configs is a list # Validate device_configs is a list
if not isinstance(device_configs, 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) logger.error(error_msg)
return ([{"error": error_msg}], "") return ([{"error": error_msg}], "")
@ -297,24 +323,32 @@ class VPCSMultiCommands(BaseTool):
# Validate each item in device_configs # Validate each item in device_configs
for i, item in enumerate(device_configs): for i, item in enumerate(device_configs):
if not isinstance(item, dict): 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) logger.error(error_msg)
return ([{"error": error_msg}], "") return ([{"error": error_msg}], "")
# Validate required fields in each device config # Validate required fields in each device config
if "device_name" not in item: 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) logger.error(error_msg)
return ([{"error": error_msg}], "") return ([{"error": error_msg}], "")
if "commands" not in item: 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) logger.error(error_msg)
return ([{"error": error_msg}], "") return ([{"error": error_msg}], "")
if not isinstance(item["commands"], list): if not isinstance(item["commands"], list):
error_msg = ( 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) logger.error(error_msg)
return ([{"error": error_msg}], "") return ([{"error": error_msg}], "")
@ -333,13 +367,14 @@ class VPCSMultiCommands(BaseTool):
**kwargs: Any, **kwargs: Any,
) -> list[dict[str, 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 VPCS devices are lightweight virtual machines that simulate basic PC network
basic PC network functionality for lab testing. This is NOT network device configuration. functionality for lab testing. This is NOT network device configuration.
Args: 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: Returns:
List of execution results for each VPCS device 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) device_configs, project_id = self._validate_tool_input(tool_input)
# Check if validation returned an error # 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 return device_configs
# Extract all device names from input using set comprehension # Extract all device names from input using set comprehension
device_names = {config["device_name"] for config in device_configs} device_names = {config["device_name"] for config in device_configs}
# Get device port mapping with project_id # 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( logger.info(
"Retrieved port mappings for %d devices: %s", "Retrieved port mappings for %d devices: %s",
len(device_ports), len(device_ports),
@ -400,7 +441,8 @@ class VPCSMultiCommands(BaseTool):
error_count = sum(1 for r in results if r.get("status") == "error") error_count = sum(1 for r in results if r.get("status") == "error")
logger.info( 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), len(results),
success_count, success_count,
error_count, error_count,

View File

@ -26,7 +26,7 @@
GNS3-Copilot Public Model Package 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. It contains common functionality that can be shared across different tools and modules.
Main modules: Main modules:

View File

@ -32,7 +32,6 @@ availability.
""" """
import logging import logging
import os
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,6 +44,7 @@ def _get_gns3_copilot_root() -> Path:
# Go up to the gns3_copilot directory (utils parent) # Go up to the gns3_copilot directory (utils parent)
return current_file.parent.parent return current_file.parent.parent
# Default forbidden commands (fallback if file not found) # Default forbidden commands (fallback if file not found)
DEFAULT_FORBIDDEN_COMMANDS = [ DEFAULT_FORBIDDEN_COMMANDS = [
"traceroute", "traceroute",
@ -101,7 +101,10 @@ def _load_forbidden_commands() -> list[str]:
forbidden_commands.append(line.lower()) forbidden_commands.append(line.lower())
if not forbidden_commands: 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() _forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy()
else: else:
logger.info( logger.info(
@ -135,7 +138,9 @@ def reload_forbidden_commands() -> None:
""" """
global _forbidden_commands_cache global _forbidden_commands_cache
_forbidden_commands_cache = None _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]: def get_forbidden_commands() -> list[str]:

View File

@ -57,7 +57,9 @@ def get_device_ports_from_topology(
Devices that don't exist or missing console_port will not be included Devices that don't exist or missing console_port will not be included
""" """
# Log received parameters # 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: try:
# Lazy import to avoid circular dependency # Lazy import to avoid circular dependency
@ -77,7 +79,9 @@ def get_device_ports_from_topology(
for device_name in device_names: for device_name in device_names:
# Check if device exists in topology # Check if device exists in topology
if device_name not in topology.get("nodes", {}): 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 continue
node_info = topology["nodes"][device_name] node_info = topology["nodes"][device_name]
@ -99,15 +103,30 @@ def get_device_ports_from_topology(
# Use defaults if not found in tags # Use defaults if not found in tags
if device_type is None: if device_type is None:
device_type = "cisco_ios_telnet" 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: 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: if platform is None:
platform = "cisco_ios" 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: 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 # Add device to hosts_data
hosts_data[device_name] = { hosts_data[device_name] = {

View File

@ -135,9 +135,14 @@ def calculate_two_node_shape(
node2_center_x = node2["x"] + (node2_width / 2) node2_center_x = node2["x"] + (node2_width / 2)
node2_center_y = node2["y"] + (node2_height / 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_deg = round(math.degrees(angle_rad))
angle_rad = math.radians(angle_deg) angle_rad = math.radians(angle_deg)
@ -157,10 +162,16 @@ def calculate_two_node_shape(
shape_width = rx * 2 shape_width = rx * 2
shape_height = ry * 2 shape_height = ry * 2
svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad)) svg_x = center_x - (
svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad)) 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 offset_distance = ry * text_offset_ratio
@ -177,12 +188,22 @@ def calculate_two_node_shape(
else: # rectangle else: # rectangle
shape_width = distance 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_x = center_x - (
svg_y = center_y - ((shape_width / 2) * math.sin(angle_rad) + (shape_height / 2) * math.cos(angle_rad)) (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 offset_distance = (shape_height / 2) * text_offset_ratio
@ -238,7 +259,7 @@ def generate_ellipse_svg(
svg_height: int, svg_height: int,
) -> str: ) -> str:
"""Generate SVG for ellipse.""" """Generate SVG for ellipse."""
return f"""<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>""" return f"""<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>""" # noqa: E501
def generate_rectangle_svg( def generate_rectangle_svg(
@ -247,7 +268,7 @@ def generate_rectangle_svg(
color_scheme: dict[str, Any], color_scheme: dict[str, Any],
) -> str: ) -> str:
"""Generate SVG for rectangle.""" """Generate SVG for rectangle."""
return f"""<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>""" return f"""<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>""" # noqa: E501
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str: 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_width = len(text) * 8 + 20
text_height = DEFAULT_FONT_SIZE + 16 text_height = DEFAULT_FONT_SIZE + 16
return f"""<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>""" return f"""<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>""" # noqa: E501
def _hsv_to_hex(h: int, s: int, v: int) -> str: 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"] return COLOR_SCHEMES["NORMAL_AREA"]
# 3. Logical Isolation # 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"] return COLOR_SCHEMES["ISOLATION"]
# 4. High Availability # 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"] return COLOR_SCHEMES["HIGH_AVAILABILITY"]
# 5. External/Internet # 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"] return COLOR_SCHEMES["EXTERNAL"]
# 6. Management # 6. Management
@ -353,7 +392,12 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
return COLOR_SCHEMES["SECURITY_TRUSTED"] return COLOR_SCHEMES["SECURITY_TRUSTED"]
# 8. Cloud/Tunnel # 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"] return COLOR_SCHEMES["CLOUD_TUNNEL"]
# Legacy keyword matching for backward compatibility # 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". 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 { return {
"ellipse": result["shape"], "ellipse": result["shape"],
"text": result["text"], "text": result["text"],
@ -411,7 +457,9 @@ def calculate_two_node_rectangle(
Wrapper around calculate_two_node_shape with shape_type="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"] metadata = result["metadata"]
return { return {
"rectangle": result["shape"], "rectangle": result["shape"],

View File

@ -30,7 +30,9 @@ This module provides utility functions to retrieve LLM model configurations
with decrypted API keys by directly accessing the database. with decrypted API keys by directly accessing the database.
Usage: 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) # Get user's default LLM config (with API key)
config = await get_user_llm_config_with_app(user_id, app) config = await get_user_llm_config_with_app(user_id, app)
@ -51,7 +53,9 @@ from fastapi import FastAPI
logger = logging.getLogger(__name__) 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. 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 return config
except Exception as e: 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 return None

View File

@ -55,7 +55,8 @@ def convert_langchain_to_openai(lc_message) -> Dict[str, Any]:
Convert LangChain message to OpenAI-compatible format. Convert LangChain message to OpenAI-compatible format.
Args: Args:
lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage, ToolMessage) lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage,
ToolMessage)
Returns: Returns:
Dictionary in OpenAI-compatible format 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())), "id": tc_dict.get("id", str(uuid.uuid4())),
"type": "function", "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 msg["tool_calls"] = tool_calls
@ -149,7 +153,11 @@ def convert_openai_to_langchain(msg: Dict[str, Any]):
return ai_msg return ai_msg
elif role == "tool": 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": elif role == "system":
return SystemMessage(content=content) 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", "") content = getattr(chunk, "content", "")
if 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 # Check for tool call chunks
if hasattr(chunk, "tool_call_chunks") and chunk.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": { "tool_call": {
"id": tc_id, "id": tc_id,
"type": "function", "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": 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": elif event_type == "on_tool_end":
tool_output = event.get("data", {}).get("output", "") tool_output = event.get("data", {}).get("output", "")

View File

@ -85,12 +85,14 @@ def parse_tool_content(
by json.dumps. by json.dumps.
Args: 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 fallback_to_raw: Whether to return raw content when parsing fails, default True
strict_mode: Strict mode, raises exceptions when parsing fails, default False strict_mode: Strict mode, raises exceptions when parsing fails, default False
Returns: 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 - Successfully parsed JSON/Python literal data
- Original dict/list objects (passed through) - Original dict/list objects (passed through)
- Primitive types (int, float, bool, str) - Primitive types (int, float, bool, str)
@ -158,7 +160,9 @@ def parse_tool_content(
# Empty string handling # Empty string handling
if not content.strip(): if not content.strip():
result = {} result = {}
logger.info("Content is empty or whitespace, returning: %s", result) logger.info(
"Content is empty or whitespace, returning: %s", result
)
return result return result
s = content.strip() s = content.strip()
@ -166,14 +170,19 @@ def parse_tool_content(
# Handle empty dictionary case # Handle empty dictionary case
if s == "{}": if s == "{}":
result = {} result = {}
logger.info("Content is empty dictionary, returning: %s", result) logger.info(
"Content is empty dictionary, returning: %s", result
)
return result return result
# Try to parse as Python literal # Try to parse as Python literal
# (higher priority as many tools return Python format strings) # (higher priority as many tools return Python format strings)
try: try:
result = ast.literal_eval(s) 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 return result
except (ValueError, SyntaxError): except (ValueError, SyntaxError):
pass pass
@ -181,7 +190,9 @@ def parse_tool_content(
# Try to parse as JSON # Try to parse as JSON
try: try:
result = json.loads(s) result = json.loads(s)
logger.info("Successfully parsed as JSON, returning: %s", result) logger.info(
"Successfully parsed as JSON, returning: %s", result
)
return result return result
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
@ -210,7 +221,8 @@ def parse_tool_content(
# Handle unsupported types # Handle unsupported types
error_msg = ( # type: ignore[unreachable] 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) logger.error(error_msg)
@ -226,7 +238,9 @@ def parse_tool_content(
return result 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. 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. properly displayed in UI interfaces.
Args: 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 indent: JSON indentation spaces, default 2
Returns: 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) logger.info("format_tool_response parameter indent: %s", indent)
try: 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 # Ensure the result can be serialized to JSON
result = json.dumps(parsed, ensure_ascii=False, indent=indent) result = json.dumps(parsed, ensure_ascii=False, indent=indent)
logger.info("format_tool_response returning: %s", result) 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 # If the parsed result cannot be serialized, convert to string and wrap
logger.error("Cannot serialize parsed result to JSON: %s", e) logger.error("Cannot serialize parsed result to JSON: %s", e)
try: 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) logger.info("format_tool_response returning fallback: %s", result)
return result return result
except Exception: except Exception:
@ -267,12 +286,16 @@ def format_tool_response(content: str | dict | list | int | float | bool | None,
return result return result
except Exception as e: except Exception as e:
logger.error("Error formatting tool response: %s", 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) logger.info("format_tool_response returning error: %s", result)
return 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. 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: Examples:
>>> normalize_tool_response({"status": "success", "output": "OK"}) >>> 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"}]) >>> 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 # 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 { return {
"success": False, "success": False,
"total": 0, "total": 0,
@ -319,29 +351,56 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
# Handle empty responses # Handle empty responses
if not response: 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) # Handle list responses (most tools return list of device results)
if isinstance(response, list): 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 failed = len(response) - successful
normalized_data = [] normalized_data = []
for item in response: for item in response:
if isinstance(item, dict): if isinstance(item, dict):
normalized_item = { 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 "", "name": item.get("device_name") or item.get("name") or "",
"status": item.get("status", "unknown"), "status": item.get("status", "unknown"),
} }
if normalized_item["status"] == "success": 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: 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) normalized_data.append(normalized_item)
else: else:
# Non-dict items in list # 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 { return {
"success": failed == 0, "success": failed == 0,
@ -367,8 +426,14 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
} }
# Legacy format: extract common fields # Legacy format: extract common fields
total = response.get("total_nodes") or response.get("total") or response.get("count", 1) total = (
successful = response.get("successful_nodes") or response.get("successful") or 0 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 failed = response.get("failed_nodes") or response.get("failed") or 0
# Extract data from various possible locations # 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: if not data and "status" in response:
data = [ data = [
{ {
"name": response.get("device_name") or response.get("name") or "", "name": response.get("device_name")
or response.get("name")
or "",
"status": response["status"], "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 "", "error": response.get("error") or "",
} }
] ]
@ -425,14 +494,23 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
"total": 1, "total": 1,
"successful": 1, "successful": 1,
"failed": 0, "failed": 0,
"data": [{"id": "", "name": "", "status": "unknown", "result": str(response)}], "data": [
{
"id": "",
"name": "",
"status": "unknown",
"result": str(response),
}
],
"metadata": metadata, "metadata": metadata,
} }
# Test function to verify the implementation # Test function to verify the implementation
def _test_parse_tool_content() -> None: 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]] = [ test_cases: list[tuple[Any, Any]] = [
# String inputs # String inputs
( (
@ -486,7 +564,9 @@ def _test_parse_tool_content() -> None:
valid = "✓" valid = "✓"
except Exception: except Exception:
valid = "✗" 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__": if __name__ == "__main__":

View File

@ -79,10 +79,11 @@ class ToolCallStreamAccumulator:
tc_id = getattr(tool_call, "id", None) tc_id = getattr(tool_call, "id", None)
tc_name = getattr(tool_call, "name", "") 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: if tc_id:
# Initialize current tool state (this is the only time to get ID) # Initialize current tool state (this is the only time to
# Note: only one tool can be called at a time # get ID). Note: only one tool can be called at a time
self._current_tool_call = { self._current_tool_call = {
"id": tc_id, "id": tc_id,
"name": tc_name if tc_name else "UNKNOWN_TOOL", "name": tc_name if tc_name else "UNKNOWN_TOOL",
@ -90,19 +91,24 @@ class ToolCallStreamAccumulator:
} }
# Send initial tool_call event with empty args # Send initial tool_call event with empty args
chunks.append({ chunks.append(
"type": "tool_call", {
"tool_call": { "type": "tool_call",
"id": tc_id, "tool_call": {
"type": "function", "id": tc_id,
"function": { "type": "function",
"name": self._current_tool_call["name"], "function": {
"arguments": "" "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 # Concatenate parameter strings from tool_call_chunk
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks: if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
if self._current_tool_call: if self._current_tool_call:
@ -116,43 +122,58 @@ class ToolCallStreamAccumulator:
# Core: string concatenation # Core: string concatenation
if isinstance(args_chunk, str): 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 # Send updated tool_call event with accumulated args
chunks.append({ chunks.append(
"type": "tool_call", {
"tool_call": { "type": "tool_call",
"id": tool_data["id"], "tool_call": {
"type": "function", "id": tool_data["id"],
"function": { "type": "function",
"name": tool_data["name"], "function": {
"arguments": tool_data["args_string"] "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" # Check finish_reason == "tool_calls" or "STOP"
response_metadata = getattr(chunk, "response_metadata", {}) 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: if self._current_tool_call:
tool_data = self._current_tool_call tool_data = self._current_tool_call
# Send final complete tool_call event # Send final complete tool_call event
chunks.append({ chunks.append(
"type": "tool_call", {
"tool_call": { "type": "tool_call",
"id": tool_data["id"], "tool_call": {
"type": "function", "id": tool_data["id"],
"function": { "type": "function",
"name": tool_data["name"], "function": {
"arguments": tool_data["args_string"], "name": tool_data["name"],
"complete": True # Mark as complete "arguments": tool_data["args_string"],
} "complete": True, # Mark as complete
},
},
} }
}) )
# Clear the current tool call state # Clear the current tool call state
self._current_tool_call = None self._current_tool_call = None
@ -160,11 +181,13 @@ class ToolCallStreamAccumulator:
# Also handle regular content (when not in tool call mode) # Also handle regular content (when not in tool call mode)
content = getattr(chunk, "content", "") content = getattr(chunk, "content", "")
if content and not self._current_tool_call: if content and not self._current_tool_call:
chunks.append({ chunks.append(
"type": "content", {
"content": content, "type": "content",
"message_id": event.get("metadata", {}).get("msg_id") "content": content,
}) "message_id": event.get("metadata", {}).get("msg_id"),
}
)
return chunks return chunks