YueGuobin 54775faaf4 feat(copilot): rename copilot modes for clarity and add mode-specific tools
- Rename "teaching" mode to "teaching_assistant" for better clarity
- Rename "lab_assistant" mode to "lab_automation_assistant" to reflect expanded capabilities
- Implement mode-specific tool sets: teaching_assistant gets read-only diagnostic tools only, while lab_automation_assistant gets full diagnostic and configuration tools
- Update API documentation examples to reflect new mode names
- Maintain backward compatibility with default tool set initialization
2026-03-05 21:44:36 +08:00

465 lines
18 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Guobin Yue
# Author: Guobin Yue
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
# mypy: ignore-errors
"""
GNS3 Network Automation Assistant - LangGraph Agent
This module implements the core LangGraph agent workflow for GNS3-Copilot,
an AI-powered assistant for GNS3 network automation and management.
The agent provides:
- LangGraph-based state management and workflow
- Mode-aware tool orchestration for GNS3 operations
- Context-aware conversation handling
- Automatic conversation title generation
- Integration with GNS3 topology management
Copilot Modes:
- "teaching_assistant" (default): Diagnostic tools only, no configuration changes
- "lab_automation_assistant": Full diagnostic and configuration tools
"""
# Standard library imports
import logging
import operator
from typing import Annotated
from typing import Literal
# Third-party imports
from langchain.messages import AnyMessage
from langchain.messages import SystemMessage
from langchain.messages import ToolMessage
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END
from langgraph.graph import START
from langgraph.graph import StateGraph
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.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.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 GNS3CreateNodeTool
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool
from gns3server.agent.gns3_copilot.tools_v2 import GNS3TemplateTool
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
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.
# Define tools for different copilot modes
# Teaching assistant mode: READ-ONLY diagnostic tools only
TEACHING_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
GNS3StartNodeTool(), # Start GNS3 nodes
GNS3UpdateNodeNameTool(), # Update node name
ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands (READ-ONLY)
]
# Lab automation assistant mode: Full diagnostic AND configuration tools
LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
GNS3StartNodeTool(), # Start GNS3 nodes
GNS3UpdateNodeNameTool(), # Update node name
ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands (READ-ONLY)
ExecuteMultipleDeviceConfigCommands(), # Execute configuration commands
VPCSMultiCommands(), # Execute VPCS commands on multiple devices
]
# Default tools (legacy support - will be overridden by mode-specific 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
ALL_TOOLS = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
tools_by_name = {tool.name: tool for tool in ALL_TOOLS}
# Log application startup
logger.info("GNS3-Copilot application starting up")
# Constants for conversation title management
DEFAULT_CONVERSATION_TITLE = "New Conversation"
UNTITLED_SESSION_FALLBACK = "Untitled Session"
TITLE_MAX_LENGTH = 40
# Define state
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.
Attributes:
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
topology_info: Dictionary containing GNS3 project topology information
"""
messages: Annotated[list[AnyMessage], operator.add]
llm_calls: int
remaining_steps: RemainingSteps
# Optional conversation title
conversation_title: str | None
# Store GNS3 topology information
topology_info: dict | None
# Define llm call node
def llm_call(state: dict, config: RunnableConfig | None = None):
"""
LLM decides whether to call a tool or not.
Uses pre_model_hook pattern for automatic topology injection and
message trimming, ensuring separation of concerns and complete
history preservation in state["messages"].
"""
logger.info("LLM call node invoked")
# Get llm_config from request-scoped context variable
llm_config = get_current_llm_config()
if not llm_config:
logger.error("LLM config not found in context")
return {
"messages": [],
"llm_calls": state.get("llm_calls", 0),
"topology_info": None,
}
logger.debug(
"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
messages = state.get("messages", [])
if not messages or len(messages) == 0:
logger.warning("No messages in state, skipping LLM call")
return {
"messages": [],
"llm_calls": state.get("llm_calls", 0),
"topology_info": None,
}
# Get project_id from config configurable (set when starting the chat)
project_id = None
topology_info = None
if config and config.get("configurable"):
project_id = config["configurable"].get("project_id")
# Retrieve topology information if available
if project_id:
try:
topology_tool = GNS3TopologyTool()
topology = topology_tool._run(project_id=project_id)
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")
)
else:
logger.warning(
"Failed to retrieve topology for project_id %s: %s",
project_id,
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)
# Store topology_info in state for pre_model_hook to access
state["topology_info"] = topology_info
# Select tools based on copilot_mode
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)")
else: # teaching_assistant mode (default)
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS
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
system_prompt = load_system_prompt(llm_config)
pre_hook = create_pre_model_hook(
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
)
# Create fresh model with tools for each LLM call
logger.debug(
"Creating model with tools: provider=%s, model=%s, mode=%s, tools=%d",
llm_config.get("provider"),
llm_config.get("model"),
copilot_mode,
len(mode_tools),
)
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
logger.info("Calling pre_hook to prepare %d messages", len(messages))
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))
# Invoke model with prepared messages
response = model_with_tools.invoke(prepared_messages)
logger.info("LLM call completed: tool_calls=%d", len(response.tool_calls) if hasattr(response, "tool_calls") else 0)
return {
"messages": [response],
"llm_calls": state.get("llm_calls", 0) + 1,
"topology_info": topology_info,
}
# Define generate title node
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).
"""
# Get llm_config from request-scoped context variable
llm_config = get_current_llm_config()
if not llm_config:
logger.error("LLM config not found in context, cannot generate title")
return {"conversation_title": UNTITLED_SESSION_FALLBACK}
# Only generate a title if it hasn't been set yet
current_title = state.get("conversation_title")
if current_title in [None, "New Conversation"]:
logger.info("Title generation triggered for session")
messages = state["messages"]
# Build the prompt for title generation
title_prompt_messages = [
SystemMessage(content=TITLE_PROMPT),
messages[0], # User's first message
messages[-1], # Assistant's final response in this turn
]
# 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}})
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}'")
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("'", "")
logger.info("Generated new title: %s", new_title)
return {"conversation_title": new_title}
except Exception as e:
logger.error(f"Title generation failed: {e}, using fallback")
# Improved fallback: Use user's first message content
if messages and len(messages) > 0:
first_message = messages[0]
if hasattr(first_message, "content"):
fallback_title = first_message.content[:30].strip()
# Remove newlines and extra spaces
fallback_title = fallback_title.replace("\n", " ").strip()
# Collapse multiple spaces
while " " in fallback_title:
fallback_title = fallback_title.replace(" ", " ")
# Truncate if needed
if len(fallback_title) > 28:
fallback_title = fallback_title[:28] + ".."
if fallback_title:
logger.info(f"Using fallback title from user message: '{fallback_title}'")
return {"conversation_title": fallback_title}
# Final fallback
logger.info(f"Using final fallback title: '{UNTITLED_SESSION_FALLBACK}'")
return {"conversation_title": UNTITLED_SESSION_FALLBACK}
# Title already exists → no update needed
return {}
# Define tool node
def tool_node(state: dict, config: RunnableConfig | None = None):
"""Performs the tool call"""
tool_calls = state["messages"][-1].tool_calls
logger.info("Tool node invoked: tool_calls=%d", len(tool_calls))
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"])
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)
except Exception as e:
logger.error("Tool %s failed: %s", tool_name, e, exc_info=True)
observation = f"Error: {str(e)}"
result.append(ToolMessage(content=observation, tool_call_id=tool_call["id"], name=tool_call["name"]))
return {"messages": result}
# Routing logic after the LLM node
def should_continue(
state: MessagesState,
) -> Literal["tool_node", "title_generator_node", END]:
"""
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
- Otherwise → conversation is complete, go to END
"""
last_message = state["messages"][-1]
current_title = state.get("conversation_title")
# LLM requested one or more tool executions
if last_message.tool_calls:
return "tool_node"
# First full interaction completed and title not yet generated
if current_title in [None, "New Conversation"]:
return "title_generator_node"
# Normal completion (multi-turn conversation or title already exists)
return END
# Routing logic after the tool node, Check remaining_steps
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.
Args:
state: Current conversation state with messages and remaining steps
Returns:
"llm_call" to continue processing, END to terminate conversation
Logic:
- If the last message is ToolMessage and steps >= 4: continue to LLM
- Otherwise: end the conversation to prevent infinite loops
"""
last_message = state["messages"][-1]
if isinstance(last_message, ToolMessage):
if state["remaining_steps"] < 4:
return END
return "llm_call"
return END
# Build and compile the agent
# Build workflow
agent_builder = StateGraph(MessagesState)
# Add nodes
agent_builder.add_node("llm_call", llm_call)
agent_builder.add_node("tool_node", tool_node)
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
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
END: END, # End conversation if no tools needed
},
)
# Conditional routing after tool execution
# Prevents infinite recursion by checking remaining steps before continuing
agent_builder.add_conditional_edges(
"tool_node",
recursion_limit_continue,
{
"llm_call": "llm_call", # Continue to LLM if tools executed and steps remain
END: END, # End conversation to prevent infinite loops
},
)
agent_builder.add_edge("title_generator_node", END)