diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py index efd759a6c..b69fbcc73 100644 --- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -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"], diff --git a/gns3server/agent/gns3_copilot/agent_service.py b/gns3server/agent/gns3_copilot/agent_service.py index 7234872c5..0392c91d1 100644 --- a/gns3server/agent/gns3_copilot/agent_service.py +++ b/gns3server/agent/gns3_copilot/agent_service.py @@ -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: diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py index d5db7bde3..7edf136aa 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -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 diff --git a/gns3server/api/routes/controller/chat.py b/gns3server/api/routes/controller/chat.py index 5832793d9..596af59b6 100644 --- a/gns3server/api/routes/controller/chat.py +++ b/gns3server/api/routes/controller/chat.py @@ -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: