YueGuobin e68cc17ed5
feat: add fault injection system and external skills repository
## Summary

Add a complete fault injection system for GNS3 Copilot, migrate all
skills from local Python files to an external Git repository with
hot reload support, and restructure Copilot API under /copilot/.

## Key Changes

### Fault Injection
- New troubleshooting_injection mode with InjectionSkillsTool
- 368 fault scenarios across 39 protocol categories
- Context-based filtering (LLM must pass topology protocols)

### External Skills Repository
- SkillsManager: Git clone/pull, version tracking, smart updates
- SkillsLoader: YAML skills + Markdown prompts from external repo
- Hot reload via POST /copilot/reload/skills
- Configurable via gns3_server.conf

### Architecture
- API unified under /copilot/ prefix
- SkillsManager moved from Controller to agent module
- Lazy initialization with startup background preload
- Per-command Git timeout, smart update checks
- Forbidden commands hot-reloadable from external repo
- 32 INFO logs downgraded to DEBUG
2026-05-11 01:24:55 +08:00

1142 lines
41 KiB
Markdown

<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> 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)