> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt. # GNS3 Copilot Agent Chat API ## Overview This document describes the implementation of the GNS3 Copilot Chat API. This API enables clients to interact with the GNS3 Copilot Agent through a RESTful interface, providing streaming conversations, session management, session abort, and project topology queries. ## Core Features - **Project-level Isolation**: Each GNS3 project has its own Agent instance and session storage - **Streaming Responses**: Uses Server-Sent Events (SSE) for real-time streaming output - **Session Management**: Supports session listing, renaming, deletion, pinning, and history queries - **Session Abort**: Supports aborting an ongoing streaming session mid-conversation - **Statistics Tracking**: Automatically records message counts, LLM call counts, and token usage - **Copilot Modes**: Supports `teaching_assistant` (diagnostic only) and `lab_automation_assistant` (full config) modes - **User Isolation**: Each user has independent LLM configurations and session spaces ## Architecture Design ### Overall Architecture ``` Frontend (Web UI) │ │ SSE Streaming ▼ FastAPI Chat API Routes │ │ Project-level Agent Management ▼ AgentService (per project) │ ├─ SQLite Checkpointer (project_dir/gns3-copilot/) │ ├─ checkpoints table (LangGraph state) │ └─ chat_sessions table (session metadata) │ └─ LangGraph Agent (StateGraph) ├─ llm_call node ├─ tool_node (GNS3 tools) ├─ title_generator_node (auto title) └─ abort_handler_node (abort handling) │ ├─ Conditional Edges (routing functions): │ ├─ should_continue (after llm_call) │ └─ recursion_limit_continue (after tool_node) │ └─ Copilot Modes: ├─ teaching_assistant (diagnostic tools only) └─ lab_automation_assistant (full diagnostic + config tools) ``` ### Project-level Checkpoint Design Each GNS3 project creates a `gns3-copilot/copilot_checkpoints.db` SQLite database in the project directory, containing two tables: 1. **checkpoints table** (managed by LangGraph): stores Agent conversation state and memory 2. **chat_sessions table** (custom): stores session metadata and statistics **Directory Structure**: ``` {project.path}/ ├── gns3-copilot/ │ └── copilot_checkpoints.db ├── project-files/ └── project.gns3 ``` **Design Advantages**: - All related data is automatically cleaned up when the project is deleted - Achieves project-level session isolation - Facilitates backup and migration ## User Authentication Information Passing ### Background Requirements GNS3 Copilot Agent requires the following information to work properly: 1. **user_id**: Get user-specific LLM configuration 2. **jwt_token**: Authenticate when calling GNS3 API 3. **llm_config**: Contains provider, model, api_key, etc. ### ContextVars Solution Uses Python's `contextvars.ContextVar` to pass data within request scope, avoiding persisting sensitive information to checkpoint. **Data Flow**: ``` 1. API layer gets user information ├─ Get user_id from FastAPI get_current_active_user ├─ Extract jwt_token from Authorization header └─ Query LLM configuration from database (API key already decrypted) 2. Set ContextVars (temporary in-memory storage) ├─ set_current_jwt_token(jwt_token) └─ set_current_llm_config(llm_config) 3. Build secure LangGraph config (only contains non-sensitive identifiers) { "configurable": { "thread_id": session_id, "project_id": project_id }, "metadata": { "user_id": user_id } } 4. LLM node gets configuration from ContextVars ├─ get_current_jwt_token() └─ get_current_llm_config() ``` **Solution Advantages**: - Sensitive data (JWT token, API key) only stored in memory - Automatically cleared after request ends, not persisted to database - Avoids serialization/deserialization overhead - Achieves request-level data isolation ## Session Management ### chat_sessions Table Structure | Field | Type | Description | |-------|------|-------------| | id | INTEGER | Primary key (auto-increment) | | thread_id | TEXT | LangGraph thread_id (unique) | | user_id | TEXT | User ID | | project_id | TEXT | GNS3 project ID | | title | TEXT | Session title | | message_count | INTEGER | Number of messages | | llm_calls_count | INTEGER | Number of LLM calls | | input_tokens | INTEGER | Total input tokens | | output_tokens | INTEGER | Total output tokens | | total_tokens | INTEGER | Total tokens | | last_message_at | TIMESTAMP | Last message time | | created_at | TIMESTAMP | Creation time | | updated_at | TIMESTAMP | Update time | | metadata | TEXT | Reserved metadata (JSON) | | stats | TEXT | Additional statistics (JSON) | | pinned | BOOLEAN | Whether pinned (default FALSE) | **Indexes**: - `idx_thread_id`: thread_id unique index - `idx_user_project`: user_id + project_id composite index - `idx_pinned_updated`: pinned + updated_at composite index (for pin sorting) ### Database Migration **Implementation Location**: `_create_chat_sessions_table` method in `agent_service.py` **Migration Strategy**: - Use `PRAGMA table_info(chat_sessions)` to check if columns exist - If `pinned` column doesn't exist, execute `ALTER TABLE ADD COLUMN` to add it - Ensure column exists before creating index **Code Example**: ```python # Check if pinned column exists, add it if not (migration for existing databases) cursor = await conn.execute("PRAGMA table_info(chat_sessions)") columns = await cursor.fetchall() column_names = [col[1] for col in columns] if "pinned" not in column_names: log.debug("Adding pinned column to existing chat_sessions table") await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE") await conn.commit() # Create pinned index (after column is guaranteed to exist) await conn.execute("CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)") ``` **Advantages**: - Backward compatible: existing databases automatically upgraded without manual intervention - Idempotent: repeated execution won't cause errors - Zero downtime: migration happens automatically during initialization ### ChatSessionsRepository Provides CRUD operations for sessions: - **create_session**: Create new session - **get_session_by_thread**: Query session by thread_id - **list_sessions**: List user's sessions (supports filtering and pagination, sorted by pinned and updated_at) - **update_session**: Update session (supports incremental counter updates) - **delete_session**: Delete session and its checkpoints - **delete_all_sessions**: Delete all sessions in project - **pin_session**: Pin or unpin session ### Automatic Statistics Collection Statistics are collected in real-time during conversation, and updated to `chat_sessions` table in one batch after streaming ends. **Implementation Location**: `stream_chat` method in `agent_service.py` **Statistics Logic**: 1. **message_count (number of messages)** - Initial value: 1 (user message) - `on_chat_model_end` event: +1 (AI complete reply, only counted once per turn via `ai_response_counted` flag, not each streaming chunk) - `on_tool_end` event: +1 (each tool execution result) 2. **llm_calls_count (number of LLM calls)** - Listen to `on_chat_model_start` event - +1 each time LLM starts generation - **Filtered**: `title_generator_node` events are excluded from count (internal use only) 3. **input_tokens (input tokens)** - Extracted from `usage_metadata` in `on_chat_model_end` event - Uses incremental addition (`+=`): each event's token count is added to the running total - **Filtered**: `title_generator_node` events are excluded from token counting - Tries multiple extraction methods: `response.usage_metadata` → `output.usage_metadata` → direct data fields 4. **output_tokens (output tokens)** - Extracted from `usage_metadata` in `on_chat_model_end` event - Uses incremental addition (`+=`): each event's token count is added to the running total - **Filtered**: `title_generator_node` events are excluded from token counting 5. **total_tokens (total tokens)** - Calculation formula: `input_tokens + output_tokens` (computed at update time) **Notes**: - message_count counts **complete messages**, not streaming chunks - `ai_response_counted` flag ensures AI responses are only counted once per turn, even if multiple `on_chat_model_end` events fire - `title_generator_node` is completely excluded from all statistics (llm_calls, tokens, streaming output) - Token data depends on LLM's returned `usage_metadata`, some models may not support - Statistics are incrementally updated to database via `update_session` method after stream ends (using SQL `field = field + ?` syntax) - **Message ID handling**: Assign ID when creating initial message (`HumanMessage(id=str(uuid4()))`), messages read from checkpoint without ID are also automatically generated - **Format conversion**: Use `message_converters.py` module to handle conversion between LangChain and OpenAI formats, ensuring tool_calls format conforms to OpenAI specification ### Automatic Title Synchronization Session title is automatically generated by `title_generator_node` node, saved in `conversation_title` field in LangGraph checkpoint. **Synchronization Mechanism**: 1. After streaming Chat completes, read final state from checkpoint 2. Check if `conversation_title` has changed 3. If changed, update to `chat_sessions` table **Advantages**: - Avoids accessing database directly in nodes (prevents circular dependencies) - All database updates concentrated after stream ends - Clear logic, easy to maintain ## SSE Message Format Chat API uses Server-Sent Events (SSE) for streaming transmission. ### Message Types | type | Description | Included Fields | |------|-------------|------------------| | content | AI text content (streaming) | content, message_id (optional), session_id | | tool_call | LLM decides to call tool (streaming, parameters accumulated gradually) | tool_call (object, includes id, type, function), session_id, message_id (optional) | | tool_start | Tool starts execution | tool_name, tool_call_id, session_id | | tool_end | Tool execution complete | tool_name, tool_output, session_id | | error | Error message | error, session_id | | abort | Stream aborted by user | session_id | | done | Stream end | session_id | | heartbeat | *(Planned)* Heartbeat keepalive | session_id | **Tool Output Format** (`tool_output` field): - If the tool returns a non-string type (dict, list), it is automatically serialized to JSON format using `json.dumps(obj, ensure_ascii=False, indent=2)` - If the tool returns a string type, it is passed through as-is - This ensures all structured data is in standard JSON format, making it easy for the frontend to parse with `JSON.parse()` - Chinese and other non-ASCII characters are preserved (not escaped to `\uXXXX`) ### Message Examples ```json // AI text streaming output {"type": "content", "content": "Hello! How can I help"} // LLM decides to call tool (streaming transmission, parameters accumulated gradually) // 1st chunk: tool call starts (parameters empty) { "type": "tool_call", "tool_call": { "id": "call_123", "type": "function", "function": {"name": "execute_multiple_device_commands", "arguments": ""} }, "session_id": "xxx" } // 2nd chunk: parameters accumulating { "type": "tool_call", "tool_call": { "id": "call_123", "type": "function", "function": {"name": "execute_multiple_device_commands", "arguments": "{\"device_names\": [\"R1\"], "} }, "session_id": "xxx" } // 3rd chunk: parameters accumulating { "type": "tool_call", "tool_call": { "id": "call_123", "type": "function", "function": {"name": "execute_multiple_device_commands", "arguments": "{\"device_names\": [\"R1\"], \"commands\": [\"show ver\"]}"} }, "session_id": "xxx" } // 4th chunk: parameters complete (mark complete=true) { "type": "tool_call", "tool_call": { "id": "call_123", "type": "function", "function": { "name": "execute_multiple_device_commands", "arguments": "{\"device_names\": [\"R1\"], \"commands\": [\"show ver\"]}", "complete": true } }, "session_id": "xxx" } // Tool starts execution (associated via tool_call_id) { "type": "tool_start", "tool_name": "execute_multiple_device_commands", "tool_call_id": "call_123", "session_id": "xxx" } // Tool execution complete { "type": "tool_end", "tool_name": "execute_multiple_device_commands", "tool_output": "[\n {\n \"device_name\": \"R-1\",\n \"status\": \"success\",\n \"output\": \"Cisco IOS Software, \\n IOSv Software (X86_64_LINUX_IOSD-UNIVERSALK9-M), Version 15.2(1.90)\"\n },\n {\n \"device_name\": \"R-2\",\n \"status\": \"failed\",\n \"error\": \"Connection timeout\"\n }\n]", "session_id": "xxx" } // Stream end {"type": "done", "session_id": "xxx"} // Error {"type": "error", "error": "Project not found", "session_id": "xxx"} // Stream aborted by user {"type": "abort", "session_id": "xxx"} ``` ### Streaming Tool Call Mechanism **Background**: When LLM generates tool call parameters, it outputs character by character like text content. **Implementation**: Use `ToolCallStreamAccumulator` class to maintain state, handling three phases: 1. **Initialization Phase**: Get tool ID and name from `tool_calls`, send initial `tool_call` event (parameters empty) 2. **Accumulation Phase**: Get parameter fragments from `tool_call_chunks`, accumulate complete parameters via string concatenation, send updated `tool_call` event after each accumulation 3. **Completion Phase**: Detect `finish_reason == "tool_calls"` or `"stop"`, send final `tool_call` event (mark `complete: true`) **Frontend Handling**: - When receiving `tool_call` event, determine if it's a new tool call based on `tool_call.id` - Subsequent events with same ID are used to update parameter display - When `function.complete: true`, parameters are complete, tool can be executed - `tool_start` event contains `tool_call_id`, can associate with previous `tool_call` event **Example Code** (frontend): ```javascript // Maintain current tool call state let currentToolCall = null; function handleToolCallEvent(chunk) { const toolCall = chunk.tool_call; if (!currentToolCall || currentToolCall.id !== toolCall.id) { // New tool call currentToolCall = { id: toolCall.id, name: toolCall.function.name, arguments: toolCall.function.arguments, complete: toolCall.function.complete || false }; displayToolCallStarted(currentToolCall); } else { // Update existing tool call parameters currentToolCall.arguments = toolCall.function.arguments; currentToolCall.complete = toolCall.function.complete || false; updateToolCallArguments(currentToolCall); } if (currentToolCall.complete) { // Parameters complete, ready to execute tool displayToolCallReady(currentToolCall); } } ``` ### Heartbeat Mechanism *(Planned)* **Purpose**: Prevent proxy server/load balancer from disconnecting SSE connection due to timeout. **Planned Implementation**: Use `asyncio.wait` to set timeout, send `heartbeat` message after timeout, then continue waiting for next event. **Frontend Handling**: When receiving `heartbeat` message, ignore it directly, don't render anything. **Note**: The `heartbeat` type is defined in the `ChatResponse` schema but not yet implemented in the streaming path. Currently, long-running tool executions may cause proxy timeouts. ## API Endpoints All Copilot endpoints are under `/v3/copilot/` path. ### Chat Endpoints | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/copilot/projects/{project_id}/chat/stream` | Streaming Chat (main interface) | | GET | `/copilot/projects/{project_id}/chat/sessions` | List sessions (sorted by pin and update time) | | GET | `/copilot/projects/{project_id}/chat/sessions/{session_id}/history` | Get session history | | PATCH | `/copilot/projects/{project_id}/chat/sessions/{session_id}` | Rename session | | DELETE | `/copilot/projects/{project_id}/chat/sessions/{session_id}` | Delete session | | POST | `/copilot/projects/{project_id}/chat/sessions/{session_id}/abort` | Abort ongoing streaming session | | PUT | `/copilot/projects/{project_id}/chat/sessions/{session_id}/pin` | Pin session | | DELETE | `/copilot/projects/{project_id}/chat/sessions/{session_id}/pin` | Unpin session | ### Skills Endpoints | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/copilot/reload/skills` | Hot reload skills, prompts, and forbidden commands | See [Skills Repository](skills-repository.md) for details on the reload process and configuration. ### POST /v3/copilot/projects/{project_id}/chat/stream **Function**: Streaming conversation interface **Request Parameters**: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | message | string | Yes | User message content | | session_id | string | No | Session ID (creates new session if not provided) | | stream | boolean | No | Enable streaming response (default true) | | temperature | float | No | LLM temperature (reserved, currently unused) | | mode | string | No | Interaction mode (fixed as "text", reserved for future expansion) | **Request Example**: ```json // First message (new session) { "message": "Hello, can you help me?", "stream": true } // Subsequent messages (continue session) { "message": "Show me the network topology", "session_id": "d7e76375-6960-419a-9367-211ef64af877", "stream": true } ``` **Response**: SSE stream, contains multiple types of messages (see message format section) **Session ID Management**: - **First message**: Do not send `session_id` in request, backend generates a new UUID - **Retrieve session_id**: Each SSE message (including `done` message) contains `session_id` field - **Subsequent messages**: Include the saved `session_id` in request body to continue conversation - **Example flow**: 1. First request: `{"message": "hello", "stream": true}` → generates new session 2. Get `session_id` from SSE response: `{"type": "done", "session_id": "xxx-xxx-xxx"}` 3. Second request: `{"message": "how are you?", "session_id": "xxx-xxx-xxx", "stream": true}` **Project Status Check**: Only allows conversation when project status is "opened" **Response Example** (SSE stream): ``` data: {"type": "content", "content": "Hello", "session_id": "d7e76375-6960-419a-9367-211ef64af877"} data: {"type": "content", "content": "! I can help", "session_id": "d7e76375-6960-419a-9367-211ef64af877"} data: {"type": "done", "session_id": "d7e76375-6960-419a-9367-211ef64af877"} ``` ### GET /v3/copilot/projects/{project_id}/chat/sessions **Function**: List all sessions in a project **Query Parameters**: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | user_id | string | No | Filter by user ID | | limit | int | No | Maximum number of sessions (default 100) | **Response**: Session list, includes statistics (message count, token usage, etc.), sorted by pin status and update time **Response Example**: ```json [ { "id": 1, "thread_id": "d7e76375-6960-419a-9367-211ef64af877", "user_id": "admin", "project_id": "a0f46d81-e564-443c-b321-2cdebe80e321", "title": "GNS3 Topology Assistance", "message_count": 4, "llm_calls_count": 2, "input_tokens": 8500, "output_tokens": 1200, "total_tokens": 9700, "last_message_at": "2026-03-08T01:34:07", "created_at": "2026-03-07T17:48:07", "updated_at": "2026-03-08T01:34:07", "metadata": {}, "stats": {}, "pinned": false } ] ``` ### GET /v3/copilot/projects/{project_id}/chat/sessions/{session_id}/history **Function**: Get complete history of a session **Path Parameters**: - session_id: Session ID **Query Parameters**: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | limit | int | No | Maximum number of messages (default 100) | **Response Example**: ```json { "thread_id": "d7e76375-6960-419a-9367-211ef64af877", "title": "GNS3 Topology Assistance", "messages": [ { "id": "f0247568-071d-412f-9e3e-4cbe815834ea", "role": "user", "content": "What can you do?", "metadata": { "created_at": "2026-03-07T17:48:07.848519" } }, { "id": "lc_run--019cc969-eb81-7dd1-a894-e819daf81cd0", "role": "assistant", "content": "I can serve as a teaching assistant for GNS3 network labs...", "tool_calls": [ { "id": "call_00_xxx", "type": "function", "function": { "name": "get_gns3_topology", "arguments": {} } } ], "metadata": {} } ], "created_at": null, "updated_at": null, "llm_calls": 2 } ``` ### PATCH /v3/copilot/projects/{project_id}/chat/sessions/{session_id} **Function**: Rename session **Request Parameters**: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | title | string | Yes | New title (1-255 characters) | **Request Example**: ```json { "title": "New Session Title" } ``` **Response**: Updated session information ### DELETE /v3/copilot/projects/{project_id}/chat/sessions/{session_id} **Function**: Delete session and all its checkpoint data **Response**: 204 No Content ### POST /v3/copilot/projects/{project_id}/chat/sessions/{session_id}/abort **Function**: Abort an ongoing streaming session **Behavior**: 1. Sets an in-memory abort flag (`_abort_flags[session_id] = True`) 2. The LangGraph graph checks this flag at conditional edges (`should_continue`, `recursion_limit_continue`) 3. If abort is detected during a tool call, `abort_handler_node` generates placeholder `ToolMessage` results to maintain message history consistency 4. The stream ends gracefully, and any aborted tool messages are yielded as `tool_end` events with `{"status": "aborted"}` content **Response Example**: ```json {"status": "ok", "session_id": "d7e76375-6960-419a-9367-211ef64af877"} ``` **Abort Flow**: ``` POST /abort → set_abort_flag(session_id) ↓ Graph conditional edge checks flag ↓ ┌─ Has pending tool_calls? │ YES → abort_handler_node (generates placeholder ToolMessages) │ NO → END directly ↓ Stream ends → yields aborted tool_end events → done ``` ### PUT /v3/copilot/projects/{project_id}/chat/sessions/{session_id}/pin **Function**: Pin session to top of list **Response Example**: ```json { "id": 1, "thread_id": "d7e76375-6960-419a-9367-211ef64af877", "title": "GNS3 Topology Assistance", "pinned": true, ... } ``` ### DELETE /v3/copilot/projects/{project_id}/chat/sessions/{session_id}/pin **Function**: Unpin session **Response Example**: ```json { "id": 1, "thread_id": "d7e76375-6960-419a-9367-211ef64af877", "title": "GNS3 Topology Assistance", "pinned": false, ... } ``` **Sorting Rules**: - Pinned sessions (pinned=true) appear at the front - Among pinned sessions, sort by updated_at descending - Normal sessions sort by updated_at descending ## Data Models ### ChatRequest - message: str - User message content - session_id: Optional[str] - Session ID (optional) - stream: bool - Enable streaming response (default true) - temperature: Optional[float] - LLM temperature parameter (Note: currently unused, reserved for future runtime override implementation. Current temperature is read from user's database LLM configuration) - mode: Literal["text"] - Interaction mode ### ChatSession Session model, stores session metadata and statistics. **Base Fields**: - id: Database auto-increment ID - thread_id: LangGraph thread_id (session unique identifier) - user_id: User ID - project_id: GNS3 project ID - title: Session title (auto-generated or user-modified) **Statistics Fields**: - message_count: Complete message count (user messages + AI replies + tool results) - llm_calls_count: Total LLM call count - input_tokens: Total input tokens (accumulated across all LLM calls) - output_tokens: Total output tokens (accumulated across all LLM calls) - total_tokens: Total tokens (input_tokens + output_tokens) **Time Fields**: - last_message_at: Timestamp of last message - created_at: Session creation time - updated_at: Session last update time **Reserved Fields**: - metadata: Metadata JSON string (stores mode, status, tags, etc.) - stats: Additional statistics JSON string (stores tool call counts, etc.) **Session Management**: - pinned: Whether pinned to top of list (default false) ### ConversationHistory - thread_id: str - Session ID - title: str - Session title - messages: List[OpenAIMessage] - Message list - created_at: Optional[str] - Creation time - updated_at: Optional[str] - Update time - llm_calls: int - Number of LLM calls ### OpenAIMessage OpenAI-compatible message model. **Base Fields**: - id: str - Message unique identifier (auto-generated or inherited from LangChain message) - role: Literal["user", "assistant", "system", "tool"] - Message role - content: str - Message content (supports text, JSON string) - metadata: Optional[Dict] - Message metadata (includes created_at and other custom fields) - created_at: str - Message creation time (ISO 8601 format) - Other custom fields can be added as needed **Tool-related Fields**: - name: Optional[str] - Tool message name (tool message) - tool_call_id: Optional[str] - Associated tool call ID (tool message) - tool_calls: Optional[List[OpenAIToolCall]] - Tool call list (assistant message) - id: str - Tool call ID - type: Literal["function"] - Fixed as "function" - function: Dict - Contains name and arguments (dict or JSON string) **Important Notes**: - Message creation time is stored in `metadata.created_at` field - Frontend should read `metadata.created_at` for message timestamp - Historical messages may not have `created_at` in metadata (empty `{}`) ## Core Components ### Message Converters (Message Format Conversion) **File**: `gns3server/agent/gns3_copilot/utils/message_converters.py` **Responsibility**: Convert between LangChain message format and OpenAI-compatible format **Main Functions**: - `convert_langchain_to_openai()`: LangChain → OpenAI format (used in `get_history` and message conversion) - `convert_openai_to_langchain()`: OpenAI → LangChain format (utility, not used in main streaming path) - `convert_stream_event_to_openai()`: *(Not used in main streaming path)* — streaming uses `ToolCallStreamAccumulator` for `on_chat_model_stream` and `AgentService._convert_event_to_chunk` for other events **Key Conversion Logic**: 1. **Message ID Handling** - Auto-generate UUID if message has no ID - Ensure all returned messages have unique identifier 2. **Metadata and Timestamp Handling** - Extract entire `metadata` dict from LangChain message - Message creation time stored in `metadata.created_at` field (ISO 8601 format) - No top-level `created_at` field in returned message - Frontend should read `message.metadata.created_at` for timestamp - Historical messages without metadata will have empty `{}` 3. **Tool Calls Format Conversion** - LangChain format: `{'name': 'xxx', 'args': {...}, 'id': 'yyy', 'type': 'tool_call'}` - OpenAI format: `{'id': 'yyy', 'type': 'function', 'function': {'name': 'xxx', 'arguments': '{...}'}}` - Automatically convert `args` object to JSON string (if needed) 4. **Content Type Handling** - Supports string, dict, list types - Non-string types automatically converted to JSON string **Implementation Location**: `utils/message_converters.py` ### LangGraph Agent (gns3_copilot.py) **File**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py` **Responsibility**: LangGraph-based workflow orchestration for AI conversation **Graph Nodes**: 1. **llm_call Node**: Invokes LLM with tools and conversation history - Injects topology information into system prompt - Handles message trimming for context window management - Selects tools based on copilot mode (`teaching_assistant` vs `lab_automation_assistant`) - Creates fresh model instance with tools for each call 2. **tool_node Function**: Executes tool calls and returns results - **Critical**: Serializes tool output to JSON before creating ToolMessage - This ensures both SSE streaming and history storage use consistent JSON format - Implementation: ```python # Serialize observation to JSON string if it's not already a string if not isinstance(observation, str): observation = json.dumps(observation, ensure_ascii=False, indent=2) tool_msg = ToolMessage( content=observation, # Always JSON string format tool_call_id=tool_call["id"], name=tool_call["name"], metadata={"created_at": datetime.utcnow().isoformat()} ) ``` 3. **title_generator_node** (`generate_title` function): Auto-generates conversation title on first interaction - Uses a separate lightweight LLM (title_model) to generate a title from the first user message and assistant response - Title is truncated to 40 characters max - Fallback: uses first 30 chars of user's message if title generation fails - This node is **filtered out** from statistics and SSE streaming (internal use only) 4. **abort_handler_node**: Handles abort when pending tool_calls exist - Generates placeholder `ToolMessage` with `{"status": "aborted"}` content - Ensures message history consistency and prevents checkpoint corruption - Only triggered when abort flag is set and the last AI message has tool_calls **Conditional Edges (Routing Functions)**: - **should_continue** (after `llm_call`): Routes to `tool_node`, `title_generator_node`, `abort_handler_node`, or `END` based on: 1. Check abort flag → `abort_handler_node` (if pending tool_calls) or `END` 2. Has tool_calls → `tool_node` 3. First interaction without title → `title_generator_node` 4. Otherwise → `END` - **recursion_limit_continue** (after `tool_node`): Routes to `llm_call` or `END` based on: 1. Check abort flag → `END` 2. Remaining steps < 4 → `END` (prevent infinite loops) 3. Otherwise → `llm_call` **State** (`MessagesState`): - `messages`: Conversation messages (cumulative with `operator.add`) - `llm_calls`: LLM invocation counter - `remaining_steps`: Recursion depth tracker (initial: 20) - `conversation_title`: Auto-generated title - `topology_info`: GNS3 project topology data - `session_id`: Session identifier (for abort tracking) - `abort`: Abort flag **Copilot Modes**: The agent supports two tool sets, selected by `copilot_mode` in the user's LLM configuration: | Mode | Tools | Description | |------|-------|-------------| | `teaching_assistant` (default) | GNS3Template, GNS3CreateNode, GNS3Link, GNS3StartNode, GNS3UpdateNodeName, ExecuteMultipleDeviceCommands, PacketCapture, DeviceSkills | Read-only diagnostic + node creation | | `lab_automation_assistant` | All teaching_assistant tools + GNS3StopNode, GNS3SuspendNode, ExecuteMultipleDeviceConfigCommands, VPCSCommands | Full diagnostic + configuration tools | **Why Serialize in tool_node?** - **SSE Streaming**: `on_tool_end` event receives `ToolMessage.content` directly - **History Storage**: ToolMessages are persisted to checkpoint database - **Consistency**: Both paths use the same JSON format Without serialization, LangChain would convert dict/list to Python str() representation (single quotes, non-JSON format) when saving to history. **Tool Output Data Flow**: ``` Tool.invoke() → dict/list ↓ tool_node() → json.dumps() → JSON string ↓ ToolMessage(content=JSON_string) ↓ ┌────────────────┬─────────────────┐ │ SSE Stream │ History DB │ │ (agent_service)│ (checkpoints) │ └────────────────┴─────────────────┘ ↓ Frontend receives standard JSON ``` ### AgentService **Responsibility**: Project-level Agent management service **Main Methods**: - `stream_chat`: Streaming conversation, automatically manages sessions and statistics - `get_history`: Get session history - `list_sessions`: List sessions - `delete_session`: Delete session - `rename_session`: Rename session - `pin_session`: Pin or unpin session - `abort_session`: Signal abort for a running session (sets in-memory flag) - `close`: Close database connection **Core Flow** (stream_chat): 1. Initialize checkpointer connection (if not connected) 2. Get or create chat session (from `chat_sessions` table) 3. Set ContextVars (JWT token, LLM config) 4. Build LangGraph config 5. Create initial message with ID and timestamp: `HumanMessage(content=message, id=str(uuid4()), metadata={"created_at": datetime.utcnow().isoformat()})` 6. Stream Agent execution, collecting statistics simultaneously 7. Update session statistics to database after stream ends 8. Sync auto-generated title **Statistics Collection Mechanism** (in `stream_chat`): - Listen to LangGraph's `astream_events` event stream - Collect statistics in real-time during event loop - Statistics logic doesn't depend on converted SSE chunk, gets directly from original events **Key Event Handling**: - `on_chat_model_start`: LLM call count +1 (excludes `title_generator_node`) - `on_chat_model_end`: Extract token usage via `response.usage_metadata` → `output.usage_metadata` → data fields (excludes `title_generator_node`), AI message count +1 (once per turn via `ai_response_counted` flag) - `on_tool_end`: Tool message count +1 - `on_chat_model_stream`: Processed by `ToolCallStreamAccumulator` for progressive tool call arguments (excludes `title_generator_node` from SSE output) - Abort flag is cleared at stream start, checked during streaming for graceful termination **Implementation Location**: `agent_service.py` ### ProjectAgentManager **Responsibility**: Global singleton, manages AgentService instances for all projects **Methods**: - `get_agent(project_id, project_path)`: Get or create project's AgentService - `remove_agent(project_id)`: Remove project's AgentService - `close_all`: Close all AgentService ### Chat API Routes **File**: `gns3server/api/routes/controller/chat.py` **Route Registration**: ```python router.include_router( chat.router, prefix="/{project_id}/chat", tags=["Chat"] ) ``` **Main Endpoint Implementation**: - All endpoints require user authentication (`get_current_active_user`) - All endpoints check if project status is "opened" - stream endpoint uses `StreamingResponse` to return SSE stream ## Project Lifecycle Integration ### When Project Opens Create or get AgentService instance: ```python agent_manager = await get_project_agent_manager() agent_service = await agent_manager.get_agent(project_id, project.path) ``` ### When Project Closes Remove AgentService instance, release resources: ```python agent_manager.remove_agent(project_id) ``` ### When Project Deletes 1. Call `delete_all_sessions(project_id)` to delete all sessions and checkpoint data 2. Remove AgentService instance 3. Project directory is deleted, database file is also deleted ## Frontend Integration ### useChat Hook Handle different types based on SSE message's `type` field: | type | Handling Logic | |------|----------------| | content | Append to current AI message content | | tool_call | Create tool_call type message, display tool call information | | tool_start | Optional: show tool start execution status | | tool_end | Create tool_result type message, display tool execution result | | error | Display error message | | abort | Mark stream as aborted, stop loading state | | done | Mark stream end, stop loading state | | heartbeat | *(Planned)* Ignore (keepalive signal) | ### Session ID Management (Important) The frontend must properly manage session_id to maintain conversation continuity: 1. **First request**: Do not include `session_id` in request body 2. **Save session_id**: Extract `session_id` from each SSE message (especially the `done` message) 3. **Subsequent requests**: Include the saved `session_id` in request body to continue the conversation 4. **State management**: Store `session_id` in React state/localStorage to persist across page refreshes **Example**: ```javascript // First message const response = await fetch('/chat/stream', { method: 'POST', body: JSON.stringify({ message: 'Hello', stream: true }) }); // Get session_id from first done message let sessionId = null; for await (const chunk of reader) { const data = JSON.parse(chunk.data); if (data.type === 'done') { sessionId = data.session_id; break; } } // Subsequent messages - include session_id await fetch('/chat/stream', { method: 'POST', body: JSON.stringify({ message: 'Continue conversation', session_id: sessionId, stream: true }) }); ``` ### Message Timestamp Each message includes a timestamp in the `metadata` field: - **Field location**: `message.metadata.created_at` - **Format**: ISO 8601 (e.g., `"2026-03-08T01:33:17.848519"`) - **Note**: Historical messages may have empty `metadata` ({}) if created before this feature was added **Example**: ```javascript // Read message timestamp const timestamp = message.metadata?.created_at; const displayTime = timestamp ? new Date(timestamp).toLocaleString() : 'Unknown'; ``` ### Error Handling - Network error: Show retry option - LLM error: Show error message - Project not opened: Prompt user to open project - LLM not configured: Guide user to configure LLM ## Security Considerations ### User Isolation - Each user can only access their own sessions - user_id stored in config.metadata - All database queries filtered by user_id ### Project Access Control - Only allow access to projects user has permission for - Project status check: only allow "opened" status projects to use Chat ### LLM Configuration Security - API key encrypted storage in database - Pass via ContextVars, not persisted to checkpoint - Automatically clear sensitive information in memory after request ends ## Performance Optimization ### Database Connection Management - Use WAL mode to improve concurrent write performance - Project-level connection reuse - Automatically close old connections when switching projects ### Checkpoint Optimization - LangGraph automatically manages checkpoints table - Periodically clean old checkpoints (optional) - Use indexes to accelerate queries (thread_id, user_id + project_id) ### Statistics Collection and Update **Collection Mechanism** (in-memory): - Collect statistics synchronously during SSE streaming transmission - Listen to LangGraph event stream, no additional network overhead - Use temporary variables to accumulate statistics, avoid frequent database access **Update Strategy** (batch write after stream ends): - After streaming Chat completes, update `chat_sessions` table in one batch - Use SQL incremental update syntax: `message_count = message_count + ?` - Single database transaction, commit all statistic updates **Advantages**: - Reduce database write count (N events → 1 update) - Lower database lock contention - Improve real-time performance of streaming response **Implementation Location**: `agent_service.py` `stream_chat` method (statistics collection in event loop + batch update after stream) ## Dependencies - `langchain` >= 0.3.0 - `langgraph` >= 0.2.0 - `langchain-core` - `aiosqlite` - `fastapi` ## Extensibility ### Reserved Fields - `metadata` (TEXT JSON): Store session-level metadata - `stats` (TEXT JSON): Store additional statistics ### Future Possible Extensions #### Runtime LLM Parameter Override Current LLM configuration (including temperature, max_tokens, etc.) is read from user's database configuration. Future support for overriding these parameters at request time: **Implementation Plan**: ```python # In chat.py's stream_chat function if request.temperature is not None: llm_config["temperature"] = str(request.temperature) if request.max_tokens is not None: llm_config["max_tokens"] = str(request.max_tokens) ``` **Current Status**: - `temperature` parameter already added to ChatRequest schema, but override logic not implemented - Parameter reserved in API for backward compatibility - TODO comments added in code to mark implementation location **Notes**: - Need to validate parameter ranges (e.g., temperature: 0.0-2.0) - Need to consider whether to record override values to statistics - Need to provide corresponding settings in frontend UI #### Other Extension Directions - Multi-modal support (images, files) - Voice input/output - Multi-user collaboration sessions - Session sharing and export - Custom tool registration ## References - [LangGraph Checkpoint Documentation](https://langchain-ai.github.io/langgraph/how-tos/checkpointers/) - [Server-Sent Events (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) - [OpenAI Chat Format](https://platform.openai.com/docs/api-reference/chat)