mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-06 07:11:13 +02: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:
1 parent
25f2d1b56b
commit
7c3b832bca
32 files changed
+1967
-762
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user