style: fix E501 line-too-long errors in gns3_copilot

Fix all 423 E501 line length violations across 26 files to comply with
   PEP 8 88-character line limit.

   Changes:
   - Split long f-strings across multiple lines
   - Break long docstring descriptions and parameter lists
   - Split markdown table rows and list examples
   - Break long URL construction f-strings
   - Split long logger messages and comments
   - Add noqa: E501 for SVG strings (cannot be split)

   Modified files:
   - agent/: context_manager.py, gns3_copilot.py, model_factory.py
   - gns3_client/: connector_factory.py, context_helpers.py, custom_gns3fy.py,
                   gns3_project_info.py, gns3_topology_reader.py
   - prompts/: __init__.py, lab_automation_assistant_prompt.py,
               prompt_loader.py, teaching_assistant_prompt.py
   - tools_v2/: __init__.py, config_tools_nornir.py, display_tools_nornir.py,
                gns3_create_link.py, gns3_create_node.py, gns3_get_node_temp.py,
                gns3_start_node.py, gns3_update_node_name.py,
                vpcs_tools_telnetlib3.py
   - utils/: __init__.py, command_filter.py, get_gns3_device_port.py,
             gns3_drawing_utils.py, llm_config_helper.py, message_converters.py,
             parse_tool_content.py, tool_call_stream.py

   All files now pass ruff E501 checks.

   Co-Authored-By: Yue Guobin <yueguobin@outlook.com>
This commit is contained in:
YueGuobin committed 2026-03-10 01:05:17 +08:00
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",