feat(agent): add comprehensive logging to LLM and tool execution nodes

- Add info and debug logging to llm_call node for tracking LLM invocations and configuration
- Add error handling and logging to tool_node for tool execution failures
- Add startup logging to stream_chat method with session details
- Improve observability of agent workflow and debugging capabilities
This commit is contained in:
YueGuobin 2026-03-04 15:46:12 +08:00
parent 1e9cf3fcbb
commit a278a6091c
4 changed files with 94 additions and 10 deletions

View File

@ -128,6 +128,8 @@ class MessagesState(TypedDict):
def llm_call(state: dict, config: RunnableConfig | None = None):
"""LLM decides whether to call a tool or not"""
logger.info("LLM call node invoked")
# Get llm_config from request-scoped context variable
from gns3server.agent.gns3_copilot.gns3_client import get_current_llm_config
llm_config = get_current_llm_config()
@ -140,9 +142,13 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
"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),
@ -216,13 +222,21 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
# print(full_messages)
# Create fresh model with tools for each LLM call
logger.debug("Creating model with tools: provider=%s, model=%s",
llm_config.get("provider"), llm_config.get("model"))
model_with_tools = create_base_model_with_tools(
tools,
llm_config=llm_config
)
logger.info("Invoking LLM with %d messages", len(full_messages))
response = model_with_tools.invoke(full_messages)
logger.info("LLM call completed: tool_calls=%d",
len(response.tool_calls) if hasattr(response, 'tool_calls') else 0)
return {
"messages": [model_with_tools.invoke(full_messages)],
"messages": [response],
"llm_calls": state.get("llm_calls", 0) + 1,
"topology_info": topology_info,
}
@ -317,10 +331,21 @@ def generate_title(state: MessagesState, config: RunnableConfig | None = None) -
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 state["messages"][-1].tool_calls:
tool = tools_by_name[tool_call["name"]]
observation = tool.invoke(tool_call["args"])
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"],

View File

@ -123,13 +123,24 @@ class AgentService:
Yields:
Dict containing SSE-compatible response chunks
"""
log.info(
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s",
project_id,
user_id,
session_id,
mode,
)
# Set request-scoped context variables (memory-only, not persisted)
if jwt_token:
from gns3server.agent.gns3_copilot.gns3_client import set_current_jwt_token
set_current_jwt_token(jwt_token)
log.debug("JWT token set in context")
if llm_config:
from gns3server.agent.gns3_copilot.gns3_client import set_current_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"))
# Build config - only thread-safe identifiers
config = {
@ -152,12 +163,14 @@ class AgentService:
# Get the compiled graph
graph = await self._get_graph()
log.debug("LangGraph graph obtained, starting stream")
# Stream events
try:
async for event in graph.astream_events(inputs, config=config, version="v2"):
chunk = self._convert_event_to_chunk(event, session_id)
if chunk:
log.debug("Yielding chunk: type=%s", chunk.get("type"))
yield chunk
except Exception as e:

View File

@ -30,6 +30,8 @@ import logging
from typing import Optional
from contextvars import ContextVar
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)
@ -39,27 +41,39 @@ _llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_contex
def set_current_jwt_token(token: str) -> None:
"""Set the JWT token for the current request context."""
_jwt_token_context.set(token)
logger.debug("JWT token set in context")
def get_current_jwt_token() -> Optional[str]:
"""Get the JWT token for the current request context."""
return _jwt_token_context.get()
token = _jwt_token_context.get()
if token:
logger.debug("JWT token retrieved from context")
else:
logger.warning("JWT token not found in context")
return token
def set_current_llm_config(config: dict) -> None:
"""Set the LLM config for the current request context."""
_llm_config_context.set(config)
logger.debug("LLM config set in context: provider=%s, model=%s",
config.get("provider"), config.get("model"))
def get_current_llm_config() -> Optional[dict]:
"""Get the LLM config for the current request context."""
return _llm_config_context.get()
config = _llm_config_context.get()
if config:
logger.debug("LLM config retrieved from context: provider=%s, model=%s",
config.get("provider"), config.get("model"))
else:
logger.warning("LLM config not found in context")
return config
from uuid import UUID
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
logger = logging.getLogger(__name__)
# Fallback default URL
DEFAULT_GNS3_URL = "http://127.0.0.1:3080"
@ -316,17 +330,22 @@ def _detect_url_for_api() -> Optional[str]:
Returns:
URL string, or None if detection failed
"""
logger.debug("Detecting GNS3 server URL for API calls")
# Try Controller first
url = _get_url_from_controller()
if url:
logger.debug("Using URL from Controller for API call: %s", url)
return url
# Try Config
url = _get_url_from_config()
if url:
logger.debug("Using URL from Config for API call: %s", url)
return url
# Fallback
logger.debug("Using fallback URL for API call: %s", DEFAULT_GNS3_URL)
return DEFAULT_GNS3_URL

View File

@ -86,15 +86,29 @@ async def stream_chat(
The project must be opened to use chat functionality.
"""
# Get user authentication info
user_id = str(current_user.user_id)
# Check if project is opened
if project.status != "opened":
log.warning(
"Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s",
user_id,
project.id,
project.status
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to use chat. Current status: {project.status}"
)
# Get user authentication info
user_id = str(current_user.user_id)
log.info(
"Chat request started: user_id=%s, project_id=%s, project_name=%s, session_id=%s",
user_id,
project.id,
project.name,
request.session_id or "(new)",
)
# Get JWT token from Authorization header
auth_header = http_request.headers.get("Authorization", "")
@ -107,21 +121,33 @@ async def stream_chat(
from gns3server.db.tasks import get_user_llm_config_full
llm_config = await get_user_llm_config_full(user_id, app)
if not llm_config:
log.warning("LLM config not found for user: %s", user_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="LLM configuration not found. Please configure your LLM settings first."
)
log.debug(
"LLM config loaded: user_id=%s, provider=%s, model=%s",
user_id,
llm_config.get("provider"),
llm_config.get("model"),
)
# Get or create AgentService for this project
agent_manager = await get_project_agent_manager()
agent_service = await agent_manager.get_agent(str(project.id), project.path)
log.debug("AgentService obtained for project: %s", project.id)
# Generate session_id if not provided
session_id = request.session_id or str(uuid.uuid4())
if not request.session_id:
log.debug("New session created: %s", session_id)
async def generate():
"""Generator for SSE streaming."""
try:
log.debug("Starting stream: session_id=%s", session_id)
async for chunk in agent_service.stream_chat(
message=request.message,
session_id=session_id,
@ -141,6 +167,7 @@ async def stream_chat(
continue
# Final done message
log.debug("Stream completed: session_id=%s", session_id)
yield f"data: {json.dumps({'type': 'done', 'session_id': session_id})}\n\n"
except Exception as e: