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