diff --git a/docs/gns3-copilot/DESIGN_DOCS_LICENSE.md b/docs/gns3-copilot/DESIGN_DOCS_LICENSE.md
deleted file mode 100644
index 13038d265..000000000
--- a/docs/gns3-copilot/DESIGN_DOCS_LICENSE.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# GNS3 Copilot Design Documents License
-
-**Copyright © 2025 Yue Guobin (岳国宾)** ([GitHub](https://github.com/yueguobin))
-
-All design documents in this directory and its subdirectories are licensed under the
-Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0).
-
-## License Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: https://creativecommons.org/licenses/by-sa/4.0/
-
-## What This Means
-
-This is a **strong copyleft** license, similar to the GPL license used for
-the GNS3 server code. It ensures that:
-
-- Anyone can use and modify these documents
-- Commercial use is permitted (training, books, etc.)
-- All derivative works must remain under CC BY-SA 4.0
-- The original author must be credited
-
-## Icons
-
-License icons are available from:
-- https://creativecommons.org/downloads/
-- https://chooser-beta.creativecommons.org/
-
-## Contributing
-
-By contributing to these design documents, you agree that your contributions
-will be licensed under the same CC BY-SA 4.0 license, with copyright attributed
-to you respectively for your contributions.
-
-## Contact
-
-For questions about licensing, please contact:
-
-- GitHub: https://github.com/yueguobin
diff --git a/docs/gns3-copilot/README.md b/docs/gns3-copilot/README.md
index c64972609..0ecf00f4c 100644
--- a/docs/gns3-copilot/README.md
+++ b/docs/gns3-copilot/README.md
@@ -7,21 +7,12 @@ This directory contains design documentation, implementation guides, and future
```
docs/gns3-copilot/
├── README.md # This file
-├── implemented/ # Implemented features and designs
-│ ├── chat-api.md # Chat API design (SSE, session management)
-│ ├── llm-model-configs.md # LLM model configuration system
-│ ├── command-security.md # Command security and filtering
-│ ├── context-window-management.md # Context window optimization
-│ └── node-control-tools.md # Node start/stop tools for lab automation
-├── todo/ # Planned features and designs
-│ ├── jinja2-config-templates-system.md # Config template system
-│ ├── config-templates-implementation-guide.md # Template implementation
-│ ├── ai-prompting-for-config-templates.md # AI prompts for templates
-│ ├── acl-web-ui-implementation-guide.md # ACL/ACL Web UI
-│ ├── hitl-implementation-plan.md # HITL (Human-in-the-Loop)
-│ ├── vision-topology-creation.md # Vision-based topology
-│ └── ... # More planned features
-└── guides/ # User and developer guides (TBD)
+└── implemented/ # Implemented features and designs
+ ├── chat-api.md # Chat API design (SSE, session management)
+ ├── llm-model-configs.md # LLM model configuration system
+ ├── command-security.md # Command security and filtering
+ ├── context-window-management.md # Context window optimization
+ └── node-control-tools.md # Node start/stop/suspend tools for lab automation
```
## Implemented Features
@@ -84,43 +75,21 @@ Tools for controlling network device lifecycle in GNS3 projects.
**Status:** ✅ Implemented
-## Planned Features
+## Future Enhancements
-### Jinja2 Configuration Templates (`todo/jinja2-config-templates-system.md`)
-Template-based configuration generation system.
+The following features are currently under consideration or development:
-**Planned Features:**
-- Vendor-specific templates (Cisco, Juniper, Huawei, etc.)
-- JSON schema validation
-- AI generates structured data → Templates render configs
-- Multi-vendor support
-
-**Status:** 📋 Design Complete, Implementation Pending
-
-### ACL Web UI (`todo/acl-web-ui-implementation-guide.md`)
-Web-based ACL (Access Control List) management interface.
-
-**Status:** 📋 Design Complete
-
-### HITL Implementation (`todo/hitl-implementation-plan.md`)
-Enhanced Human-in-the-Loop confirmation workflows.
-
-**Status:** 📋 Design Complete
-
-### Vision Topology Creation (`todo/vision-topology-creation.md`)
-Create network topologies from images/diagrams.
-
-**Status:** 📋 Design Complete
+- Configuration Templates: Template-based configuration generation for multi-vendor network devices
+- Vision-based Topology Creation: Create network topologies from images/diagrams
+- Enhanced HITL Workflows: Advanced Human-in-the-Loop confirmation patterns
+- Web UI Enhancements: Improved management interfaces
## Contributing
When adding new documentation:
-1. **Design Phase:** Add new documents to `todo/`
-2. **Implementation:** Move to `implemented/` when feature is complete
-3. **Naming:**
- - `todo/`: Use descriptive names like `{feature}-implementation-guide.md`
- - `implemented/`: Use concise names like `{feature}.md`
+1. **Implementation:** Add documentation to `implemented/` when feature is complete
+2. **Naming:** Use concise names like `{feature}.md`
## Document Status Legend
@@ -146,4 +115,4 @@ When adding new documentation:
---
-_Last updated: 2026-03-11_
+_Last updated: 2026-03-12_
diff --git a/docs/gns3-copilot/implemented/chat-api.md b/docs/gns3-copilot/implemented/chat-api.md
index e0e7817d9..f4c37c08d 100644
--- a/docs/gns3-copilot/implemented/chat-api.md
+++ b/docs/gns3-copilot/implemented/chat-api.md
@@ -1034,30 +1034,3 @@ if request.max_tokens is not None:
- [OpenAI Chat Format](https://platform.openai.com/docs/api-reference/chat)
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/implemented/command-security.md b/docs/gns3-copilot/implemented/command-security.md
index 37f14eab8..eaf49c6ed 100644
--- a/docs/gns3-copilot/implemented/command-security.md
+++ b/docs/gns3-copilot/implemented/command-security.md
@@ -433,30 +433,3 @@ If you:
Please submit an issue: https://github.com/yueguobin/gns3-copilot/issues
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/implemented/context-window-management.md b/docs/gns3-copilot/implemented/context-window-management.md
index 95e4bca08..5b2fd05a5 100644
--- a/docs/gns3-copilot/implemented/context-window-management.md
+++ b/docs/gns3-copilot/implemented/context-window-management.md
@@ -326,30 +326,3 @@ except Exception as e:
- `gns3server/agent/gns3_copilot/agent/model_factory.py` - Model creation and tool binding
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/implemented/llm-model-configs.md b/docs/gns3-copilot/implemented/llm-model-configs.md
index 57639909d..ed64f90a3 100644
--- a/docs/gns3-copilot/implemented/llm-model-configs.md
+++ b/docs/gns3-copilot/implemented/llm-model-configs.md
@@ -892,32 +892,3 @@ HTTP 400 Bad Request
"detail": "context_limit is required (unit: K tokens, e.g., 128 = 128K = 128,000 tokens). Please check your model provider's documentation for the current context window size and specify it in the configuration."
}
```
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/implemented/node-control-tools.md b/docs/gns3-copilot/implemented/node-control-tools.md
index fb7053315..f3c56a0b4 100644
--- a/docs/gns3-copilot/implemented/node-control-tools.md
+++ b/docs/gns3-copilot/implemented/node-control-tools.md
@@ -1,11 +1,214 @@
-# Node Control Tools
+# Node and Topology Management Tools
## Overview
-GNS3-Copilot provides tools for controlling the lifecycle of network devices in GNS3 projects. These tools enable AI agents to start, stop, suspend, and manage nodes as part of automated lab management workflows.
+GNS3-Copilot provides tools for managing the lifecycle of network devices and topology in GNS3 projects. These tools enable AI agents to create, connect, start, stop, suspend, and manage nodes as part of automated lab management workflows.
## Available Tools
+### GNS3TemplateTool 🆕
+
+**Tool Name:** `get_gns3_templates`
+
+**Description:** Retrieves all available device templates from the GNS3 server, including template names, IDs, and types.
+
+**Input:**
+```json
+{}
+```
+
+**Output:**
+```json
+{
+ "templates": [
+ {
+ "name": "Cisco IOSv",
+ "template_id": "uuid-of-template",
+ "template_type": "router"
+ },
+ {
+ "name": "Ethernet switch",
+ "template_id": "uuid-of-template2",
+ "template_type": "switch"
+ }
+ ]
+}
+```
+
+**Features:**
+- Lists all available device templates
+- No input required (connects to configured GNS3 server)
+- Returns template_id needed for node creation
+
+**Use Cases:**
+- Discover available device types before creating nodes
+- Get template_id for GNS3CreateNodeTool
+- Template inventory management
+
+### GNS3CreateNodeTool 🆕
+
+**Tool Name:** `create_gns3_node`
+
+**Description:** Creates multiple nodes in a GNS3 project using specified templates and coordinates.
+
+**Input:**
+```json
+{
+ "project_id": "uuid-of-project",
+ "nodes": [
+ {
+ "template_id": "uuid-of-template",
+ "x": 100,
+ "y": -200
+ },
+ {
+ "template_id": "uuid-of-template2",
+ "x": -200,
+ "y": 300
+ }
+ ]
+}
+```
+
+**Output:**
+```json
+{
+ "project_id": "uuid-of-project",
+ "created_nodes": [
+ {
+ "node_id": "uuid-of-node1",
+ "name": "NodeName1",
+ "status": "success"
+ },
+ {
+ "node_id": "uuid-of-node2",
+ "name": "NodeName2",
+ "status": "success"
+ }
+ ],
+ "total_nodes": 2,
+ "successful_nodes": 2,
+ "failed_nodes": 0
+}
+```
+
+**Features:**
+- Batch create multiple nodes
+- Uses templates for consistent node configuration
+- X/Y coordinate positioning for topology layout
+- **Important**: Ensure distance between any two nodes is greater than 250px for clear interface labels
+
+**Use Cases:**
+- Automated topology deployment
+- Multi-node lab initialization
+- Programmatic topology creation
+
+**Implementation Details:**
+- Calls `POST /projects/{project_id}/nodes` for each node
+- Uses template_id from GNS3TemplateTool
+- Assigns default names sequentially (e.g., R1, R2, R3)
+
+### GNS3LinkTool 🆕
+
+**Tool Name:** `create_gns3_link`
+
+**Description:** Creates one or more links between nodes in a GNS3 project by connecting their network ports.
+
+**Input:**
+```json
+{
+ "project_id": "uuid-of-project",
+ "links": [
+ {
+ "node_id1": "uuid-of-node1",
+ "port1": "Ethernet0/0",
+ "node_id2": "uuid-of-node2",
+ "port2": "Ethernet0/0"
+ }
+ ]
+}
+```
+
+**Output:**
+```json
+[
+ {
+ "link_id": "uuid-of-link",
+ "node_id1": "uuid-of-node1",
+ "port1": "Ethernet0/0",
+ "node_id2": "uuid-of-node2",
+ "port2": "Ethernet0/0"
+ }
+]
+```
+
+**Features:**
+- Batch create multiple links
+- Automatic port discovery by name
+- Error handling for individual link failures
+- Port names must match topology data
+
+**Use Cases:**
+- Automated topology wiring
+- Multi-link connection setup
+- Network infrastructure deployment
+
+**Implementation Details:**
+- Calls `POST /projects/{project_id}/links` for each link
+- Port names must match those from `gns3_topology_reader` tool
+- Uses adapter_number and port_number for port identification
+- Supports both physical and virtual interfaces
+
+### GNS3UpdateNodeNameTool 🆕
+
+**Tool Name:** `update_gns3_node_name`
+
+**Description:** Updates the name of one or multiple nodes in a GNS3 project.
+
+**Input:**
+```json
+{
+ "project_id": "uuid-of-project",
+ "nodes": [
+ {"node_id": "uuid-of-node-1", "new_name": "Router1"},
+ {"node_id": "uuid-of-node-2", "new_name": "Switch1"}
+ ]
+}
+```
+
+**Output:**
+```json
+{
+ "project_id": "...",
+ "total_nodes": 2,
+ "successful": 2,
+ "failed": 0,
+ "nodes": [
+ {
+ "node_id": "...",
+ "old_name": "...",
+ "new_name": "Router1",
+ "status": "success"
+ }
+ ]
+}
+```
+
+**Features:**
+- Batch rename multiple nodes
+- Verification of name change
+- Comprehensive error handling
+
+**Use Cases:**
+- Apply naming conventions to topology
+- Rename nodes for better organization
+- Update node names after topology creation
+
+**Implementation Details:**
+- Calls `PUT /projects/{project_id}/nodes/{node_id}`
+- Cannot rename while node is started (except special node types)
+- CAN rename while node is suspended
+
### GNS3StartNodeTool
**Tool Name:** `start_gns3_node`
@@ -46,46 +249,7 @@ GNS3-Copilot provides tools for controlling the lifecycle of network devices in
- Multi-node topology initialization
- Lab startup automation
-### GNS3StartNodeQuickTool
-
-**Tool Name:** `start_gns3_node_quick`
-
-**Description:** Starts nodes in a GNS3 project WITHOUT waiting for startup completion. Suitable for automated deployment workflows where long waits would cause HTTP timeouts.
-
-**Input:**
-```json
-{
- "project_id": "uuid-of-project",
- "node_ids": ["uuid-of-node-1", "uuid-of-node-2"]
-}
-```
-
-**Output:**
-```json
-{
- "project_id": "...",
- "total_nodes": 2,
- "successful": 2,
- "failed": 0,
- "nodes": [
- {"node_id": "...", "name": "...", "status": "started"}
- ],
- "note": "Start commands sent. Nodes are booting in background. Check node status later."
-}
-```
-
-**Features:**
-- Sends start commands immediately
-- No waiting for startup completion
-- Returns initial status
-- Prevents HTTP timeouts in automated workflows
-
-**Use Cases:**
-- Automated CI/CD pipelines
-- Bulk node deployment
-- Workflows requiring immediate return
-
-### GNS3StopNodeTool ✨
+### GNS3StopNodeTool
**Tool Name:** `stop_gns3_node`
@@ -132,7 +296,7 @@ GNS3-Copilot provides tools for controlling the lifecycle of network devices in
- Retrieves updated status after stop command
- Returns detailed results for each node
-### GNS3SuspendNodeTool ✨
+### GNS3SuspendNodeTool
**Tool Name:** `suspend_gns3_node`
@@ -218,26 +382,40 @@ GNS3-Copilot provides tools for controlling the lifecycle of network devices in
```
gns3server/agent/gns3_copilot/tools_v2/
-├── gns3_start_node.py # Start tools
-├── gns3_stop_node.py # Stop tool
-└── gns3_suspend_node.py # Suspend tool
+├── gns3_create_node.py # Node creation tool 🆕
+├── gns3_create_link.py # Link creation tool 🆕
+├── gns3_get_node_temp.py # Template retrieval tool 🆕
+├── gns3_update_node_name.py # Node rename tool 🆕
+├── gns3_start_node.py # Start node tool
+├── gns3_stop_node.py # Stop node tool
+└── gns3_suspend_node.py # Suspend node tool
```
### API Integration
-The tools use the `Node` class from `custom_gns3fy`:
+The tools use the `Node` and `Link` classes from `custom_gns3fy`:
```python
-from gns3server.agent.gns3_copilot.gns3_client import Node
+from gns3server.agent.gns3_copilot.gns3_client import Node, Link, get_gns3_connector
-# Start node
-node.start()
+# Get templates
+templates = get_gns3_connector().get_templates()
-# Stop node
-node.stop()
+# Create node
+node = Node(project_id=project_id, template_id=template_id, x=x, y=y, connector=gns3_server)
+node.create()
-# Suspend node
-node.suspend()
+# Create link
+link = Link(project_id=project_id, connector=gns3_server, nodes=[...])
+link.create()
+
+# Update node name
+node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
+node.update(name=new_name)
+
+# Start/stop/suspend node
+node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
+node.start() # or node.stop() / node.suspend()
```
### Progress Tracking
@@ -296,23 +474,31 @@ Starting 3 node(s), please wait...
### Teaching Assistant Mode
**Tools Available:**
+- `GNS3TemplateTool` - List available device templates 🆕
+- `GNS3CreateNodeTool` - Create nodes in topology 🆕
+- `GNS3LinkTool` - Create links between nodes 🆕
+- `GNS3UpdateNodeNameTool` - Rename nodes 🆕
- `GNS3StartNodeTool` - For diagnostics requiring started nodes
**Capabilities:**
- READ-ONLY diagnostic tools
+- Can create and manage topology (nodes, links, names)
- Cannot stop or suspend nodes (prevents disruption of active labs)
### Lab Automation Assistant Mode
**Tools Available:**
+- `GNS3TemplateTool` - List available device templates 🆕
+- `GNS3CreateNodeTool` - Create nodes in topology 🆕
+- `GNS3LinkTool` - Create links between nodes 🆕
+- `GNS3UpdateNodeNameTool` - Rename nodes 🆕
- `GNS3StartNodeTool` - Full lab deployment
- `GNS3StopNodeTool` - Full lab shutdown
-- `GNS3SuspendNodeTool` - Lab pause with state preservation ✨
-- `GNS3StartNodeQuickTool` - Fast automated deployment
+- `GNS3SuspendNodeTool` - Lab pause with state preservation
**Capabilities:**
- Full diagnostic and configuration tools
-- Complete lab lifecycle management (start/stop/suspend)
+- Complete topology and lifecycle management (create, connect, start/stop/suspend)
- Automated workflows with state preservation
- Lab snapshot capabilities for later resumption
@@ -332,21 +518,7 @@ result = tool._run(json.dumps({
# Output includes progress bar and final status
```
-### Example 2: Quick Start for CI/CD
-
-```python
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeQuickTool
-
-tool = GNS3StartNodeQuickTool()
-result = tool._run(json.dumps({
- "project_id": "abc-123-def",
- "node_ids": ["node-1"]
-}))
-
-# Immediate return without waiting
-```
-
-### Example 3: Stop Nodes
+### Example 2: Stop Nodes
```python
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
@@ -360,11 +532,11 @@ result = tool._run(json.dumps({
# Immediate return with stop status
```
-### Example 4: Automated Lab Lifecycle
+### Example 3: Automated Lab Lifecycle
```python
# Lab deployment
-start_tool = GNS3StartNodeQuickTool()
+start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
@@ -380,7 +552,7 @@ stop_result = stop_tool._run(json.dumps({
}))
```
-### Example 5: Lab Pause and Resume ✨
+### Example 4: Lab Pause and Resume
```python
from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
@@ -413,7 +585,7 @@ start_result = start_tool._run(json.dumps({
# Back to previous state in seconds!
```
-### Example 6: Suspend While Renaming Nodes ✨
+### Example 5: Suspend While Renaming Nodes
```python
from gns3server.agent.gns3_copilot.tools_v2 import (
@@ -442,6 +614,152 @@ rename_result = rename_tool._run(json.dumps({
# Note: Cannot rename while started, but CAN rename while suspended!
```
+### Example 6: Get Available Templates 🆕
+
+```python
+from gns3server.agent.gns3_copilot.tools_v2 import GNS3TemplateTool
+
+tool = GNS3TemplateTool()
+result = tool._run("")
+
+# Returns all available device templates
+# {
+# "templates": [
+# {"name": "Cisco IOSv", "template_id": "...", "template_type": "router"},
+# {"name": "Ethernet switch", "template_id": "...", "template_type": "switch"}
+# ]
+# }
+```
+
+### Example 7: Create Topology Nodes 🆕
+
+```python
+from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
+
+tool = GNS3CreateNodeTool()
+result = tool._run(json.dumps({
+ "project_id": "abc-123-def",
+ "nodes": [
+ {
+ "template_id": "uuid-of-router-template",
+ "x": 100,
+ "y": -200
+ },
+ {
+ "template_id": "uuid-of-switch-template",
+ "x": -200,
+ "y": 300
+ }
+ ]
+}))
+
+# Creates two nodes with specified templates and positions
+```
+
+### Example 8: Connect Nodes with Links 🆕
+
+```python
+from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
+
+tool = GNS3LinkTool()
+result = tool._run(json.dumps({
+ "project_id": "abc-123-def",
+ "links": [
+ {
+ "node_id1": "uuid-of-node1",
+ "port1": "Ethernet0/0",
+ "node_id2": "uuid-of-node2",
+ "port2": "Ethernet0/0"
+ },
+ {
+ "node_id1": "uuid-of-node1",
+ "port1": "Ethernet0/1",
+ "node_id2": "uuid-of-node3",
+ "port2": "Ethernet0/0"
+ }
+ ]
+}))
+
+# Creates two links connecting the nodes
+```
+
+### Example 9: Apply Naming Convention 🆕
+
+```python
+from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
+
+tool = GNS3UpdateNodeNameTool()
+result = tool._run(json.dumps({
+ "project_id": "abc-123-def",
+ "nodes": [
+ {"node_id": "node-1", "new_name": "R1-Core"},
+ {"node_id": "node-2", "new_name": "R2-Core"},
+ {"node_id": "node-3", "new_name": "S1-Access"},
+ {"node_id": "node-4", "new_name": "S2-Access"}
+ ]
+}))
+
+# Applies consistent naming to all nodes
+```
+
+### Example 10: Complete Topology Creation Workflow 🆕
+
+```python
+from gns3server.agent.gns3_copilot.tools_v2 import (
+ GNS3TemplateTool,
+ GNS3CreateNodeTool,
+ GNS3LinkTool,
+ GNS3UpdateNodeNameTool,
+ GNS3StartNodeTool
+)
+
+# Step 1: Get available templates
+template_tool = GNS3TemplateTool()
+templates = template_tool._run("")
+# Find router and switch template_ids...
+
+# Step 2: Create nodes
+create_tool = GNS3CreateNodeTool()
+nodes = create_tool._run(json.dumps({
+ "project_id": project_id,
+ "nodes": [
+ {"template_id": router_template_id, "x": 0, "y": -200},
+ {"template_id": router_template_id, "x": 200, "y": -200},
+ {"template_id": switch_template_id, "x": 100, "y": 0}
+ ]
+}))
+
+# Step 3: Connect nodes
+link_tool = GNS3LinkTool()
+links = link_tool._run(json.dumps({
+ "project_id": project_id,
+ "links": [
+ {"node_id1": nodes["created_nodes"][0]["node_id"], "port1": "Ethernet0/0",
+ "node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/0"},
+ {"node_id1": nodes["created_nodes"][1]["node_id"], "port1": "Ethernet0/0",
+ "node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/1"}
+ ]
+}))
+
+# Step 4: Apply naming
+name_tool = GNS3UpdateNodeNameTool()
+names = name_tool._run(json.dumps({
+ "project_id": project_id,
+ "nodes": [
+ {"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"},
+ {"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"},
+ {"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"}
+ ]
+}))
+
+# Step 5: Start nodes
+start_tool = GNS3StartNodeTool()
+start_result = start_tool._run(json.dumps({
+ "project_id": project_id,
+ "node_ids": [n["node_id"] for n in nodes["created_nodes"]]
+}))
+```
+
## Error Handling
All tools include comprehensive error handling:
@@ -485,7 +803,7 @@ All tools include comprehensive error handling:
### Access Control
-- Both tools respect GNS3's built-in access control
+- All tools respect GNS3's built-in access control
- Requires valid GNS3 server authentication
- Project-level permissions apply
@@ -494,6 +812,9 @@ All tools include comprehensive error handling:
All operations are logged:
```python
logger.info("Starting %d nodes in project %s...", len(node_ids), project_id)
+logger.info("Creating %d nodes in project %s...", len(nodes), project_id)
+logger.info("Creating %d links in project %s...", len(links), project_id)
+logger.info("Updating names for %d nodes in project %s...", len(nodes), project_id)
logger.info("Stop command sent for node %s (%s)", node_id, node.name)
logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
```
@@ -501,10 +822,12 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
### Mode-Based Restrictions
- **Teaching Assistant Mode**:
- - Can start nodes
+ - Can create topology (templates, nodes, links, names)
+ - Can start nodes for diagnostics
- Cannot stop, suspend (prevents disruption of active labs)
- **Lab Automation Assistant Mode**:
+ - Can create and manage full topology
- Can start, stop, and suspend nodes (full lifecycle control)
- Complete lab management including state preservation
@@ -512,8 +835,11 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
| Operation | Typical Duration | Wait Time | Progress | State Preserved |
|-----------|-----------------|-----------|----------|-----------------|
-| Start (normal) | 60-180s | ~140s base | Yes | N/A |
-| Start (quick) | < 1s | 0s | No | N/A |
+| Get Templates | < 2s | 0s | No | N/A |
+| Create Node | < 1s per node | 0s | No | N/A |
+| Create Link | < 1s per link | 0s | No | N/A |
+| Update Name | < 1s per node | 0s | No | N/A |
+| Start | 60-180s | ~140s base | Yes | N/A |
| Stop | < 5s | 0s | No | ❌ No |
| Suspend | < 10s | 0s | No | ✅ Yes |
@@ -523,11 +849,16 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
- Stop/Suspend do not require progress tracking (immediate feedback)
- Start duration depends on node type (router, switch, PC, etc.)
- Suspend provides fast resume capability compared to full start
+- Create node/link operations are fast and require no waiting
+- Template retrieval is instant with no parameters needed
## Future Enhancements
### Planned Features
+- [ ] **Quick Start Tool**: Start nodes without waiting for completion (for CI/CD)
+- [ ] **Delete Node Tool**: Remove nodes from topology
+- [ ] **Delete Link Tool**: Remove links from topology
- [ ] **Resume Tool**: Explicit resume operation for suspended nodes
- [ ] **Restart Tool**: Combined stop + start operation
- [ ] **Bulk Status Check**: Query multiple nodes without stopping
@@ -536,8 +867,9 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
### Potential Improvements
+- [ ] Auto-layout calculation (optimal node positioning)
- [ ] Progress tracking for long suspend operations (rare but possible)
-- [ ] Concurrent suspend operations (parallel API calls)
+- [ ] Concurrent create/link operations (parallel API calls)
- [ ] Suspend node groups by name pattern
- [ ] Dependency-aware suspend (suspend in dependency order)
- [ ] Auto-suspend after idle timeout
@@ -551,6 +883,6 @@ logger.info("Suspend command sent for node %s (%s)", node_id, node.name)
---
-_Implementation Date: 2026-03-11_
+_Implementation Date: 2026-03-12_
-_Status: ✅ Implemented and Available in Lab Automation Assistant Mode_
+_Status: ✅ Implemented - Topology management tools available in both modes. Full lifecycle management (start/stop/suspend) available in Lab Automation Assistant Mode_
diff --git a/docs/gns3-copilot/todo/ai-prompting-for-config-templates.md b/docs/gns3-copilot/todo/ai-prompting-for-config-templates.md
deleted file mode 100644
index 6245f4cee..000000000
--- a/docs/gns3-copilot/todo/ai-prompting-for-config-templates.md
+++ /dev/null
@@ -1,824 +0,0 @@
-# AI Prompting for Configuration Templates
-
-## Overview
-
-This document provides prompts and examples for training the AI to generate structured configuration data instead of full configuration text. This is critical for the Jinja2 template system to work effectively.
-
----
-
-## Core System Prompt
-
-```python
-# File: gns3server/agent/gns3_copilot/prompts/config_assistant_prompt.py
-
-CONFIG_GENERATION_SYSTEM_PROMPT = """
-You are an expert network configuration assistant for GNS3. Your role is to help users configure network devices by generating structured configuration data.
-
-## CRITICAL RULES
-
-1. **NEVER** generate full configuration text directly
-2. **ALWAYS** output structured data (Python dict/JSON format)
-3. The system will render actual configurations using Jinja2 templates
-4. Only include parameters that are explicitly mentioned by the user
-5. Use correct data types (int for numbers, bool for flags, str for text)
-
-## How It Works
-
-```
-User Request → AI (Structured Data) → Template Renderer → Full Config → Device
-```
-
-You are responsible for the "AI (Structured Data)" step only.
-
-## Supported Vendors and OS Types
-
-| Vendor | OS Types |
-|---------|----------------------|
-| cisco | ios, iosxr, nx-os, asa |
-| juniper| junos, srx |
-| huawei | vrp |
-| arista | eos |
-| mikrotik| routeros |
-
-## Available Features and Their Schemas
-
-### OSPF Configuration
-
-```python
-{
- "ospf": {
- "enabled": bool, # Required: Enable OSPF
- "process_id": int (1-65535), # Required: OSPF process ID
- "router_id": str ("x.x.x.x"), # Optional: Router ID
- "networks": [ # Optional: Network statements
- {
- "address": str, # Network address
- "wildcard": str, # Wildcard mask
- "area": int # OSPF area (0-4294967295)
- }
- ],
- "passive_interfaces": [str], # Optional: List of passive interfaces
- "auto_cost_reference": int, # Optional: Reference bandwidth in Mbps
- "default_information_originate": bool, # Optional: Advertise default route
- "default_metric": int, # Optional: Default route metric
- "interfaces": [ # Optional: Per-interface config
- {
- "name": str, # Interface name
- "cost": int, # OSPF cost
- "area": int, # OSPF area
- "hello_interval": int, # Hello interval (seconds)
- "dead_interval": int # Dead interval (seconds)
- }
- ]
- }
-}
-```
-
-Example:
-```python
-{
- "ospf": {
- "enabled": True,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0},
- {"address": "10.0.0.0", "wildcard": "0.255.255.255", "area": 1}
- ],
- "passive_interfaces": ["GigabitEthernet0/0"]
- }
-}
-```
-
-### BGP Configuration
-
-```python
-{
- "bgp": {
- "enabled": bool,
- "as_number": int (1-65535),
- "router_id": str ("x.x.x.x"),
- "log_neighbor_changes": bool,
- "graceful_restart": bool,
- "neighbors": [
- {
- "ip": str,
- "remote_as": int,
- "description": str (optional),
- "ebgp_multihop": int (optional),
- "next_hop_self": bool,
- "remove_private_as": bool,
- "route_map_in": str (optional),
- "route_map_out": str (optional),
- "password": str (optional)
- }
- ],
- "address_families": [
- {
- "type": str, # "ipv4", "ipv6", "vpnv4", "vpnv6"
- "vrf": str (optional),
- "redistribute_connected": bool,
- "redistribute_static": bool,
- "redistribute_ospf": int (optional),
- "networks": [
- {"address": str, "mask": str}
- ],
- "neighbors": [
- {
- "ip": str,
- "activate": bool,
- "route_map_in": str (optional),
- "route_map_out": str (optional),
- "soft_reconfiguration_inbound": bool
- }
- ]
- }
- ]
- }
-}
-```
-
-### Interface Configuration
-
-```python
-{
- "interfaces": [
- {
- "name": str,
- "description": str (optional),
- "ip_address": str (optional),
- "subnet_mask": str (optional),
- "ipv6_address": str (optional),
- "secondary_ips": [
- {"address": str, "mask": str}
- ],
- "enabled": bool,
- "mtu": int (optional),
- "bandwidth": int (optional),
- "speed": str (optional),
- "duplex": str (optional),
- "acl_in": str (optional),
- "acl_out": str (optional),
- "nat_inside": bool,
- "nat_outside": bool,
- "vlan": int (optional),
- "trunk_vlans": str (optional) # e.g., "10,20,30" or "10-50"
- }
- ]
-}
-```
-
-### VLAN Configuration (Cisco IOS)
-
-```python
-{
- "vlans": [
- {
- "id": int (1-4094),
- "name": str,
- "interfaces": [str] # List of interface names
- }
- ]
-}
-```
-
-### ACL Configuration (Cisco IOS)
-
-```python
-{
- "acl": {
- "number": int (1-99 for standard, 100-199 for extended),
- "name": str (optional for named ACLs),
- "rules": [
- {
- "action": str, # "permit" or "deny"
- "protocol": str, # "ip", "tcp", "udp", "icmp", etc.
- "source": str, # "any", "host x.x.x.x", "x.x.x.x y.y.y.y"
- "destination": str, # Same format as source
- "source_port": str (optional),
- "destination_port": str (optional)
- }
- ]
- }
-}
-```
-
-### NAT Configuration (Cisco IOS)
-
-```python
-{
- "nat": {
- "inside_source": {
- "pool": str (optional),
- "network": str,
- "mask": str,
- "interface": str (optional), # Use "interface" keyword
- "overload": bool
- },
- "outside_source": {
- # Similar structure
- },
- "static": [
- {
- "inside_local": str,
- "inside_global": str,
- "tcp": bool (optional),
- "udp": bool (optional),
- "local_port": int (optional),
- "global_port": int (optional)
- }
- ]
- }
-}
-```
-
----
-
-## Interaction Examples
-
-### Example 1: Simple OSPF Configuration
-
-**User**: "Configure OSPF process 100 on R1 with router-id 1.1.1.1. Include network 192.168.1.0/24 in area 0."
-
-**AI Response** (should call tool):
-```python
-render_device_config(
- node_id="R1",
- vendor="cisco",
- os_type="ios",
- config_data={
- "ospf": {
- "enabled": True,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
- ]
- }
- }
-)
-```
-
-### Example 2: Multi-Area OSPF
-
-**User**: "Set up OSPF on R2. Process ID 50, router-id 2.2.2.2. Put 10.1.0.0/16 in area 0, 172.16.0.0/16 in area 1, and 192.168.100.0/24 in area 2. Make loopback0 passive."
-
-**AI Response**:
-```python
-render_device_config(
- node_id="R2",
- vendor="cisco",
- os_type="ios",
- config_data={
- "ospf": {
- "enabled": True,
- "process_id": 50,
- "router_id": "2.2.2.2",
- "networks": [
- {"address": "10.1.0.0", "wildcard": "0.0.255.255", "area": 0},
- {"address": "172.16.0.0", "wildcard": "0.0.255.255", "area": 1},
- {"address": "192.168.100.0", "wildcard": "0.0.0.255", "area": 2}
- ],
- "passive_interfaces": ["Loopback0"]
- }
- }
-)
-```
-
-### Example 3: BGP Configuration
-
-**User**: "Configure BGP on router R1 with AS 65001. Set up IBGP with R2 at 10.0.0.2 and EBGP with ISP at 203.0.13.2 (AS 65002). Advertise network 192.168.0.0/16."
-
-**AI Response**:
-```python
-render_device_config(
- node_id="R1",
- vendor="cisco",
- os_type="ios",
- config_data={
- "bgp": {
- "enabled": True,
- "as_number": 65001,
- "router_id": "1.1.1.1",
- "log_neighbor_changes": True,
- "neighbors": [
- {
- "ip": "10.0.0.2",
- "remote_as": 65001,
- "description": "IBGP_R2",
- "next_hop_self": True
- },
- {
- "ip": "203.0.13.2",
- "remote_as": 65002,
- "description": "ISP",
- "ebgp_multihop": 2
- }
- ],
- "address_families": [
- {
- "type": "ipv4",
- "networks": [
- {"address": "192.168.0.0", "mask": "255.255.0.0"}
- ],
- "neighbors": [
- {"ip": "10.0.0.2", "activate": True},
- {"ip": "203.0.13.2", "activate": True}
- ]
- }
- ]
- }
- }
-)
-```
-
-### Example 4: Interface Configuration
-
-**User**: "Configure GigabitEthernet0/0 with IP 192.168.1.1/24, description 'LAN', enable it. Configure GigabitEthernet0/1 with IP 10.0.0.1/30, description 'WAN'."
-
-**AI Response**:
-```python
-render_device_config(
- node_id="R1",
- vendor="cisco",
- os_type="ios",
- config_data={
- "interfaces": [
- {
- "name": "GigabitEthernet0/0",
- "description": "LAN",
- "ip_address": "192.168.1.1",
- "subnet_mask": "255.255.255.0",
- "enabled": True
- },
- {
- "name": "GigabitEthernet0/1",
- "description": "WAN",
- "ip_address": "10.0.0.1",
- "subnet_mask": "255.255.255.252",
- "enabled": True
- }
- ]
- }
-)
-```
-
-### Example 5: Multi-Feature Configuration
-
-**User**: "Configure R1 as follows: hostname HQ-ROUTER, GigabitEthernet0/0 = 192.168.1.1/24 (LAN, NAT inside), GigabitEthernet0/1 = 203.0.13.1/30 (WAN, NAT outside), OSPF process 100 with 192.168.1.0/24 in area 0"
-
-**AI Response**:
-```python
-render_device_config(
- node_id="R1",
- vendor="cisco",
- os_type="ios",
- config_data={
- "hostname": "HQ-ROUTER",
- "interfaces": [
- {
- "name": "GigabitEthernet0/0",
- "ip_address": "192.168.1.1",
- "subnet_mask": "255.255.255.0",
- "description": "LAN",
- "nat_inside": True,
- "enabled": True
- },
- {
- "name": "GigabitEthernet0/1",
- "ip_address": "203.0.13.1",
- "subnet_mask": "255.255.255.252",
- "description": "WAN",
- "nat_outside": True,
- "enabled": True
- }
- ],
- "ospf": {
- "enabled": True,
- "process_id": 100,
- "networks": [
- {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
- ]
- }
- }
-)
-```
-
-### Example 6: Juniper Configuration
-
-**User**: "Configure Juniper SRX with OSPF. Router-id 10.1.1.1. Area 0 on interface ge-0/0/0.0."
-
-**AI Response**:
-```python
-render_device_config(
- node_id="srx1",
- vendor="juniper",
- os_type="junos",
- config_data={
- "ospf": {
- "enabled": True,
- "router_id": "10.1.1.1",
- "areas": [
- {
- "area_id": "0.0.0.0",
- "interfaces": [
- {"name": "ge-0/0/0.0"}
- ]
- }
- ]
- }
- }
-)
-```
-
----
-
-## Few-Shot Prompting Examples
-
-When the AI needs to handle complex requests, provide these examples in the system prompt:
-
-### Example 1: Parsing Network Notation
-
-```
-User: "Add network 192.168.1.0/24 to OSPF area 0"
-AI Output: {"networks": [{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}]}
-
-User: "Add network 10.0.0.0/8 to OSPF area 1"
-AI Output: {"networks": [{"address": "10.0.0.0", "wildcard": "0.255.255.255", "area": 1}]}
-
-Conversion: CIDR to Wildcard
-- /24 → 0.0.0.255
-- /16 → 0.0.255.255
-- /8 → 0.255.255.255
-- /30 → 0.0.0.3
-- /32 → 0.0.0.0
-```
-
-### Example 2: Interface Naming Conventions
-
-```
-User: "Configure interface gig0/0"
-AI Output: {"name": "GigabitEthernet0/0"}
-
-User: "Configure fa0/1"
-AI Output: {"name": "FastEthernet0/1"}
-
-User: "Configure eth0"
-AI Output: {"name": "Ethernet0/0"}
-
-Abbreviation Mapping:
-- gi/gig/gigabit → GigabitEthernet
-- fa/fast → FastEthernet
-- eth → Ethernet
-- serial → Serial
-- lo/loopback → Loopback
-```
-
-### Example 3: IP Address with Subnet
-
-```
-User: "IP 192.168.1.1/24"
-AI Output: {"ip_address": "192.168.1.1", "subnet_mask": "255.255.255.0"}
-
-User: "IP 10.0.0.1 255.255.255.252"
-AI Output: {"ip_address": "10.0.0.1", "subnet_mask": "255.255.255.252"}
-
-User: "IP 2001:db8::1/64"
-AI Output: {"ipv6_address": "2001:db8::1/64"}
-```
-
----
-
-## Error Handling
-
-### When Information is Missing
-
-**User**: "Configure OSPF"
-
-**AI Should Ask**:
-```
-"I need more information to configure OSPF. Please provide:
-- OSPF process ID
-- Router ID (optional)
-- Networks to advertise with their areas
-- Any passive interfaces (optional)"
-```
-
-### When Request is Ambiguous
-
-**User**: "Configure BGP with the ISP"
-
-**AI Should Ask**:
-```
-"I need more details for BGP configuration:
-- Your local AS number
-- ISP's IP address and AS number
-- Networks to advertise (optional)
-- Any special settings like route-maps, passwords, etc. (optional)"
-```
-
-### When Feature is Not Supported
-
-**User**: "Configure MPLS LDP on this router"
-
-**AI Response**:
-```
-"MPLS LDP configuration is not yet supported through the template system.
-Available features: OSPF, BGP, Interface, VLAN, ACL, NAT.
-Would you like me to help with any of these?"
-```
-
----
-
-## Best Practices for AI Responses
-
-1. **Always use tool calls** - Don't just output the data, call the render_device_config tool
-
-2. **Validate before calling** - Check that all required fields are present
-
-3. **Ask for clarification** - If critical information is missing, ask the user
-
-4. **Be explicit about vendor/OS** - Don't assume, ask if not specified
-
-5. **Handle edge cases**:
- - Multiple interfaces with similar configs
- - Conflicting parameters
- - Invalid values (out of range)
-
-6. **Provide context** - Explain what you're about to configure before calling the tool
-
----
-
-## Tool Definition for LangChain
-
-```python
-from langchain_core.tools import tool
-from typing import Dict, Any
-
-@tool
-def render_device_config(
- node_id: str,
- vendor: str,
- os_type: str,
- config_data: Dict[str, Any]
-) -> str:
- """
- Render network device configuration using Jinja2 templates.
-
- Instead of generating full configuration text, provide structured data
- that will be rendered through vendor-specific templates.
-
- Args:
- node_id: GNS3 node identifier (e.g., "node-1", "R1")
- vendor: Device vendor - cisco, juniper, huawei, arista, mikrotik
- os_type: Operating system type - ios, iosxr, nx-os, junos, vrp, eos, routeros
- config_data: Structured configuration data (dict) for the features
-
- Returns:
- Rendered configuration string or error message
-
- Examples:
- >>> config = render_device_config(
- ... node_id="R1",
- ... vendor="cisco",
- ... os_type="ios",
- ... config_data={
- ... "ospf": {
- ... "enabled": True,
- ... "process_id": 100,
- ... "router_id": "1.1.1.1",
- ... "networks": [
- ... {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
- ... ]
- ... }
- ... }
- ... )
-
- Supported Features:
- - ospf: OSPF routing protocol
- - bgp: BGP routing protocol
- - interface: Interface configuration
- - vlan: VLAN configuration
- - acl: Access control lists
- - nat: NAT configuration
- - rip: RIP routing protocol
- - eigrp: EIGRP routing protocol
- """
- from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
-
- renderer = ConfigRenderer()
-
- try:
- # Validate data
- feature = list(config_data.keys())[0] if len(config_data) == 1 else None
- if feature:
- renderer.validate_data(f"{vendor}_{feature}", config_data)
-
- # Render
- if len(config_data) == 1:
- feature = list(config_data.keys())[0]
- config = renderer.render(vendor, os_type, feature, config_data)
- else:
- config = renderer.render_multi(vendor, os_type, config_data)
-
- return f"Configuration rendered successfully:\n{config}"
-
- except Exception as e:
- return f"Error: {str(e)}"
-
-
-@tool
-def list_available_templates() -> Dict[str, Any]:
- """
- List all available configuration templates organized by vendor and OS type.
-
- Returns:
- Dictionary of available templates:
- {
- "cisco": {
- "ios": ["ospf", "bgp", "interface", "vlan", "acl", "nat"],
- "nx-os": ["ospf", "bgp", "interface"]
- },
- "juniper": {
- "junos": ["ospf", "bgp", "interface"]
- }
- }
-
- Use this to understand what features are supported for each vendor/OS combination.
- """
- from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
-
- renderer = ConfigRenderer()
- return renderer.get_available_templates()
-
-
-@tool
-def validate_config_data(
- vendor: str,
- feature: str,
- config_data: Dict[str, Any]
-) -> Dict[str, Any]:
- """
- Validate configuration data against JSON schema before rendering.
-
- Args:
- vendor: Device vendor (cisco, juniper, huawei, etc.)
- feature: Feature name (ospf, bgp, interface, etc.)
- config_data: Configuration data to validate
-
- Returns:
- Validation result with status and optional error details
-
- Example:
- >>> result = validate_config_data(
- ... vendor="cisco",
- ... feature="ospf",
- ... config_data={"ospf": {"enabled": True, "process_id": 100}}
- ... )
- >>> # Returns: {"status": "valid"}
- """
- from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
-
- renderer = ConfigRenderer()
-
- try:
- renderer.validate_data(f"{vendor}_{feature}", config_data)
- return {"status": "valid", "message": "Configuration data is valid"}
- except Exception as e:
- return {"status": "invalid", "errors": str(e)}
-```
-
----
-
-## Complete Agent Integration Example
-
-```python
-# gns3server/agent/gns3_copilot/agent/config_agent.py
-
-from langchain.agents import create_openai_functions_agent, AgentExecutor
-from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
-
-# Define tools
-tools = [
- render_device_config,
- list_available_templates,
- validate_config_data,
- # ... other GNS3 tools
-]
-
-# Create prompt
-prompt = ChatPromptTemplate.from_messages([
- ("system", CONFIG_GENERATION_SYSTEM_PROMPT),
- MessagesPlaceholder(variable_name="chat_history", optional=True),
- ("human", "{input}"),
- MessagesPlaceholder(variable_name="agent_scratchpad"),
-])
-
-# Create agent
-agent = create_openai_functions_agent(llm, tools, prompt)
-agent_executor = AgentExecutor(
- agent=agent,
- tools=tools,
- verbose=True,
- handle_parsing_errors=True,
- max_iterations=5
-)
-
-# Example usage
-async def configure_device(user_message: str):
- response = await agent_executor.ainvoke({
- "input": user_message,
- "chat_history": []
- })
- return response
-```
-
----
-
-## Testing the AI Prompts
-
-Use these test cases to verify the AI generates correct structured data:
-
-```python
-test_cases = [
- {
- "input": "Configure OSPF process 100 with network 192.168.1.0/24 in area 0",
- "expected_keys": ["ospf"],
- "expected_values": {
- "ospf.process_id": 100,
- "ospf.networks[0].address": "192.168.1.0",
- "ospf.networks[0].area": 0
- }
- },
- {
- "input": "Set up BGP AS 65001, neighbor 10.0.0.2 remote-as 65002",
- "expected_keys": ["bgp"],
- "expected_values": {
- "bgp.as_number": 65001,
- "bgp.neighbors[0].ip": "10.0.0.2",
- "bgp.neighbors[0].remote_as": 65002
- }
- },
- # ... more test cases
-]
-```
-
----
-
-## Continuous Improvement
-
-1. **Collect user interactions** - Save actual requests and AI responses
-2. **Analyze errors** - Find patterns in failed generations
-3. **Update prompts** - Refine examples and instructions
-4. **Expand schemas** - Add new features as needed
-5. **Vendor feedback** - Learn from network engineers
-
----
-
-## Quick Reference Card
-
-### What AI Should Do
-
-| User Says | AI Generates |
-|-----------|-------------|
-| "OSPF process 100" | `{"ospf": {"enabled": True, "process_id": 100}}` |
-| "network 192.168.1.0/24 area 0" | `{"networks": [{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}]}` |
-| "BGP AS 65001" | `{"bgp": {"enabled": True, "as_number": 65001}}` |
-| "interface 192.168.1.1/24" | `{"ip_address": "192.168.1.1", "subnet_mask": "255.255.255.0"}` |
-
-### What AI Should NOT Do
-
-| Don't ❌ | Instead ✅ |
-|---------|-----------|
-| Output "router ospf 100" | Output `{"ospf": {"process_id": 100}}` |
-| Guess missing values | Ask user for missing values |
-| Assume vendor/OS | Ask or detect from node |
-| Mix features in one dict | Separate by feature key |
-| Use string for numbers | Use int: `process_id: 100` not `"process_id": "100"` |
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
diff --git a/docs/gns3-copilot/todo/config-templates-implementation-guide.md b/docs/gns3-copilot/todo/config-templates-implementation-guide.md
deleted file mode 100644
index 8e9859636..000000000
--- a/docs/gns3-copilot/todo/config-templates-implementation-guide.md
+++ /dev/null
@@ -1,736 +0,0 @@
-# Configuration Templates Implementation Guide
-
-## Quick Start Examples
-
-### Example 1: Configure OSPF on a Cisco Router
-
-**User Request**:
-```
-"Configure OSPF on R1 with process ID 100, router-id 1.1.1.1.
-Include network 192.168.1.0/24 in area 0 and 10.0.0.0/8 in area 1.
-Make GigabitEthernet0/0 a passive interface."
-```
-
-**AI Should Generate** (structured JSON):
-```json
-{
- "ospf": {
- "enabled": true,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {
- "address": "192.168.1.0",
- "wildcard": "0.0.0.255",
- "area": 0
- },
- {
- "address": "10.0.0.0",
- "wildcard": "0.255.255.255",
- "area": 1
- }
- ],
- "passive_interfaces": ["GigabitEthernet0/0"]
- }
-}
-```
-
-**Agent Action**:
-```python
-# The agent calls the render tool with the structured data
-result = render_device_config(
- node_id="node-1",
- vendor="cisco",
- os_type="ios",
- config_data=ai_output
-)
-```
-
-**Rendered Configuration**:
-```cisco
-router ospf 100
- router-id 1.1.1.1
- network 192.168.1.0 mask 0.0.0.255 area 0
- network 10.0.0.0 mask 0.255.255.255 area 1
- passive-interface GigabitEthernet0/0
-!
-```
-
----
-
-### Example 2: Configure BGP with Multiple Neighbors
-
-**User Request**:
-```
-"Configure BGP on R1 with AS 65001. Set up IBGP with R2 (10.0.0.2, AS 65001)
-and EBGP with ISP (203.0.13.2, AS 65002). Advertise network 192.168.0.0/16.
-Enable route-map INBOUND-FILTER on R2 inbound."
-```
-
-**AI Generates**:
-```json
-{
- "bgp": {
- "enabled": true,
- "as_number": 65001,
- "router_id": "1.1.1.1",
- "log_neighbor_changes": true,
- "neighbors": [
- {
- "ip": "10.0.0.2",
- "remote_as": 65001,
- "description": "IBGP_Peer_R2",
- "next_hop_self": true,
- "route_map_in": "INBOUND-FILTER"
- },
- {
- "ip": "203.0.13.2",
- "remote_as": 65002,
- "description": "ISP_EBGP",
- "ebgp_multihop": 2
- }
- ],
- "address_families": [
- {
- "type": "ipv4",
- "networks": [
- {
- "address": "192.168.0.0",
- "mask": "255.255.0.0"
- }
- ],
- "neighbors": [
- {"ip": "10.0.0.2", "activate": true},
- {"ip": "203.0.13.2", "activate": true}
- ]
- }
- ]
- }
-}
-```
-
-**Rendered Configuration**:
-```cisco
-router bgp 65001
- bgp router-id 1.1.1.1
- bgp log-neighbor-changes
- neighbor 10.0.0.2 remote-as 65001
- neighbor 10.0.0.2 description IBGP_Peer_R2
- neighbor 10.0.0.2 next-hop-self
- neighbor 10.0.0.2 route-map INBOUND-FILTER in
- neighbor 203.0.13.2 remote-as 65002
- neighbor 203.0.13.2 description ISP_EBGP
- neighbor 203.0.13.2 ebgp-multihop 2
- address-family ipv4
- network 192.168.0.0 mask 255.255.0.0
- neighbor 10.0.0.2 activate
- neighbor 203.0.13.2 activate
- exit-address-family
-!
-```
-
----
-
-### Example 3: Configure Interfaces with IP Addresses
-
-**User Request**:
-```
-"Configure GigabitEthernet0/0 with IP 192.168.1.1/24, description 'LAN Network'.
-Configure GigabitEthernet0/1 with IP 10.0.0.1/30, description 'WAN Link'.
-Both interfaces should be enabled."
-```
-
-**AI Generates**:
-```json
-{
- "interfaces": [
- {
- "name": "GigabitEthernet0/0",
- "description": "LAN Network",
- "ip_address": "192.168.1.1",
- "subnet_mask": "255.255.255.0",
- "enabled": true
- },
- {
- "name": "GigabitEthernet0/1",
- "description": "WAN Link",
- "ip_address": "10.0.0.1",
- "subnet_mask": "255.255.255.252",
- "enabled": true
- }
- ]
-}
-```
-
-**Rendered Configuration**:
-```cisco
-interface GigabitEthernet0/0
- description LAN Network
- ip address 192.168.1.1 255.255.255.0
- no shutdown
-!
-interface GigabitEthernet0/1
- description WAN Link
- ip address 10.0.0.1 255.255.255.252
- no shutdown
-!
-```
-
----
-
-## Advanced Examples
-
-### Example 4: Multi-Feature Configuration
-
-**User Request**:
-```
-"Configure R1 as follows:
-- Hostname: CORE-R1
-- GigabitEthernet0/0: 192.168.1.1/24, LAN, enable NAT inside
-- GigabitEthernet0/1: 203.0.13.1/30, WAN, enable NAT outside
-- OSPF: process 100, router-id 1.1.1.1, advertise 192.168.1.0/24 in area 0
-- NAT: overload interface GigabitEthernet0/1 for 192.168.1.0/24"
-```
-
-**AI Generates** (complete configuration):
-```json
-{
- "hostname": "CORE-R1",
- "interfaces": [
- {
- "name": "GigabitEthernet0/0",
- "description": "LAN",
- "ip_address": "192.168.1.1",
- "subnet_mask": "255.255.255.0",
- "nat_inside": true,
- "enabled": true
- },
- {
- "name": "GigabitEthernet0/1",
- "description": "WAN",
- "ip_address": "203.0.13.1",
- "subnet_mask": "255.255.255.252",
- "nat_outside": true,
- "enabled": true
- }
- ],
- "ospf": {
- "enabled": true,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {
- "address": "192.168.1.0",
- "wildcard": "0.0.0.255",
- "area": 0
- }
- ]
- },
- "nat": {
- "inside_source": {
- "pool": "LAN_POOL",
- "network": "192.168.1.0",
- "mask": "255.255.255.0",
- "interface": "GigabitEthernet0/1",
- "overload": true
- }
- }
-}
-```
-
----
-
-### Example 5: Juniper JunOS Configuration
-
-**User Request**:
-```
-"Configure Juniper SRX with OSPF area 0 on interface ge-0/0/0.0 with IP 192.168.1.1/24.
-Set router-id to 10.1.1.1."
-```
-
-**AI Generates**:
-```json
-{
- "ospf": {
- "enabled": true,
- "router_id": "10.1.1.1",
- "areas": [
- {
- "area_id": "0.0.0.0",
- "interfaces": [
- {
- "name": "ge-0/0/0.0",
- "address": "192.168.1.1/24"
- }
- ]
- }
- ]
- }
-}
-```
-
-**Template**: `config_templates/juniper/junos/ospf.j2`
-```jinja2
-{% if ospf.enabled %}
-protocols {
- ospf {
-{% if ospf.router_id %}
- router-id {{ ospf.router_id }};
-{% endif %}
-{% for area in ospf.areas %}
- area {{ area.area_id }} {
-{% for iface in area.interfaces %}
- interface {{ iface.name }} {
-{% if iface.address %}
- family inet {
- address {{ iface.address }};
- }
-{% endif %}
- }
-{% endfor %}
- }
-{% endfor %}
- }
-}
-{% endif %}
-```
-
-**Rendered Configuration**:
-```junos
-protocols {
- ospf {
- router-id 10.1.1.1;
- area 0.0.0.0 {
- interface ge-0/0/0.0 {
- family inet {
- address 192.168.1.1/24;
- }
- }
- }
- }
-}
-```
-
----
-
-## Integration with LangGraph Agent
-
-### Updated Agent Flow
-
-```python
-from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
-from langchain.agents import AgentExecutor, create_openai_functions_agent
-from langchain.tools import tool
-
-@tool
-def render_and_apply_config(
- node_id: str,
- vendor: str,
- os_type: str,
- config_data: dict
-) -> str:
- """Render configuration and apply to device"""
- from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
-
- renderer = ConfigRenderer()
-
- # Step 1: Validate
- try:
- renderer.validate_data(f"{vendor}_config", config_data)
- except Exception as e:
- return f"Validation failed: {e}"
-
- # Step 2: Render
- try:
- if len(config_data) == 1:
- feature = list(config_data.keys())[0]
- config = renderer.render(vendor, os_type, feature, config_data)
- else:
- config = renderer.render_multi(vendor, os_type, config_data)
-
- # Step 3: Apply to device (via telnet/console/SSH)
- # result = apply_config_to_node(node_id, config)
- return f"Configuration rendered successfully:\n{config}"
-
- except Exception as e:
- return f"Rendering failed: {e}"
-
-
-# Updated agent prompt
-SYSTEM_PROMPT = """
-You are a network configuration assistant for GNS3.
-
-When users ask to configure network devices:
-1. Extract the configuration requirements
-2. Generate STRUCTURED DATA (JSON/dict), NOT full configuration text
-3. Call the render_and_apply_config tool with the structured data
-4. The system will render the actual configuration using templates
-
-Example for OSPF:
-- User: "Configure OSPF with process 100, network 192.168.1.0/24 in area 0"
-- You should output: {"ospf": {"enabled": true, "process_id": 100, ...}}
-
-Available vendors: cisco, juniper, huawei, arista
-Available OS types: ios, iosxr, nexus, junos, vrp, eos
-"""
-```
-
----
-
-## Template Snippets Library
-
-### OSPF Interface Templates
-
-**Cisco IOS**:
-```jinja2
-{# ospf.j2 - Cisco IOS OSPF #}
-{% if ospf.enabled %}
-router ospf {{ ospf.process_id }}
-{% if ospf.router_id %}
- router-id {{ ospf.router_id }}
-{% endif %}
-{% for network in ospf.networks %}
- network {{ network.address }} mask {{ network.wildcard }} area {{ network.area }}
-{% endfor %}
-{% for iface in ospf.passive_interfaces %}
- passive-interface {{ iface }}
-{% endfor %}
-!
-{% endif %}
-```
-
-**Juniper JunOS**:
-```jinja2
-{# ospf.j2 - Juniper JunOS OSPF #}
-{% if ospf.enabled %}
-protocols {
- ospf {
-{% if ospf.router_id %}
- router-id {{ ospf.router_id }};
-{% endif %}
-{% for area in ospf.areas %}
- area {{ area.area_id }} {
-{% for iface in area.interfaces %}
- interface {{ iface.name }};
-{% endfor %}
- }
-{% endfor %}
- }
-}
-{% endif %}
-```
-
-**Huawei VRP**:
-```jinja2
-{# ospf.j2 - Huawei VRP OSPF #}
-{% if ospf.enabled %}
-ospf {{ ospf.process_id }}
-{% if ospf.router_id %}
- router-id {{ ospf.router_id }}
-{% endif %}
-{% for area in ospf.areas %}
- area {{ area.area_id }}
-{% for network in area.networks %}
- network {{ network.address }} {{ network.wildcard }}
-{% endfor %}
-{% endfor %}
-{% endif %}
-```
-
----
-
-## Testing Framework
-
-### Unit Test for Template Rendering
-
-```python
-# tests/agent/test_config_renderer.py
-
-import pytest
-from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
-
-def test_ospf_cisco_ios():
- """Test OSPF configuration rendering for Cisco IOS"""
- renderer = ConfigRenderer()
-
- data = {
- "ospf": {
- "enabled": True,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
- ]
- }
- }
-
- config = renderer.render("cisco", "ios", "ospf", data)
-
- assert "router ospf 100" in config
- assert "router-id 1.1.1.1" in config
- assert "network 192.168.1.0 mask 0.0.0.255 area 0" in config
-
-def test_bgp_cisco_ios():
- """Test BGP configuration rendering for Cisco IOS"""
- renderer = ConfigRenderer()
-
- data = {
- "bgp": {
- "enabled": True,
- "as_number": 65001,
- "neighbors": [
- {"ip": "10.0.0.2", "remote_as": 65002}
- ],
- "address_families": [
- {
- "type": "ipv4",
- "neighbors": [
- {"ip": "10.0.0.2", "activate": True}
- ]
- }
- ]
- }
- }
-
- config = renderer.render("cisco", "ios", "bgp", data)
-
- assert "router bgp 65001" in config
- assert "neighbor 10.0.0.2 remote-as 65002" in config
- assert "address-family ipv4" in config
- assert "neighbor 10.0.0.2 activate" in config
-
-def test_interface_cisco_ios():
- """Test interface configuration rendering"""
- renderer = ConfigRenderer()
-
- data = {
- "interfaces": [
- {
- "name": "GigabitEthernet0/0",
- "description": "Test Interface",
- "ip_address": "192.168.1.1",
- "subnet_mask": "255.255.255.0",
- "enabled": True
- }
- ]
- }
-
- config = renderer.render("cisco", "ios", "interface", data)
-
- assert "interface GigabitEthernet0/0" in config
- assert "description Test Interface" in config
- assert "ip address 192.168.1.1 255.255.255.0" in config
- assert "no shutdown" in config
-```
-
----
-
-## API Integration
-
-### New Controller Endpoint
-
-```python
-# gns3server/api/routes/controller/config_templates.py
-
-from fastapi import APIRouter, Depends
-from typing import Dict, Any
-from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
-
-router = APIRouter()
-
-@router.get("/config-templates")
-async def list_templates() -> Dict[str, Any]:
- """List all available configuration templates"""
- renderer = ConfigRenderer()
- return renderer.get_available_templates()
-
-@router.post("/config-templates/render")
-async def render_config_template(
- vendor: str,
- os_type: str,
- feature: str,
- data: Dict[str, Any]
-) -> Dict[str, str]:
- """Render a configuration template with provided data"""
- renderer = ConfigRenderer()
-
- try:
- config = renderer.render(vendor, os_type, feature, data)
- return {"status": "success", "config": config}
- except Exception as e:
- return {"status": "error", "message": str(e)}
-
-@router.post("/config-templates/validate")
-async def validate_config_data(
- schema_name: str,
- data: Dict[str, Any]
-) -> Dict[str, Any]:
- """Validate configuration data against schema"""
- renderer = ConfigRenderer()
-
- try:
- is_valid = renderer.validate_data(schema_name, data)
- return {"status": "valid"}
- except Exception as e:
- return {"status": "invalid", "errors": str(e)}
-```
-
----
-
-## Prompt Engineering for AI
-
-### System Prompt Template
-
-```python
-CONFIG_GENERATION_PROMPT = """
-You are a network configuration expert. When users request device configurations:
-
-1. UNDERSTAND the requirements (vendor, OS, features, parameters)
-2. GENERATE structured data (dict/JSON), NOT full configuration text
-3. CALL the appropriate rendering tool with the structured data
-
-RULES:
-- NEVER output full configuration text directly
-- ALWAYS use structured data format
-- Include only the parameters that are explicitly mentioned
-- Use correct data types (int for numbers, bool for flags)
-- Follow the JSON schema for each feature
-
-VENDORS: cisco, juniper, huawei, arista, mikrotik
-OS TYPES: ios, iosxr, nx-os, junos, vrp, eos, routeros
-
-FEATURES AVAILABLE:
-- ospf: process_id, router_id, networks[{address,wildcard,area}]
-- bgp: as_number, router_id, neighbors[{ip,remote_as,description,...}]
-- interface: name, ip_address, subnet_mask, description, enabled
-- vlan: id, name, interfaces[]
-- acl: number, rules[{action,protocol,source,destination}]
-- nat: inside_source, outside_source, static
-
-EXAMPLE:
-
-User: "Configure OSPF process 100 with router-id 1.1.1.1, network 192.168.1.0/24 area 0"
-
-Your tool call:
-render_device_config(
- node_id="node-1",
- vendor="cisco",
- os_type="ios",
- config_data={{
- "ospf": {{
- "enabled": True,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {{"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}}
- ]
- }}
- }}
-)
-"""
-```
-
----
-
-## Migration Path
-
-### Phase 1: Core Templates (Week 1-2)
-- Cisco IOS: ospf, bgp, interface, vlan, acl
-- Juniper JunOS: ospf, bgp, interface
-- Schema definitions
-
-### Phase 2: Extended Features (Week 3-4)
-- NAT, QoS, Multicast
-- Nexus, IOS-XR variants
-- Huawei VRP support
-
-### Phase 3: Advanced Features (Week 5-6)
-- MPLS, VPN
-- Firewall policies (ASA, SRX)
-- Automation and testing
-
-### Phase 4: Integration (Week 7-8)
-- Integrate with AI Copilot
-- Add rendering endpoint to API
-- Testing and validation
-
----
-
-## Best Practices
-
-1. **Template Design**:
- - Keep templates simple and focused
- - Use conditionals sparingly
- - Add comments for complex logic
- - Follow vendor syntax conventions
-
-2. **Schema Design**:
- - Define all fields with types
- - Add descriptions for AI
- - Include validation rules
- - Use enums for fixed values
-
-3. **AI Prompting**:
- - Provide clear examples
- - Specify expected output format
- - Include error handling guidance
- - Test with various inputs
-
-4. **Testing**:
- - Unit test each template
- - Test with real devices
- - Validate schemas
- - Integration testing
-
----
-
-## Troubleshooting
-
-### Common Issues
-
-**Issue**: Template not found
-```
-Solution: Check template path format: "{vendor}/{os_type}/{feature}.j2"
-```
-
-**Issue**: Invalid data structure
-```
-Solution: Validate against JSON schema first
-```
-
-**Issue**: Rendering produces empty config
-```
-Solution: Check if feature flag "enabled" is set to True
-```
-
-**Issue**: Syntax error in rendered config
-```
-Solution: Review template logic, check conditional statements
-```
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
diff --git a/docs/gns3-copilot/todo/enhance-user-me-endpoint.md b/docs/gns3-copilot/todo/enhance-user-me-endpoint.md
deleted file mode 100644
index d3f19c009..000000000
--- a/docs/gns3-copilot/todo/enhance-user-me-endpoint.md
+++ /dev/null
@@ -1,852 +0,0 @@
-# Enhance `/me` Endpoint with Groups, Pools, and ACEs
-
-**Document Status**: Design Phase
-**Priority**: High
-**Created**: 2026-03-06
-**Related Docs**: [User-Selectable Group Default Config](./user-selectable-group-default-config.md)
-
----
-
-## Table of Contents
-
-- [Problem Description](#problem-description)
-- [Data Model Analysis](#data-model-analysis)
-- [Solution Design](#solution-design)
-- [Implementation Steps](#implementation-steps)
-- [API Response Structure](#api-response-structure)
-- [Testing Plan](#testing-plan)
-
----
-
-## Problem Description
-
-### Current Behavior
-
-The `/me` endpoint only returns basic user information. Users cannot easily see:
-1. Which groups they belong to
-2. Which resource pools they have access to
-3. Their access control entries (ACEs)
-
-### User Needs
-
-1. **Group Membership**: Understand inherited configs and permissions
-2. **Pool Access**: Know which resource pools are available
-3. **ACE Visibility**: See what access control rules apply to them
-
----
-
-## Data Model Analysis
-
-### User Model Relationships
-
-**File**: `gns3server/db/models/users.py:38-52`
-
-```python
-class User(BaseTable):
- __tablename__ = "users"
-
- user_id = Column(GUID, primary_key=True, default=generate_uuid)
- username = Column(String, unique=True, index=True)
- email = Column(String, unique=True, index=True)
- full_name = Column(String)
- hashed_password = Column(String)
- last_login = Column(DateTime)
- is_active = Column(Boolean, default=True)
- is_superadmin = Column(Boolean, default=False)
-
- # Relationships
- groups = relationship("UserGroup", secondary=user_group_map, back_populates="users")
- acl_entries = relationship("ACE") # User's direct ACEs
-```
-
-### ACE Model
-
-**File**: `gns3server/db/models/acl.py:28-46`
-
-```python
-class ACE(BaseTable):
- __tablename__ = "acl"
-
- ace_id = Column(GUID, primary_key=True, default=generate_uuid)
- ace_type = Column(String) # "user" or "group"
- path = Column(String) # e.g., "/pools/{pool_id}", "/projects"
- propagate = Column(Boolean, default=True)
- allowed = Column(Boolean, default=True)
- user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
- user = relationship("User", back_populates="acl_entries")
- group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE"))
- group = relationship("UserGroup", back_populates="acl_entries")
- role_id = Column(GUID, ForeignKey('roles.role_id', ondelete="CASCADE"))
- role = relationship("Role", back_populates="acl_entries")
-```
-
-### Resource Pool Model
-
-**File**: `gns3server/db/models/pools.py:46-53`
-
-```python
-class ResourcePool(BaseTable):
- __tablename__ = "resource_pools"
-
- resource_pool_id = Column(GUID, primary_key=True, default=generate_uuid)
- name = Column(String, unique=True, index=True)
- resources = relationship("Resource", secondary=resource_pool_map, back_populates="resource_pools")
-```
-
-### Key Relationships
-
-```
-User ────< UserGroup > (via user_group_map)
- │
- └───< ACE (user_id)
- │
- ├── path = "/pools/{pool_id}" → ResourcePool
- ├── path = "/projects"
- ├── role → Role → Privilege
- └── allowed (boolean)
-
-UserGroup ────< ACE (group_id)
- │
- └─── users
-```
-
-### Pool Path Format
-
-From `gns3server/db/repositories/rbac.py:326-327`:
-
-```python
-if ace_path.startswith("/pool"):
- resource_pool_id = ace_path.split("/")[2]
-```
-
-**Pool ACE Path Format**: `/pools/{resource_pool_id}`
-
----
-
-## Solution Design
-
-### Approach
-
-1. **Groups**: Eager load via `selectinload(User.groups)`
-2. **Pools**: Extract from user and group ACEs where path starts with `/pools/`
-3. **ACEs**: Aggregate user's direct ACEs and group ACEs
-
-### Response Structure
-
-```json
-{
- // ===== Basic User Info =====
- "user_id": "uuid",
- "username": "string",
- "email": "string",
- "full_name": "string",
- "is_active": true,
- "is_superadmin": false,
- "last_login": "datetime",
- "created_at": "datetime",
- "updated_at": "datetime",
-
- // ===== User Groups =====
- "groups": [
- {
- "user_group_id": "uuid",
- "name": "Developers",
- "is_builtin": false,
- "created_at": "datetime",
- "updated_at": "datetime"
- }
- ],
-
- // ===== Accessible Pools =====
- "pools": [
- {
- "resource_pool_id": "uuid",
- "name": "Production Pool",
- "access_source": "user", // "user" or "group"
- "access_allowed": true
- }
- ],
-
- // ===== ACEs =====
- "aces": [
- {
- "ace_id": "uuid",
- "path": "/pools/{pool_id}",
- "allowed": true,
- "propagate": true,
- "ace_type": "user", // "user" or "group"
- "source_group_id": null, // null if ace_type is "user"
- "source_group_name": null
- }
- ]
-}
-```
-
----
-
-## Implementation Steps
-
-### Step 1: Update Schemas
-
-**File**: `gns3server/schemas/controller/users.py`
-
-```python
-from typing import List, Optional
-from datetime import datetime
-from pydantic import ConfigDict, EmailStr, BaseModel, Field, SecretStr
-from uuid import UUID
-
-from .base import DateTimeModelMixin
-
-
-class UserGroup(BaseModel):
- """User group reference."""
- user_group_id: UUID
- name: str
- is_builtin: bool
- created_at: datetime
- updated_at: datetime
-
- model_config = ConfigDict(from_attributes=True)
-
-
-class ResourcePoolInfo(BaseModel):
- """Resource pool info accessible to user."""
- resource_pool_id: UUID
- name: str
- access_source: str = Field(..., description="'user' or 'group'")
- access_allowed: bool = Field(..., description="Whether access is allowed")
-
- model_config = ConfigDict(from_attributes=True)
-
-
-class ACEInfo(BaseModel):
- """Access Control Entry info."""
- ace_id: UUID
- path: str
- allowed: bool
- propagate: bool
- ace_type: str = Field(..., description="'user' or 'group'")
- source_group_id: Optional[UUID] = Field(None, description="Group ID if from group ACE")
- source_group_name: Optional[str] = Field(None, description="Group name if from group ACE")
-
- model_config = ConfigDict(from_attributes=True)
-
-
-class UserBase(BaseModel):
- """Common user properties."""
- username: Optional[str] = Field(None, min_length=3, pattern="[a-zA-Z0-9_-]+$")
- is_active: bool = True
- email: Optional[EmailStr] = None
- full_name: Optional[str] = None
-
-
-class User(DateTimeModelMixin, UserBase):
- user_id: UUID
- last_login: Optional[datetime] = None
- is_superadmin: bool = False
-
- # NEW FIELDS
- groups: List[UserGroup] = []
- pools: List[ResourcePoolInfo] = []
- aces: List[ACEInfo] = []
-
- model_config = ConfigDict(from_attributes=True)
-
-
-# Other existing schemas...
-class UserCreate(UserBase):
- username: str = Field(..., min_length=3, pattern="[a-zA-Z0-9_-]+$")
- password: SecretStr = Field(..., min_length=8, max_length=100)
-
-
-class UserUpdate(UserBase):
- password: Optional[SecretStr] = Field(None, min_length=8, max_length=100)
-
-
-class LoggedInUserUpdate(BaseModel):
- password: Optional[SecretStr] = Field(None, min_length=8, max_length=100)
- email: Optional[EmailStr] = None
- full_name: Optional[str] = None
-
-
-class Credentials(BaseModel):
- username: str
- password: str
-```
-
-### Step 2: Update Repository Method
-
-**File**: `gns3server/db/repositories/users.py`
-
-```python
-from uuid import UUID
-from typing import Optional, List, Dict, Any
-from sqlalchemy import select, update, delete, func
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
-
-from .base import BaseRepository
-
-import gns3server.db.models as models
-from gns3server import schemas
-from gns3server.services import auth_service
-
-import logging
-
-log = logging.getLogger(__name__)
-
-
-class UsersRepository(BaseRepository):
-
- # ... existing methods ...
-
- async def get_user_with_details(
- self,
- user_id: UUID,
- include_pools: bool = True,
- include_aces: bool = True
- ) -> Optional[Dict[str, Any]]:
- """
- Get user with groups, pools, and ACEs.
-
- Args:
- user_id: User UUID
- include_pools: Whether to include accessible resource pools
- include_aces: Whether to include ACEs
-
- Returns:
- Dictionary with user, groups, pools, and aces
- """
-
- # Get user with groups eagerly loaded
- query = select(models.User).where(
- models.User.user_id == user_id
- ).options(selectinload(models.User.groups))
-
- result = await self._db_session.execute(query)
- user = result.scalars().first()
-
- if not user:
- return None
-
- # Prepare response
- response = {
- "user": user,
- "groups": list(user.groups),
- "pools": [],
- "aces": []
- }
-
- if not include_pools and not include_aces:
- return response
-
- # Get user's direct ACEs
- user_aces_query = select(models.ACE).where(
- models.ACE.user_id == user_id
- )
- user_aces_result = await self._db_session.execute(user_aces_query)
- user_aces = user_aces_result.scalars().all()
-
- # Get group ACEs (inherited from user's groups)
- group_aces = []
- for group in user.groups:
- group_aces_query = select(models.ACE).where(
- models.ACE.group_id == group.user_group_id
- )
- group_aces_result = await self._db_session.execute(group_aces_query)
- group_aces.extend(group_aces_result.scalars().all())
-
- # Process ACEs and extract pools
- pool_ids_seen = set()
-
- if include_aces:
- # Add user ACEs
- for ace in user_aces:
- response["aces"].append({
- "ace_id": ace.ace_id,
- "path": ace.path,
- "allowed": ace.allowed,
- "propagate": ace.propagate,
- "ace_type": "user",
- "source_group_id": None,
- "source_group_name": None
- })
-
- # Add group ACEs
- for ace in group_aces:
- response["aces"].append({
- "ace_id": ace.ace_id,
- "path": ace.path,
- "allowed": ace.allowed,
- "propagate": ace.propagate,
- "ace_type": "group",
- "source_group_id": ace.group_id,
- "source_group_name": next((g.name for g in user.groups if g.user_group_id == ace.group_id), None)
- })
-
- if include_pools:
- # Extract pools from ACEs
- for ace in user_aces + group_aces:
- if ace.path.startswith("/pools/") and ace.allowed:
- try:
- pool_id = UUID(ace.path.split("/")[2])
-
- if pool_id not in pool_ids_seen:
- # Get pool info
- pool_query = select(models.ResourcePool).where(
- models.ResourcePool.resource_pool_id == pool_id
- )
- pool_result = await self._db_session.execute(pool_query)
- pool = pool_result.scalars().first()
-
- if pool:
- response["pools"].append({
- "resource_pool_id": pool.resource_pool_id,
- "name": pool.name,
- "access_source": "user" if ace.user_id else "group",
- "access_allowed": ace.allowed
- })
- pool_ids_seen.add(pool_id)
- except (ValueError, IndexError) as e:
- log.warning(f"Invalid pool path format: {ace.path}, error: {e}")
-
- return response
-```
-
-### Step 3: Update API Endpoint
-
-**File**: `gns3server/api/routes/controller/users.py`
-
-```python
-@router.get("/me", response_model=schemas.User)
-async def get_logged_in_user(
- current_user: schemas.User = Depends(get_current_active_user),
- users_repo: UsersRepository = Depends(get_repository(UsersRepository))
-) -> schemas.User:
- """
- Get the current active user (including groups, pools, and ACEs).
-
- Returns comprehensive user information including:
- - Basic user profile
- - Group memberships
- - Accessible resource pools
- - Access control entries (ACEs)
- """
-
- # Fetch user with all details
- user_details = await users_repo.get_user_with_details(
- current_user.user_id,
- include_pools=True,
- include_aces=True
- )
-
- if not user_details:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found"
- )
-
- # Convert to schema
- user = user_details["user"]
-
- return schemas.User(
- user_id=user.user_id,
- username=user.username,
- email=user.email,
- full_name=user.full_name,
- is_active=user.is_active,
- is_superadmin=user.is_superadmin,
- last_login=user.last_login,
- created_at=user.created_at,
- updated_at=user.updated_at,
- groups=[schemas.UserGroup.model_validate(g) for g in user_details["groups"]],
- pools=[schemas.ResourcePoolInfo(**p) for p in user_details["pools"]],
- aces=[schemas.ACEInfo(**a) for a in user_details["aces"]]
- )
-```
-
----
-
-## API Response Structure
-
-### Complete Example
-
-```json
-{
- "user_id": "550e8400-e29b-41d4-a716-446655440000",
- "username": "johndoe",
- "email": "john@example.com",
- "full_name": "John Doe",
- "is_active": true,
- "is_superadmin": false,
- "last_login": "2026-03-06T10:30:00Z",
- "created_at": "2026-01-01T00:00:00Z",
- "updated_at": "2026-03-06T10:30:00Z",
-
- "groups": [
- {
- "user_group_id": "650e8400-e29b-41d4-a716-446655440001",
- "name": "Developers",
- "is_builtin": false,
- "created_at": "2026-01-01T00:00:00Z",
- "updated_at": "2026-01-01T00:00:00Z"
- },
- {
- "user_group_id": "750e8400-e29b-41d4-a716-446655440002",
- "name": "Administrators",
- "is_builtin": true,
- "created_at": "2026-01-01T00:00:00Z",
- "updated_at": "2026-01-01T00:00:00Z"
- }
- ],
-
- "pools": [
- {
- "resource_pool_id": "850e8400-e29b-41d4-a716-446655440003",
- "name": "Production Pool",
- "access_source": "user",
- "access_allowed": true
- },
- {
- "resource_pool_id": "950e8400-e29b-41d4-a716-446655440004",
- "name": "Development Pool",
- "access_source": "group",
- "access_allowed": true
- }
- ],
-
- "aces": [
- {
- "ace_id": "a50e8400-e29b-41d4-a716-446655440005",
- "path": "/pools/850e8400-e29b-41d4-a716-446655440003",
- "allowed": true,
- "propagate": true,
- "ace_type": "user",
- "source_group_id": null,
- "source_group_name": null
- },
- {
- "ace_id": "b50e8400-e29b-41d4-a716-446655440006",
- "path": "/projects",
- "allowed": true,
- "propagate": true,
- "ace_type": "group",
- "source_group_id": "650e8400-e29b-41d4-a716-446655440001",
- "source_group_name": "Developers"
- },
- {
- "ace_id": "c50e8400-e29b-41d4-a716-446655440007",
- "path": "/pools/950e8400-e29b-41d4-a716-446655440004",
- "allowed": true,
- "propagate": true,
- "ace_type": "group",
- "source_group_id": "650e8400-e29b-41d4-a716-446655440001",
- "source_group_name": "Developers"
- }
- ]
-}
-```
-
----
-
-## Testing Plan
-
-### Unit Tests
-
-#### Test `get_user_with_details` Repository Method
-
-```python
-import pytest
-from uuid import uuid4
-
-@pytest.mark.asyncio
-async def test_get_user_with_groups_only(db_session, test_user, test_group):
- """Test getting user with groups only."""
- from gns3server.db.repositories.users import UsersRepository
-
- repo = UsersRepository(db_session)
- result = await repo.get_user_with_details(
- test_user.user_id,
- include_pools=False,
- include_aces=False
- )
-
- assert result is not None
- assert len(result["groups"]) > 0
- assert result["groups"][0].name == test_group.name
- assert result["pools"] == []
- assert result["aces"] == []
-
-
-@pytest.mark.asyncio
-async def test_get_user_with_pools_and_aces(db_session, test_user, test_pool, test_ace):
- """Test getting user with pools and ACEs."""
- from gns3server.db.repositories.users import UsersRepository
-
- repo = UsersRepository(db_session)
- result = await repo.get_user_with_details(
- test_user.user_id,
- include_pools=True,
- include_aces=True
- )
-
- assert result is not None
- assert len(result["pools"]) > 0
- assert result["pools"][0]["name"] == test_pool.name
- assert len(result["aces"]) > 0
- assert result["aces"][0]["path"].startswith("/pools/")
-
-
-@pytest.mark.asyncio
-async def test_get_user_with_group_pools(db_session, test_user, test_group, test_group_pool, test_group_ace):
- """Test getting user with pools inherited from groups."""
- from gns3server.db.repositories.users import UsersRepository
-
- repo = UsersRepository(db_session)
- result = await repo.get_user_with_details(
- test_user.user_id,
- include_pools=True,
- include_aces=True
- )
-
- assert result is not None
- # Should have pool from group ACE
- group_pools = [p for p in result["pools"] if p["access_source"] == "group"]
- assert len(group_pools) > 0
-```
-
-### Integration Tests
-
-#### Test `/me` Endpoint Response
-
-```python
-def test_get_me_with_all_details(test_client, auth_token, test_user_with_groups_and_pools):
- """Test GET /me returns groups, pools, and ACEs."""
-
- response = test_client.get(
- "/v3/access/users/me",
- headers={"Authorization": f"Bearer {auth_token}"}
- )
-
- assert response.status_code == 200
- data = response.json()
-
- # Verify basic user info
- assert "user_id" in data
- assert "username" in data
-
- # Verify groups
- assert "groups" in data
- assert isinstance(data["groups"], list)
- assert len(data["groups"]) > 0
- assert "user_group_id" in data["groups"][0]
- assert "name" in data["groups"][0]
-
- # Verify pools
- assert "pools" in data
- assert isinstance(data["pools"], list)
- if len(data["pools"]) > 0:
- pool = data["pools"][0]
- assert "resource_pool_id" in pool
- assert "name" in pool
- assert "access_source" in pool
- assert pool["access_source"] in ["user", "group"]
-
- # Verify ACEs
- assert "aces" in data
- assert isinstance(data["aces"], list)
- if len(data["aces"]) > 0:
- ace = data["aces"][0]
- assert "ace_id" in ace
- assert "path" in ace
- assert "allowed" in ace
- assert "ace_type" in ace
- assert ace["ace_type"] in ["user", "group"]
-
-
-def test_get_me_user_with_no_groups(test_client, auth_token, test_user_no_groups):
- """Test GET /me for user with no groups."""
-
- response = test_client.get(
- "/v3/access/users/me",
- headers={"Authorization": f"Bearer {auth_token}"}
- )
-
- assert response.status_code == 200
- data = response.json()
-
- assert data["groups"] == []
- # May still have pools and ACEs from direct user ACEs
-```
-
----
-
-## Benefits
-
-| Feature | Benefit |
-|---------|---------|
-| **Groups in /me** | Users see inherited configs and permissions |
-| **Pools in /me** | Users know available resource pools without separate API call |
-| **ACEs in /me** | Transparency - users see their access control rules |
-| **Single API Call** | Frontend gets all user context in one request |
-| **No Privilege Required** | Users can always see their own info |
-
----
-
-## Use Cases
-
-### 1. Frontend User Profile Page
-
-```javascript
-// Get complete user context
-const response = await fetch('/v3/access/users/me', {
- headers: { 'Authorization': `Bearer ${token}` }
-});
-const user = await response.json();
-
-// Display groups
-console.log('Member of:', user.groups.map(g => g.name));
-
-// Display available pools
-console.log('Accessible pools:', user.pools.map(p => p.name));
-
-// Display ACE summary
-console.log('ACEs:', user.aces.length);
-```
-
-### 2. LLM Config Selection UI
-
-```javascript
-// User wants to select from inherited configs
-const user = await fetchCurrentUser();
-
-// Show which configs are from which groups
-user.groups.forEach(group => {
- console.log(`Configs from ${group.name}:`, getGroupConfigs(group.user_group_id));
-});
-```
-
-### 3. Permission Troubleshooting
-
-```javascript
-// User can't access a resource - why?
-const user = await fetchCurrentUser();
-
-// Check if user has pool access
-const hasPoolAccess = user.pools.some(p => p.resource_pool_id === targetPoolId);
-
-// Check ACEs
-const relevantACEs = user.aces.filter(ace => ace.path.includes(resourcePath));
-console.log('Relevant ACEs:', relevantACEs);
-```
-
----
-
-## Performance Considerations
-
-| Query | Complexity | Optimization |
-|-------|------------|--------------|
-| Get user with groups | 1 JOIN (eager load) | Uses `selectinload` |
-| Get user ACEs | 1 query | Direct index lookup |
-| Get group ACEs | N queries (one per group) | Could optimize with subquery |
-| Get pool details | M queries (one per unique pool) | Could batch fetch |
-
-**Potential Optimization**:
-
-```python
-# Batch fetch all pools in one query
-pool_ids = [extract_pool_id_from_ace(ace) for ace in all_aces]
-
-pools_query = select(models.ResourcePool).where(
- models.ResourcePool.resource_pool_id.in_(pool_ids)
-)
-pools_result = await self._db_session.execute(pools_query)
-pools = {p.resource_pool_id: p for p in pools_result.scalars().all()}
-```
-
----
-
-## Security Considerations
-
-### Data Exposure
-
-| Data | Visibility | Rationale |
-|------|-----------|-----------|
-| Basic user info | User themselves | Already exposed in current `/me` |
-| Groups | User themselves | User knows which groups they joined |
-| Pools | User themselves | User knows which pools they can access |
-| ACEs | User themselves | Transparency about access rules |
-| Other users' data | **Hidden** | Not included in response |
-
-### Access Control
-
-- **Authentication Required**: Must provide valid JWT token
-- **No Special Privilege**: Users can always view their own data
-- **Filtering**: Only returns data for the authenticated user
-
----
-
-## Future Enhancements
-
-1. **Roles**: Add user's roles (derived from ACEs)
- ```json
- "roles": ["User", "Auditor"]
- ```
-
-2. **Effective Privileges**: Consolidated privilege list
- ```json
- "privileges": ["Project.Audit", "Node.Create"]
- ```
-
-3. **Resource Counts**: Summary of accessible resources
- ```json
- "resources_summary": {
- "projects_count": 5,
- "templates_count": 3
- }
- ```
-
----
-
-## Code Changes Checklist
-
-| File | Change Type | Description |
-|------|-------------|-------------|
-| `gns3server/schemas/controller/users.py` | Modify | Add UserGroup, ResourcePoolInfo, ACEInfo schemas; Update User schema |
-| `gns3server/db/repositories/users.py` | Modify | Add `get_user_with_details` method |
-| `gns3server/api/routes/controller/users.py` | Modify | Update `/me` endpoint to use new method |
-
----
-
-**Document Version**: 1.0
-**Last Updated**: 2026-03-06
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/hitl-implementation-plan.md b/docs/gns3-copilot/todo/hitl-implementation-plan.md
deleted file mode 100644
index 6b0499c17..000000000
--- a/docs/gns3-copilot/todo/hitl-implementation-plan.md
+++ /dev/null
@@ -1,1431 +0,0 @@
-# GNS3 Copilot HITL Feature Implementation Plan
-
-## Overview
-
-This document details the complete implementation plan for the Human-in-the-Loop (HITL) feature in GNS3 Copilot. The HITL feature allows requiring user confirmation before executing sensitive operations (such as device configuration), improving system security.
-
-## Goals
-
-- Require user confirmation before executing configuration tools
-- Support single and batch tool confirmation
-- Provide clear tool execution preview
-- Maintain complete compatibility with existing functionality
-
-## Core Design
-
-### Flow Chart
-
-```
-User Message → LLM → Tool Call Decision
- ↓
- Check if confirmation needed
- ↙ ↘
- Need HITL Direct Execution
- ↓ ↓
- Pause, wait for frontend Execute Tool
- ↓ ↓
- User Confirm/Reject Return Result
- ↓
- Execute Confirmed Tools
- ↓
- Return Result
-```
-
-### Architecture Layers
-
-```
-┌─────────────────────────────────────────┐
-│ Frontend (Web UI) │
-│ - Display confirmation dialog │
-│ - List pending tools │
-│ - Handle user confirm/reject actions │
-└─────────────────────────────────────────┘
- ↕ SSE/HTTP
-┌─────────────────────────────────────────┐
-│ API Layer (FastAPI) │
-│ - /hitl/status: Get pending tools │
-│ - /hitl/confirm: Confirm execution │
-│ - /hitl/reject: Reject execution │
-└─────────────────────────────────────────┘
- ↕
-┌─────────────────────────────────────────┐
-│ AgentService (State Management) │
-│ - Manage HITL state │
-│ - Handle confirm/reject logic │
-│ - checkpoint persistence │
-└─────────────────────────────────────────┘
- ↕
-┌─────────────────────────────────────────┐
-│ LangGraph Agent (Workflow) │
-│ - llm_call: LLM invocation │
-│ - check_hitl: Check if confirmation needed │
-│ - hitl_confirmation: Wait for confirmation │
-│ - conditional_execution: Conditional execution │
-└─────────────────────────────────────────┘
-```
-
-## Implementation Changes
-
-### 1. LangGraph Agent Layer
-
-#### File: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py`
-
-##### Change 1.1: Extend MessagesState
-
-**Location**: Lines 98-124
-
-```python
-# Extend state definition, add HITL-related fields
-class MessagesState(TypedDict):
- """GNS3-Copilot Conversation State Management Class"""
-
- messages: Annotated[list[AnyMessage], operator.add]
- llm_calls: int
- remaining_steps: RemainingSteps
- conversation_title: str | None
- topology_info: dict | None
-
- # New: HITL-related fields
- pending_tool_calls: list[dict] # List of tool calls awaiting confirmation
- hitl_confirmation_required: bool # Whether user confirmation is needed
- hitl_session_id: str | None # HITL session unique identifier
- confirmed_tool_calls: list[dict] # User-confirmed tool calls
- rejected_tool_calls: list[dict] # User-rejected tool calls
-```
-
-**Change Impact**:
-- 5 new fields in checkpoint database
-- Existing state reading code needs to be compatible with new fields
-
-##### Change 1.2: Add HITL Detection Node
-
-**Location**: Add after `generate_title` function (around line 310)
-
-```python
-# List of tools requiring confirmation
-HITL_TOOLS = {
- "execute_multiple_device_config_commands",
- # Can be added as needed:
- # "delete_node",
- # "start_gns3_node",
-}
-
-DANGEROUS_PATTERNS = [
- "reload", "reboot", "write erase", "erase startup-config",
- "factory reset", "format", "delete", "no ip routing"
-]
-
-
-def _is_dangerous_config(tool_name: str, tool_args: dict) -> bool:
- """Check if configuration contains dangerous commands"""
- if tool_name == "execute_multiple_device_config_commands":
- device_configs = tool_args.get("device_configs", [])
- for device in device_configs:
- commands = device.get("config_commands", [])
- for cmd in commands:
- cmd_lower = cmd.lower()
- if any(pattern in cmd_lower for pattern in DANGEROUS_PATTERNS):
- return True
- return False
-
-
-def check_hitl_requirement(state: MessagesState) -> dict:
- """
- Check if tool calls require user confirmation (HITL)
-
- Returns:
- dict: State update containing pending_tool_calls and hitl_confirmation_required
- """
- last_message = state["messages"][-1]
-
- # If last message has no tool calls, return directly
- if not hasattr(last_message, 'tool_calls') or not last_message.tool_calls:
- return {"hitl_confirmation_required": False}
-
- pending_tools = []
-
- for tool_call in last_message.tool_calls:
- tool_name = tool_call["name"]
- tool_args = tool_call["args"]
-
- # Only process tools that need HITL
- if tool_name in HITL_TOOLS:
- # Check if it's a dangerous operation
- is_dangerous = _is_dangerous_config(tool_name, tool_args)
-
- tool_info = {
- "tool_call_id": tool_call["id"],
- "tool_name": tool_name,
- "tool_args": tool_args,
- "danger_level": "high" if is_dangerous else "medium"
- }
-
- # Add description information
- if tool_name == "execute_multiple_device_config_commands":
- device_count = len(tool_args.get("device_configs", []))
- tool_info["description"] = f"Configure {device_count} device(s)"
-
- pending_tools.append(tool_info)
- logger.info("HITL: Tool '%s' requires confirmation (danger_level=%s)",
- tool_name, tool_info["danger_level"])
-
- if pending_tools:
- logger.info("HITL: %d tools require confirmation", len(pending_tools))
- return {
- "pending_tool_calls": pending_tools,
- "hitl_confirmation_required": True,
- "hitl_session_id": str(uuid4())
- }
-
- return {"hitl_confirmation_required": False}
-```
-
-##### Change 1.3: Modify should_continue Route
-
-**Location**: Lines 337-370
-
-```python
-def should_continue(
- state: MessagesState,
-) -> Literal["conditional_tool_execution", "hitl_confirmation", "title_generator_node", END]:
- """
- Routing decision after LLM response
-
- Returns:
- Literal: Route to next node
- """
- last_message = state["messages"][-1]
- current_title = state.get("conversation_title")
-
- # LLM requests tool call
- if last_message.tool_calls:
- # Check if HITL confirmation is needed
- if state.get("hitl_confirmation_required"):
- logger.info("Routing to hitl_confirmation node")
- return "hitl_confirmation"
-
- logger.info("Routing to conditional_tool_execution node")
- return "conditional_tool_execution"
-
- # First interaction completed, generate title
- if current_title in [None, "New Conversation"]:
- return "title_generator_node"
-
- return END
-```
-
-**Key Changes**:
-- Original route `"tool_node"` changed to `"conditional_tool_execution"`
-- New `"hitl_confirmation"` route added
-- Route name changed, all references need to be updated synchronously
-
-##### Change 1.4: Add HITL Confirmation Wait Node
-
-**Location**: Add after `should_continue` function
-
-```python
-def hitl_confirmation_node(state: MessagesState) -> dict:
- """
- HITL confirmation node - Pause execution, wait for user confirmation
-
- This is a special node that performs no operations, only maintains state.
- Actual confirmation flow is handled by the API layer.
-
- Working principle:
- 1. When node is called, state contains pending_tool_calls
- 2. LangGraph saves state to checkpoint
- 3. Execution flow pauses, waits for external state update
- 4. After user confirms via API, state is updated
- 5. Resume execution from checkpoint
-
- Returns:
- dict: Empty dictionary, maintains state unchanged
- """
- logger.info("HITL: Pausing for user confirmation (session_id=%s)",
- state.get("hitl_session_id"))
-
- # Return empty dictionary, keep state unchanged
- # State will be updated via API after user confirmation
- return {}
-```
-
-##### Change 1.5: Add Conditional Execution Node
-
-**Location**: Add after `hitl_confirmation_node` function
-
-```python
-def conditional_tool_execution(state: MessagesState) -> dict:
- """
- Conditional tool execution node - Decide whether to execute tools based on user confirmation
-
- This node replaces the original tool_node, adding:
- 1. Only execute user-confirmed tools
- 2. Handle user-rejected tools
- 3. Update HITL state
-
- Returns:
- dict: Contains execution results and state updates
- """
- confirmed_calls = state.get("confirmed_tool_calls", [])
- rejected_calls = state.get("rejected_tool_calls", [])
-
- logger.info("HITL: Executing %d confirmed tools, %d rejected",
- len(confirmed_calls), len(rejected_calls))
-
- # Handle user-rejected tools
- if rejected_calls:
- rejected_names = [c["tool_name"] for c in rejected_calls]
- rejection_msg = (
- f"User rejected the following operations: {', '.join(rejected_names)}.\n"
- f"Please provide an alternative solution or explain why these operations should not be performed."
- )
-
- # Add system message to notify LLM
- return {
- "messages": [SystemMessage(content=rejection_msg)],
- "pending_tool_calls": [],
- "hitl_confirmation_required": False,
- "confirmed_tool_calls": [],
- "rejected_tool_calls": []
- }
-
- # Execute confirmed tools
- if not confirmed_calls:
- logger.warning("HITL: No confirmed tools to execute")
- return {
- "pending_tool_calls": [],
- "hitl_confirmation_required": False
- }
-
- results = []
- for tool_call_dict in confirmed_calls:
- tool_call_id = tool_call_dict["tool_call_id"]
- tool_name = tool_call_dict["tool_name"]
- tool_args = tool_call_dict["tool_args"]
-
- logger.info("Executing confirmed tool: %s", tool_name)
-
- tool = tools_by_name[tool_name]
- try:
- observation = tool.invoke(tool_args)
- results.append(ToolMessage(
- content=observation,
- tool_call_id=tool_call_id,
- name=tool_name
- ))
- logger.debug("Tool %s completed successfully", tool_name)
- except Exception as e:
- logger.error("Tool %s failed: %s", tool_name, e, exc_info=True)
- results.append(ToolMessage(
- content=f"Error: {str(e)}",
- tool_call_id=tool_call_id,
- name=tool_name
- ))
-
- # Clean up HITL state
- return {
- "messages": results,
- "pending_tool_calls": [],
- "hitl_confirmation_required": False,
- "confirmed_tool_calls": [],
- "rejected_tool_calls": []
- }
-```
-
-**Key Changes**:
-- Replaces original `tool_node` function
-- Adds confirmation logic handling
-- Supports partial confirmation, partial rejection
-
-##### Change 1.6: Update LangGraph Build Process
-
-**Location**: Lines 393-427
-
-```python
-# Build workflow
-agent_builder = StateGraph(MessagesState)
-
-# Add nodes
-agent_builder.add_node("llm_call", llm_call)
-agent_builder.add_node("hitl_confirmation", hitl_confirmation_node)
-agent_builder.add_node("conditional_tool_execution", conditional_tool_execution)
-agent_builder.add_node("title_generator_node", generate_title)
-
-# Add edges: START → llm_call
-agent_builder.add_edge(START, "llm_call")
-
-# Add edges: conditional routing after LLM
-agent_builder.add_conditional_edges(
- "llm_call",
- should_continue,
- {
- "hitl_confirmation": "hitl_confirmation", # Needs HITL confirmation
- "conditional_tool_execution": "conditional_tool_execution", # Direct execution
- "title_generator_node": "title_generator_node", # Generate title
- END: END # End conversation
- },
-)
-
-# HITL confirmation node is special, needs to wait for external state update
-# State is updated after checkpoint saved, next round of invocation resumes from checkpoint
-# This cycle is completed by API triggering new graph.ainvoke() call
-
-# Add edges: continue LLM call after conditional execution
-agent_builder.add_conditional_edges(
- "conditional_tool_execution",
- recursion_limit_continue,
- {
- "llm_call": "llm_call",
- END: END
- },
-)
-
-# Add edges: end after title generation
-agent_builder.add_edge("title_generator_node", END)
-```
-
-**Workflow Diagram**:
-```
-START → llm_call → should_continue
- ↓
- ┌────────────┼────────────┐
- ↓ ↓ ↓
-hitl_confirmation conditional title_generator
-(wait for API) _execution ↓
- └────────────┴────────────→ END
-```
-
-#### File: `gns3server/agent/gns3_copilot/agent_service.py`
-
-##### Change 2.1: Add HITL Event Handling
-
-**Location**: `_convert_event_to_chunk` function at lines 333-376
-
-```python
-def _convert_event_to_chunk(self, event: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
- """
- Convert LangGraph events to API response chunks
-
- Supports HITL event types
- """
- event_type = event.get("event", "")
- data = event.get("data", {})
-
- if event_type == "on_chat_model_stream":
- chunk = data.get("chunk", {})
- content = getattr(chunk, "content", "")
- if content:
- return {"type": "content", "content": content}
-
- elif event_type == "on_tool_start":
- return {
- "type": "tool_start",
- "tool_name": event.get("name", ""),
- "session_id": session_id
- }
-
- elif event_type == "on_tool_end":
- output = data.get("output", "")
- if not isinstance(output, str):
- output = str(output)
- return {
- "type": "tool_end",
- "tool_name": event.get("name", ""),
- "tool_output": output,
- "session_id": session_id
- }
-
- # New: HITL confirmation required event
- elif event_type == "hitl_required":
- return {
- "type": "hitl_required",
- "pending_tools": data.get("pending_tool_calls", []),
- "hitl_session_id": data.get("hitl_session_id"),
- "timeout": 300, # 5 minute timeout
- "session_id": session_id
- }
-
- return None
-```
-
-**Note**: In actual implementation, HITL events are not triggered via LangGraph's `astream_events`, but achieved through state queries. Therefore, this function is mainly used to handle tool execution events.
-
----
-
-### 2. API Layer
-
-#### File: `gns3server/api/routes/controller/chat.py`
-
-##### Change 2.1: Add HITL Endpoints
-
-**Location**: Add at end of file (after line 325)
-
-```python
-from gns3server import schemas
-from typing import List
-
-
-# =============================================================================
-# HITL (Human-in-the-Loop) Endpoints
-# =============================================================================
-
-@router.get(
- "/sessions/{session_id}/hitl-status",
- response_model=schemas.HITLStatusResponse,
- summary="Get HITL status",
- description="Get list of pending tools in current session"
-)
-async def get_hitl_status(
- session_id: str,
- project: Project = Depends(dep_project),
- current_user: schemas.User = Depends(get_current_active_user),
-) -> schemas.HITLStatusResponse:
- """
- Get HITL status
-
- Returns the list of tool calls waiting for user confirmation in the current session.
- Frontend should poll this endpoint periodically to check for new pending tools.
- """
- if project.status != "opened":
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"Project must be opened. Current status: {project.status}"
- )
-
- agent_manager = await get_project_agent_manager()
- agent_service = await agent_manager.get_agent(str(project.id), project.path)
-
- # Get state from checkpoint
- config = {"configurable": {"thread_id": session_id}}
- state = await agent_service._graph.aget_state(config)
-
- if not state or not state.values:
- return schemas.HITLStatusResponse(
- status="idle",
- pending_tools=[],
- hitl_session_id=None,
- session_id=session_id
- )
-
- values = state.values
- pending_tools = values.get("pending_tool_calls", [])
- hitl_session_id = values.get("hitl_session_id")
-
- # Convert to Schema format
- pending_tool_schemas = []
- for tool in pending_tools:
- pending_tool_schemas.append(schemas.PendingTool(
- tool_call_id=tool["tool_call_id"],
- tool_name=tool["tool_name"],
- tool_args=tool["tool_args"],
- danger_level=tool.get("danger_level", "medium"),
- description=tool.get("description", "")
- ))
-
- return schemas.HITLStatusResponse(
- status="waiting" if pending_tool_schemas else "idle",
- pending_tools=pending_tool_schemas,
- hitl_session_id=hitl_session_id,
- session_id=session_id
- )
-
-
-@router.post(
- "/sessions/{session_id}/hitl/confirm",
- response_model=schemas.HITLConfirmationResponse,
- summary="Confirm tool execution",
- description="User confirms to execute one or more pending tools"
-)
-async def confirm_tool_execution(
- session_id: str,
- request: schemas.HITLConfirmationRequest,
- project: Project = Depends(dep_project),
- current_user: schemas.User = Depends(get_current_active_user),
-) -> schemas.HITLConfirmationResponse:
- """
- Confirm tool execution
-
- User can choose:
- - confirm_all=true: Confirm all pending tools
- - tool_call_ids=[...]: Confirm specified tools
-
- After confirmation, tools will be executed and results returned via SSE stream.
- """
- if project.status != "opened":
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"Project must be opened. Current status: {project.status}"
- )
-
- agent_manager = await get_project_agent_manager()
- agent_service = await agent_manager.get_agent(str(project.id), project.path)
-
- config = {"configurable": {"thread_id": session_id}}
- state = await agent_service._graph.aget_state(config)
-
- if not state or not state.values:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Session '{session_id}' not found"
- )
-
- values = state.values
- pending_tools = values.get("pending_tool_calls", [])
-
- if not pending_tools:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="No pending tools to confirm"
- )
-
- # Select tools to confirm based on request
- confirmed_tools = []
- if request.confirm_all:
- confirmed_tools = pending_tools
- elif request.tool_call_ids:
- confirmed_tools = [t for t in pending_tools if t["tool_call_id"] in request.tool_call_ids]
- else:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Must specify either confirm_all=true or tool_call_ids"
- )
-
- if not confirmed_tools:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="No tools matched the confirmation criteria"
- )
-
- # Update state: mark as confirmed
- await agent_service._graph.aupdate_state(
- config,
- {
- "confirmed_tool_calls": confirmed_tools,
- "pending_tool_calls": [],
- "hitl_confirmation_required": False
- }
- )
-
- # Continue execution flow
- try:
- new_state = await agent_service._graph.ainvoke(None, config)
- except Exception as e:
- logger.error("Error continuing graph execution: %s", e, exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error executing tools: {str(e)}"
- )
-
- return schemas.HITLConfirmationResponse(
- status="confirmed",
- confirmed_count=len(confirmed_tools),
- message=f"Confirmed {len(confirmed_tools)} tool(s) for execution"
- )
-
-
-@router.post(
- "/sessions/{session_id}/hitl/reject",
- response_model=schemas.HITLConfirmationResponse,
- summary="Reject tool execution",
- description="User rejects to execute one or more pending tools"
-)
-async def reject_tool_execution(
- session_id: str,
- request: schemas.HITLRejectionRequest,
- project: Project = Depends(dep_project),
- current_user: schemas.User = Depends(get_current_active_user),
-) -> schemas.HITLConfirmationResponse:
- """
- Reject tool execution
-
- User can choose:
- - reject_all=true: Reject all pending tools
- - tool_call_ids=[...]: Reject specified tools
- - reason: Rejection reason (will be fed back to LLM)
-
- After rejection, LLM will be notified and can provide alternative solution.
- """
- if project.status != "opened":
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"Project must be opened. Current status: {project.status}"
- )
-
- agent_manager = await get_project_agent_manager()
- agent_service = await agent_manager.get_agent(str(project.id), project.path)
-
- config = {"configurable": {"thread_id": session_id}}
-
- rejected_tools = []
- if request.reject_all:
- state = await agent_service._graph.aget_state(config)
- if state and state.values:
- rejected_tools = state.values.get("pending_tool_calls", [])
- elif request.tool_call_ids:
- state = await agent_service._graph.aget_state(config)
- if state and state.values:
- pending = state.values.get("pending_tool_calls", [])
- rejected_tools = [t for t in pending if t["tool_call_id"] in request.tool_call_ids]
-
- if not rejected_tools:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="No pending tools to reject"
- )
-
- # Update state: mark as rejected
- await agent_service._graph.aupdate_state(
- config,
- {
- "rejected_tool_calls": rejected_tools,
- "pending_tool_calls": [],
- "hitl_confirmation_required": False
- }
- )
-
- # Continue execution flow, LLM will receive rejection notification
- try:
- new_state = await agent_service._graph.ainvoke(None, config)
- except Exception as e:
- logger.error("Error continuing graph execution: %s", e, exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Error processing rejection: {str(e)}"
- )
-
- rejected_names = [t["tool_name"] for t in rejected_tools]
- return schemas.HITLConfirmationResponse(
- status="rejected",
- confirmed_count=0,
- message=f"Rejected {len(rejected_tools)} tool(s): {', '.join(rejected_names)}"
- )
-```
-
----
-
-### 3. Schema Layer
-
-#### File: `gns3server/schemas/controller/chat.py`
-
-##### Change 3.1: Add HITL-related Pydantic Models
-
-**Location**: Add at end of file (after line 112)
-
-```python
-class PendingTool(BaseModel):
- """Information about pending tool"""
- tool_call_id: str = Field(..., description="Unique ID of the tool call")
- tool_name: str = Field(..., description="Tool name")
- tool_args: Dict[str, Any] = Field(..., description="Tool parameters")
- danger_level: Literal["low", "medium", "high"] = Field(
- default="medium",
- description="Danger level"
- )
- description: Optional[str] = Field(None, description="Tool execution description")
-
-
-class HITLStatusResponse(BaseModel):
- """HITL status response"""
- status: Literal["idle", "waiting", "confirmed", "rejected"] = Field(
- ...,
- description="Current status"
- )
- pending_tools: List[PendingTool] = Field(
- default_factory=list,
- description="List of pending tools"
- )
- hitl_session_id: Optional[str] = Field(
- None,
- description="HITL session ID"
- )
- session_id: str = Field(..., description="Chat session ID")
-
-
-class HITLConfirmationRequest(BaseModel):
- """HITL confirmation request"""
- confirm_all: bool = Field(
- default=False,
- description="Whether to confirm all pending tools"
- )
- tool_call_ids: Optional[List[str]] = Field(
- None,
- description="List of tool call IDs to confirm"
- )
-
-
-class HITLRejectionRequest(BaseModel):
- """HITL rejection request"""
- reject_all: bool = Field(
- default=False,
- description="Whether to reject all pending tools"
- )
- tool_call_ids: Optional[List[str]] = Field(
- None,
- description="List of tool call IDs to reject"
- )
- reason: Optional[str] = Field(
- None,
- description="Rejection reason (will be fed back to LLM)"
- )
-
-
-class HITLConfirmationResponse(BaseModel):
- """HITL confirmation response"""
- status: Literal["confirmed", "rejected"] = Field(
- ...,
- description="Operation status"
- )
- confirmed_count: int = Field(
- ...,
- description="Number of confirmed tools"
- )
- message: str = Field(..., description="Response message")
-```
-
-**Also update `__init__.py` exports**:
-
-```python
-# File: gns3server/schemas/__init__.py
-
-# Add to import list
-from .controller.chat import (
- # ... existing imports ...
- PendingTool,
- HITLStatusResponse,
- HITLConfirmationRequest,
- HITLRejectionRequest,
- HITLConfirmationResponse,
-)
-```
-
----
-
-### 4. File Changes Summary
-
-| File Path | Change Type | Line Changes | Risk Level |
-|-----------|-------------|--------------|------------|
-| `gns3_copilot.py` | Modify/Add | +200 lines | 🟡 Medium |
-| `agent_service.py` | Modify | +10 lines | 🟢 Low |
-| `chat.py` (API) | Add | +150 lines | 🟢 Low |
-| `chat.py` (Schema) | Add | +50 lines | 🟢 Low |
-| **Total** | - | **+410 lines** | - |
-
----
-
-## Database Changes
-
-### Checkpoint Table Structure
-
-**Table Name**: `checkpoints` (managed by LangGraph)
-
-**New Fields** (automatically added via MessagesState extension):
-
-| Field Name | Type | Description |
-|------------|------|-------------|
-| `pending_tool_calls` | TEXT (JSON) | List of pending tool calls |
-| `hitl_confirmation_required` | BOOLEAN | Whether HITL confirmation is needed |
-| `hitl_session_id` | TEXT | HITL session ID |
-| `confirmed_tool_calls` | TEXT (JSON) | Confirmed tool calls |
-| `rejected_tool_calls` | TEXT (JSON) | Rejected tool calls |
-
-**Migration Notes**:
-- LangGraph automatically handles new state fields
-- No manual database migration required
-- Existing checkpoints are backward compatible
-
----
-
-## API Specification
-
-### 1. Get HITL Status
-
-**Endpoint**: `GET /v3/projects/{project_id}/chat/sessions/{session_id}/hitl-status`
-
-**Response Example**:
-```json
-{
- "status": "waiting",
- "pending_tools": [
- {
- "tool_call_id": "call_abc123",
- "tool_name": "execute_multiple_device_config_commands",
- "tool_args": {
- "project_id": "xxx",
- "device_configs": [
- {
- "device_name": "R1",
- "config_commands": ["interface gig0/0", "ip address 10.0.0.1/24"]
- }
- ]
- },
- "danger_level": "medium",
- "description": "Configure 1 device"
- }
- ],
- "hitl_session_id": "hitl_12345",
- "session_id": "chat_session_id"
-}
-```
-
-### 2. Confirm Execution
-
-**Endpoint**: `POST /v3/projects/{project_id}/chat/sessions/{session_id}/hitl/confirm`
-
-**Request Body**:
-```json
-{
- "confirm_all": true,
- "tool_call_ids": null
-}
-```
-
-**Or specify tools**:
-```json
-{
- "confirm_all": false,
- "tool_call_ids": ["call_abc123", "call_def456"]
-}
-```
-
-**Response Example**:
-```json
-{
- "status": "confirmed",
- "confirmed_count": 2,
- "message": "Confirmed 2 tool(s) for execution"
-}
-```
-
-### 3. Reject Execution
-
-**Endpoint**: `POST /v3/projects/{project_id}/chat/sessions/{session_id}/hitl/reject`
-
-**Request Body**:
-```json
-{
- "reject_all": true,
- "reason": "Configuration has errors, needs re-planning"
-}
-```
-
-**Response Example**:
-```json
-{
- "status": "rejected",
- "confirmed_count": 0,
- "message": "Rejected 1 tool(s): execute_multiple_device_config_commands"
-}
-```
-
----
-
-## Frontend Integration Guide
-
-### 1. Detect HITL Status
-
-```javascript
-// Periodically poll HITL status
-async function pollHITLStatus(sessionId) {
- const response = await fetch(
- `/api/v3/projects/${projectId}/chat/sessions/${sessionId}/hitl-status`
- );
- const status = await response.json();
-
- if (status.status === 'waiting' && status.pending_tools.length > 0) {
- showConfirmationDialog(status);
- }
-}
-
-// Poll every 2 seconds
-setInterval(() => pollHITLStatus(sessionId), 2000);
-```
-
-### 2. Show Confirmation Dialog
-
-```javascript
-function showConfirmationDialog(hitlStatus) {
- const { pending_tools, hitl_session_id } = hitlStatus;
-
- const dialog = document.createElement('div');
- dialog.className = 'hitl-confirmation-dialog';
-
- let html = `
-
⚠️ Please Confirm the Following Operations
-
-
-
-
-
- `;
-
- dialog.innerHTML = html;
- document.body.appendChild(dialog);
-}
-
-async function confirmAll(sessionId) {
- const response = await fetch(
- `/api/v3/projects/${projectId}/chat/sessions/${sessionId}/hitl/confirm`,
- {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ confirm_all: true })
- }
- );
-
- if (response.ok) {
- closeDialog();
- // Continue listening to SSE stream to get execution results
- }
-}
-
-async function rejectAll(sessionId) {
- const response = await fetch(
- `/api/v3/projects/${projectId}/chat/sessions/${sessionId}/hitl/reject`,
- {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ reject_all: true, reason: 'User cancelled operation' })
- }
- );
-
- if (response.ok) {
- closeDialog();
- }
-}
-```
-
----
-
-## Test Plan
-
-### Unit Tests
-
-```python
-import pytest
-from gns3server.agent.gns3_copilot.agent.gns3_copilot import (
- check_hitl_requirement,
- _is_dangerous_config
-)
-
-def test_dangerous_config_detection():
- """Test dangerous command detection"""
- tool_args = {
- "device_configs": [{
- "config_commands": ["reload", "write erase"]
- }]
- }
-
- assert _is_dangerous_config("execute_multiple_device_config_commands", tool_args) == True
-
-def test_safe_config_detection():
- """Test safe command detection"""
- tool_args = {
- "device_configs": [{
- "config_commands": ["interface gig0/0", "ip address 10.0.0.1/24"]
- }]
- }
-
- assert _is_dangerous_config("execute_multiple_device_config_commands", tool_args) == False
-
-def test_hitl_requirement_check():
- """Test HITL requirement check"""
- state = {
- "messages": [
- HumanMessage(content="Configure router"),
- AIMessage(
- content="",
- tool_calls=[{
- "id": "call_123",
- "name": "execute_multiple_device_config_commands",
- "args": {"project_id": "test"}
- }]
- )
- ]
- }
-
- result = check_hitl_requirement(state)
-
- assert result["hitl_confirmation_required"] == True
- assert len(result["pending_tool_calls"]) == 1
-```
-
-### Integration Tests
-
-```python
-import pytest
-from fastapi.testclient import TestClient
-
-def test_hitl_flow():
- """Test complete HITL flow"""
- client = TestClient(app)
-
- # 1. Start chat
- response = client.post(
- f"/v3/projects/{project_id}/chat/stream",
- json={"message": "Configure all routers"}
- )
-
- # 2. Check HITL status
- status = client.get(f"/v3/projects/{project_id}/chat/sessions/{session_id}/hitl-status")
- assert status.json()["status"] == "waiting"
- assert len(status.json()["pending_tools"]) > 0
-
- # 3. Confirm execution
- confirm = client.post(
- f"/v3/projects/{project_id}/chat/sessions/{session_id}/hitl/confirm",
- json={"confirm_all": True}
- )
- assert confirm.json()["status"] == "confirmed"
-```
-
----
-
-## Deployment Steps
-
-### Phase 1: Basic Infrastructure (1-2 days)
-
-1. ✅ Extend MessagesState
-2. ✅ Implement check_hitl_requirement node
-3. ✅ Implement hitl_confirmation_node and conditional_tool_execution
-4. ✅ Update LangGraph workflow
-5. ✅ Unit tests
-
-**Verification**: Existing functionality unaffected
-
-### Phase 2: API Endpoints (1 day)
-
-1. ✅ Add HITL status query endpoint
-2. ✅ Add confirm/reject endpoints
-3. ✅ Add Schema definitions
-4. ✅ API tests
-
-**Verification**: API can be called normally
-
-### Phase 3: Frontend Integration (2-3 days)
-
-1. ✅ Implement polling logic
-2. ✅ Show confirmation dialog
-3. ✅ Handle confirm/reject operations
-4. ✅ Display execution results
-
-**Verification**: End-to-end flow available
-
-### Phase 4: Optimization and Enhancement (1-2 days)
-
-1. ✅ Add dangerous command classification
-2. ✅ Implement timeout handling
-3. ✅ Add operation logging
-4. ✅ Performance optimization
-
-**Verification**: Production ready
-
----
-
-## Parameter Modification Feature (Extension)
-
-### Feature Overview
-
-In addition to "Confirm/Reject", HITL also supports users modifying command parameters about to be executed, then feeding the modifications back to the LLM, which understands and executes with the new parameters.
-
-### Complete Flow
-
-```
-LLM generates command A
- ↓
- HITL confirmation pause
- ↓
- Display to user
- ↓
-┌─────────┼─────────┐
-│ │ │
-Direct Confirm Reject Modify to B
-│ │ │
-Execute A Feedback LLM Feedback (A→B) to LLM
- ↓
- LLM understands modification
- ↓
- Generate new tool call
- ↓
- Execute B
-```
-
-### Conversation Example
-
-```
-User: Configure R1's gig0/0 interface to 10.0.0.1/24
-
-LLM: I will configure R1's interface for you.
-
-HITL: ⚠️ Please confirm the following operations
- Tool: execute_multiple_device_config_commands
- Parameters: {
- "device_configs": [{
- "device_name": "R1",
- "config_commands": [
- "interface gig0/0",
- "ip address 10.0.0.1 255.255.255.0"
- ]
- }]
- }
-
-User: [Modify parameters]
- ip address 10.0.0.1 255.255.255.0
- → ip address 192.168.1.1 255.255.255.0
- [Save and Execute]
-
-System feedback to LLM:
- User modified the command parameters about to be executed:
- Tool: execute_multiple_device_config_commands
- R1:
- Original commands: ['interface gig0/0', 'ip address 10.0.0.1 255.255.255.0']
- Modified to: ['interface gig0/0', 'ip address 192.168.1.1 255.255.255.0']
- Please execute according to the modified parameters.
-
-LLM: Understood, I will use the modified IP address 192.168.1.1/24 to configure R1's gig0/0 interface.
-
-[Tool execution execute_multiple_device_config_commands with modified args]
-
-LLM: Configuration completed, R1's gig0/0 interface configured to 192.168.1.1/24.
-```
-
-### Implementation Points
-
-#### 1. State Extension
-
-Add to `MessagesState`:
-
-```python
-# User-modified fields
-user_modified_args: dict | None # Structure:
-# {
-# "tool_call_id": str,
-# "original_args": dict,
-# "modified_args": dict
-# }
-```
-
-#### 2. API Extension
-
-New endpoint: `POST /v3/projects/{project_id}/chat/sessions/{session_id}/hitl/modify`
-
-**Request Body**:
-```python
-{
- "tool_call_id": "call_abc123",
- "modified_args": {
- # Modified complete parameters
- }
-}
-```
-
-**Response**:
-```python
-{
- "status": "modified",
- "modification_summary": "Show parameter differences",
- "message": "Feedback sent to AI"
-}
-```
-
-#### 3. Backend Processing Flow
-
-1. Receive modified parameters
-2. Generate parameter difference summary
-3. Add HumanMessage to conversation, explaining user's modification
-4. Clear `pending_tool_calls` and `hitl_confirmation_required`
-5. Set `user_modified_args` (used to trigger LLM regeneration)
-6. Continue execution, LLM sees user's modification and generates new tool call
-7. Execute new tool call
-
-#### 4. Frontend Implementation Points
-
-**UI Components**:
-- Display original parameters and editing area
-- Provide parameter difference highlighting (original vs new values)
-- Support JSON format validation
-- Save modification and execute button
-
-**Interaction Flow**:
-1. User clicks "Modify Parameters" button
-2. Expand parameter editing area, display original JSON
-3. User edits JSON in text box
-4. Real-time JSON format validation
-5. Click "Save and Execute" to submit modification
-6. System shows modification summary and continues execution
-
-**User Experience**:
-- For configuration tools, can provide more friendly command-line interface instead of pure JSON
-- Highlight modified parts (red strikethrough, green addition)
-- Provide parameter preset templates
-- Show before/after comparison view
-
-### Key Code Locations
-
-**File**: `gns3_copilot.py`
-
-Enhance `conditional_tool_execution` function to detect `user_modified_args`:
-
-```python
-def conditional_tool_execution(state: MessagesState) -> dict:
- """Conditional tool execution node (supports user modification)"""
-
- # Handle user modification case
- if state.get("user_modified_args"):
- # LLM already received user modification via HumanMessage
- # Clear marker, let LLM regenerate tool call
- return {
- "user_modified_args": None,
- "pending_tool_calls": [],
- "hitl_confirmation_required": False
- }
-
- # ... other processing logic
-```
-
-**Parameter Difference Generation**:
-
-```python
-def _generate_modification_summary(tool_name: str, original: dict, modified: dict) -> str:
- """Generate parameter modification summary"""
-
- if tool_name == "execute_multiple_device_config_commands":
- # Special handling for configuration tools, compare command by command
- orig_devices = original.get("device_configs", [])
- mod_devices = modified.get("device_configs", [])
-
- summary = []
- for orig, mod in zip(orig_devices, mod_devices):
- device = orig.get("device_name", "")
- orig_cmds = orig.get("config_commands", [])
- mod_cmds = mod.get("config_commands", [])
-
- if orig_cmds != mod_cmds:
- summary.append(f"\n{device}:")
- for oc, mc in zip(orig_cmds, mod_cmds):
- if oc != mc:
- summary.append(f" - {oc}")
- summary.append(f" + {mc}")
-
- return "\n".join(summary) if summary else "No modifications"
-
- # Other tools' generic handling
- # ...
-```
-
-### Security Considerations
-
-#### Parameter Validation
-
-- Validate modified parameter structure is complete
-- Check required fields exist
-- Validate parameter values are within legal range
-
-#### Dangerous Command Secondary Confirmation
-
-Even after modification, certain commands still require secondary confirmation:
-- `erase startup-config`
-- `reload`
-- `format flash:`
-
-### Test Cases
-
-**Scenario**: User modifies configuration command
-
-1. LLM generates configuration command: `ip address 10.0.0.1 255.255.255.0`
-2. User modifies to: `ip address 192.168.1.1 255.255.255.0`
-3. System shows modification summary
-4. LLM understands and confirms using new IP
-5. Execute tool with modified parameters
-6. Verify configuration result
-
----
-
-## Risk Assessment
-
-| Risk | Probability | Impact | Mitigation Measures |
-|------|-------------|--------|---------------------|
-| Breaking existing functionality | Low | High | Complete regression testing |
-| State inconsistency | Medium | Medium | Checkpoint validation |
-| Performance impact | Low | Low | Asynchronous processing |
-| Frontend integration issues | Medium | Medium | Detailed frontend documentation |
-
----
-
-## Rollback Plan
-
-If rollback is needed:
-
-1. Remove HITL-related nodes
-2. Restore original `should_continue` and `tool_node`
-3. Delete new API endpoints
-4. New fields in checkpoint are automatically ignored
-
-**Rollback Time**: Approximately 30 minutes
-
----
-
-## Future Enhancements
-
-1. **Batch operation optimization**: Support selective confirmation of some tools
-2. **Operation history**: Record all HITL operations
-3. **Automatic approval**: Set automatic approval rules for low-risk operations
-4. **Multi-user collaboration**: Support multi-person approval process
-5. **Template management**: Save commonly used configurations as templates
-
----
-
-## Reference Documentation
-
-- [LangGraph Interrupts](https://langchain-ai.github.io/langgraph/concepts/low_level/#interruption)
-- [GNS3 Copilot Architecture](./ai-chat-api-design.md)
-- [Tool Response Format Standard](./tool-response-format-standard.md)
-
----
-
-**Document Version**: v1.0
-**Created Date**: 2026-03-04
-**Author**: GNS3 Development Team
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/jinja2-config-templates-system.md b/docs/gns3-copilot/todo/jinja2-config-templates-system.md
deleted file mode 100644
index af7c766e6..000000000
--- a/docs/gns3-copilot/todo/jinja2-config-templates-system.md
+++ /dev/null
@@ -1,1112 +0,0 @@
-# Jinja2-Based Configuration Template System for GNS3 AI Copilot
-
-## Overview
-
-This document outlines a configuration template system using Jinja2 that allows the AI to generate structured data (JSON) instead of full configuration files. The templates handle the rendering of vendor-specific configurations.
-
-## Architecture
-
-```
-User Natural Language
- │
- ▼
- ┌─────────────┐
- │ AI Agent │
- │ (LLM) │
- └──────┬──────┘
- │
- ▼
- Structured Data (JSON)
- │
- ▼
- ┌──────────────────────────────────┐
- │ Jinja2 Configuration Renderer │
- │ ┌────────────────────────────┐ │
- │ │ Template Library │ │
- │ │ ├── cisco/ │ │
- │ │ │ ├── ospf.j2 │ │
- │ │ │ ├── bgp.j2 │ │
- │ │ │ ├── interface.j2 │ │
- │ │ │ └── ... │ │
- │ │ ├── juniper/ │ │
- │ │ ├── huawei/ │ │
- │ │ └── linux/ │ │
- │ └────────────────────────────┘ │
- └──────────────────┬───────────────┘
- │
- ▼
- Full Configuration File
- │
- ▼
- Push to Device
-```
-
-## Directory Structure
-
-```
-gns3server/agent/gns3_copilot/
-├── config_templates/
-│ ├── README.md # This file
-│ ├── base/ # Base/common templates
-│ │ ├── interface_common.j2 # Common interface config
-│ │ ├── routing_common.j2 # Common routing config
-│ │ └── security_common.j2 # Common security config
-│ ├── cisco/ # Cisco device templates
-│ │ ├── ios/ # IOS/IOS-XE
-│ │ │ ├── ospf.j2
-│ │ │ ├── bgp.j2
-│ │ │ ├── interface.j2
-│ │ │ ├── acl.j2
-│ │ │ ├── nat.j2
-│ │ │ ├── vlan.j2
-│ │ │ └── multiservice.j2
-│ │ ├── nexus/ # Nexus switches
-│ │ │ ├── ospf.j2
-│ │ │ ├── bgp.j2
-│ │ │ └── interface.j2
-│ │ └── asa/ # ASA firewall
-│ │ ├── nat.j2
-│ │ └── access_list.j2
-│ ├── juniper/ # Juniper devices
-│ │ ├── junos/
-│ │ │ ├── ospf.j2
-│ │ │ ├── bgp.j2
-│ │ │ └── interface.j2
-│ │ └── srx/ # SRX firewall
-│ ├── huawei/ # Huawei devices
-│ │ └── vrp/
-│ ├── linux/ # Linux servers
-│ │ ├── network.j2
-│ │ └── firewall.j2
-│ └── schemas/ # JSON Schema definitions
-│ ├── cisco_ospf.json
-│ ├── cisco_bgp.json
-│ ├── cisco_interface.json
-│ └── common.json
-├── config_renderer.py # Template rendering engine
-└── config_validator.py # Schema validation
-```
-
-## Template Examples
-
-### 1. OSPF Configuration Template (Cisco IOS)
-
-**File**: `config_templates/cisco/ios/ospf.j2`
-
-```jinja2
-{# OSPF Configuration Template for Cisco IOS #}
-{% if ospf.enabled %}
-router ospf {{ ospf.process_id }}
-{% if ospf.router_id %}
- router-id {{ ospf.router_id }}
-{% endif %}
-{% for network in ospf.networks %}
- network {{ network.address }} mask {{ network.wildcard }} area {{ network.area }}
-{% endfor %}
-{% if ospf.passive_interfaces %}
-{% for interface in ospf.passive_interfaces %}
- passive-interface {{ interface }}
-{% endfor %}
-{% endif %}
-{% if ospf.auto_cost_reference %}
- auto-cost reference-bandwidth {{ ospf.auto_cost_reference }}
-{% endif %}
-{% if ospf.default_information_originate %}
- default-information originate{{ ' metric' if ospf.default_metric else '' }}{{ ospf.default_metric if ospf.default_metric else '' }}
-{% endif %}
-!
-{% endif %}
-
-{# OSPF Interface Configuration #}
-{% for iface in ospf.interfaces %}
-interface {{ iface.name }}
-{% if iface.cost %}
- ip ospf cost {{ iface.cost }}
-{% endif %}
-{% if iface.hello_interval %}
- ip ospf hello-interval {{ iface.hello_interval }}
-{% endif %}
-{% if iface.dead_interval %}
- ip ospf dead-interval {{ iface.dead_interval }}
-{% endif %}
-{% if iface.authentication %}
- ip ospf authentication{{ ' message-digest' if iface.auth_type == 'message-digest' else '' }}
-{% if iface.auth_key %}
- ip ospf authentication-key {{ iface.auth_key }}
-{% endif %}
-{% if iface.auth_md5_keys %}
-{% for key in iface.auth_md5_keys %}
- ip ospf message-digest-key {{ key.id }} md5 {{ key.secret }}
-{% endfor %}
-{% endif %}
-{% endif %}
-{% if iface.area %}
- ip ospf {{ ospf.process_id }} area {{ iface.area }}
-{% endif %}
-!
-{% endfor %}
-```
-
-### 2. AI Output (JSON) for OSPF
-
-```json
-{
- "ospf": {
- "enabled": true,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {
- "address": "192.168.1.0",
- "wildcard": "0.0.0.255",
- "area": 0
- },
- {
- "address": "10.0.0.0",
- "wildcard": "0.255.255.255",
- "area": 1
- }
- ],
- "passive_interfaces": ["GigabitEthernet0/0"],
- "interfaces": [
- {
- "name": "GigabitEthernet0/1",
- "cost": 10,
- "area": 0,
- "hello_interval": 10,
- "dead_interval": 40
- }
- ],
- "auto_cost_reference": 100000,
- "default_information_originate": true,
- "default_metric": 100
- }
-}
-```
-
-### 3. Rendered Configuration Output
-
-```cisco
-router ospf 100
- router-id 1.1.1.1
- network 192.168.1.0 mask 0.0.0.255 area 0
- network 10.0.0.0 mask 0.255.255.255 area 1
- passive-interface GigabitEthernet0/0
- auto-cost reference-bandwidth 100000
- default-information originate metric 100
-!
-interface GigabitEthernet0/1
- ip ospf cost 10
- ip ospf hello-interval 10
- ip ospf dead-interval 40
- ip ospf 100 area 0
-!
-```
-
-## BGP Configuration Template
-
-**File**: `config_templates/cisco/ios/bgp.j2`
-
-```jinja2
-{% if bgp.enabled %}
-router bgp {{ bgp.as_number }}
-{% if bgp.router_id %}
- bgp router-id {{ bgp.router_id }}
-{% endif %}
-{% if bgp.log_neighbor_changes %}
- bgp log-neighbor-changes
-{% endif %}
-{% if bgp.graceful_restart %}
- bgp graceful-restart
-{% endif %}
-
-{# BGP Neighbors #}
-{% for neighbor in bgp.neighbors %}
- neighbor {{ neighbor.ip }} remote-as {{ neighbor.remote_as }}
- {% if neighbor.description %}
- neighbor {{ neighbor.ip }} description {{ neighbor.description }}
- {% endif %}
- {% if neighbor.ebgp_multihop %}
- neighbor {{ neighbor.ip }} ebgp-multihop {{ neighbor.ebgp_multihop }}
- {% endif %}
- {% if neighbor.next_hop_self %}
- neighbor {{ neighbor.ip }} next-hop-self
- {% endif %}
- {% if neighbor.remove_private_as %}
- neighbor {{ neighbor.ip }} remove-private-as
- {% endif %}
- {% if neighbor.route_map_in %}
- neighbor {{ neighbor.ip }} route-map {{ neighbor.route_map_in }} in
- {% endif %}
- {% if neighbor.route_map_out %}
- neighbor {{ neighbor.ip }} route-map {{ neighbor.route_map_out }} out
- {% endif %}
- {% if neighbor.password %}
- neighbor {{ neighbor.ip }} password {{ neighbor.password }}
- {% endif %}
-{% endfor %}
-
-{# Address Families #}
-{% for af in bgp.address_families %}
- address-family {{ af.type }} {{ af.vrf if af.vrf else '' }}
- {% if af.redistribute_connected %}
- redistribute connected
- {% endif %}
- {% if af.redistribute_static %}
- redistribute static
- {% endif %}
- {% if af.redistribute_ospf %}
- redistribute ospf {{ af.redistribute_ospf }}
- {% endif %}
- {% if af.networks %}
- {% for network in af.networks %}
- network {{ network.address }} mask {{ network.mask }}
- {% endfor %}
- {% endif %}
- {% for neighbor in af.neighbors %}
- neighbor {{ neighbor.ip }} activate
- {% if neighbor.route_map_in %}
- neighbor {{ neighbor.ip }} route-map {{ neighbor.route_map_in }} in
- {% endif %}
- {% if neighbor.route_map_out %}
- neighbor {{ neighbor.ip }} route-map {{ neighbor.route_map_out }} out
- {% endif %}
- {% if neighbor.soft_reconfiguration_inbound %}
- neighbor {{ neighbor.ip }} soft-reconfiguration inbound
- {% endif %}
- {% endfor %}
- exit-address-family
-{% endfor %}
-!
-{% endif %}
-```
-
-### AI Output Example for BGP
-
-**User Input**:
-```
-"Configure BGP with local AS 65001, establish eBGP with 192.168.1.2 (AS 65002),
-advertise network 10.1.0.0/16 to IPv4"
-```
-
-**AI Output**:
-```json
-{
- "bgp": {
- "enabled": true,
- "as_number": 65001,
- "router_id": "1.1.1.1",
- "log_neighbor_changes": true,
- "neighbors": [
- {
- "ip": "192.168.1.2",
- "remote_as": 65002,
- "description": "ISP_Peer",
- "ebgp_multihop": 2
- }
- ],
- "address_families": [
- {
- "type": "ipv4",
- "networks": [
- {
- "address": "10.1.0.0",
- "mask": "255.255.0.0"
- }
- ],
- "neighbors": [
- {
- "ip": "192.168.1.2",
- "activate": true
- }
- ]
- }
- ]
- }
-}
-```
-
-## Interface Configuration Template
-
-**File**: `config_templates/cisco/ios/interface.j2`
-
-```jinja2
-{% for iface in interfaces %}
-interface {{ iface.name }}
-{% if iface.description %}
- description {{ iface.description }}
-{% endif %}
-{% if iface.ip_address %}
- ip address {{ iface.ip_address }} {{ iface.subnet_mask }}
-{% endif %}
-{% if iface.ipv6_address %}
- ipv6 address {{ iface.ipv6_address }}
-{% endif %}
-{% if iface.secondary_ips %}
-{% for secondary in iface.secondary_ips %}
- ip address {{ secondary.address }} {{ secondary.mask }} secondary
-{% endfor %}
-{% endif %}
-{% if iface.enabled is defined %}
-{% if not iface.enabled %}
- shutdown
-{% else %}
- no shutdown
-{% endif %}
-{% endif %}
-{% if iface.mtu %}
- mtu {{ iface.mtu }}
-{% endif %}
-{% if iface.bandwidth %}
- bandwidth {{ iface.bandwidth }}
-{% endif %}
-{% if iface.speed %}
- speed {{ iface.speed }}
-{% endif %}
-{% if iface.duplex %}
- duplex {{ iface.duplex }}
-{% endif %}
-{% if iface.acl_in %}
- ip access-group {{ iface.acl_in }} in
-{% endif %}
-{% if iface.acl_out %}
- ip access-group {{ iface.acl_out }} out
-{% endif %}
-{% if iface.nat_outside %}
- ip nat outside
-{% endif %}
-{% if iface.nat_inside %}
- ip nat inside
-{% endif %}
-{% if iface.vlan %}
- switchport access vlan {{ iface.vlan }}
-{% endif %}
-{% if iface.trunk_vlans %}
- switchport trunk encapsulation dot1q
- switchport mode trunk
- switchport trunk allowed vlan {{ iface.trunk_vlans }}
-{% endif %}
-!
-{% endfor %}
-```
-
-## JSON Schema Definitions
-
-### OSPF Schema
-
-**File**: `config_templates/schemas/cisco_ospf.json`
-
-```json
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "title": "Cisco OSPF Configuration",
- "type": "object",
- "properties": {
- "ospf": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean",
- "description": "Enable OSPF routing"
- },
- "process_id": {
- "type": "integer",
- "minimum": 1,
- "maximum": 65535,
- "description": "OSPF process ID (1-65535)"
- },
- "router_id": {
- "type": "string",
- "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$",
- "description": "Router ID in dotted decimal notation"
- },
- "networks": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "address": {
- "type": "string",
- "description": "Network address"
- },
- "wildcard": {
- "type": "string",
- "description": "Wildcard mask"
- },
- "area": {
- "type": "integer",
- "minimum": 0,
- "maximum": 4294967295,
- "description": "OSPF area ID"
- }
- },
- "required": ["address", "wildcard", "area"]
- }
- },
- "passive_interfaces": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "description": "List of passive interfaces"
- },
- "interfaces": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "description": "Interface name (e.g., GigabitEthernet0/0)"
- },
- "cost": {
- "type": "integer",
- "minimum": 1,
- "maximum": 65535,
- "description": "Interface OSPF cost"
- },
- "area": {
- "type": "integer",
- "minimum": 0,
- "maximum": 4294967295,
- "description": "OSPF area for this interface"
- },
- "hello_interval": {
- "type": "integer",
- "minimum": 1,
- "maximum": 8192,
- "description": "OSPF hello interval in seconds"
- },
- "dead_interval": {
- "type": "integer",
- "minimum": 1,
- "maximum": 32768,
- "description": "OSPF dead interval in seconds"
- },
- "authentication": {
- "type": "boolean",
- "description": "Enable OSPF authentication"
- },
- "auth_type": {
- "type": "string",
- "enum": ["simple", "message-digest"],
- "description": "Authentication type"
- },
- "auth_key": {
- "type": "string",
- "description": "Simple authentication key"
- },
- "auth_md5_keys": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "id": {
- "type": "integer",
- "minimum": 1,
- "maximum": 255
- },
- "secret": {
- "type": "string"
- }
- },
- "required": ["id", "secret"]
- }
- }
- },
- "required": ["name"]
- }
- },
- "auto_cost_reference": {
- "type": "integer",
- "description": "Reference bandwidth for auto cost (Mbps)"
- },
- "default_information_originate": {
- "type": "boolean",
- "description": "Advertise default route"
- },
- "default_metric": {
- "type": "integer",
- "description": "Default route metric"
- }
- },
- "required": ["enabled", "process_id"]
- }
- }
-}
-```
-
-### BGP Schema
-
-**File**: `config_templates/schemas/cisco_bgp.json`
-
-```json
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "title": "Cisco BGP Configuration",
- "type": "object",
- "properties": {
- "bgp": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- },
- "as_number": {
- "type": "integer",
- "minimum": 1,
- "maximum": 65535
- },
- "router_id": {
- "type": "string",
- "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
- },
- "log_neighbor_changes": {
- "type": "boolean"
- },
- "graceful_restart": {
- "type": "boolean"
- },
- "neighbors": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "ip": {
- "type": "string"
- },
- "remote_as": {
- "type": "integer",
- "minimum": 1,
- "maximum": 65535
- },
- "description": {
- "type": "string"
- },
- "ebgp_multihop": {
- "type": "integer",
- "minimum": 1,
- "maximum": 255
- },
- "next_hop_self": {
- "type": "boolean"
- },
- "remove_private_as": {
- "type": "boolean"
- },
- "route_map_in": {
- "type": "string"
- },
- "route_map_out": {
- "type": "string"
- },
- "password": {
- "type": "string"
- }
- },
- "required": ["ip", "remote_as"]
- }
- },
- "address_families": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "enum": ["ipv4", "ipv6", "vpnv4", "vpnv6"]
- },
- "vrf": {
- "type": "string"
- },
- "redistribute_connected": {
- "type": "boolean"
- },
- "redistribute_static": {
- "type": "boolean"
- },
- "redistribute_ospf": {
- "type": "integer"
- },
- "networks": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "address": {
- "type": "string"
- },
- "mask": {
- "type": "string"
- }
- },
- "required": ["address", "mask"]
- }
- },
- "neighbors": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "ip": {
- "type": "string"
- },
- "activate": {
- "type": "boolean"
- },
- "route_map_in": {
- "type": "string"
- },
- "route_map_out": {
- "type": "string"
- },
- "soft_reconfiguration_inbound": {
- "type": "boolean"
- }
- },
- "required": ["ip"]
- }
- }
- },
- "required": ["type"]
- }
- }
- },
- "required": ["enabled", "as_number"]
- }
- }
-}
-```
-
-## Implementation
-
-### Config Renderer Module
-
-**File**: `gns3server/agent/gns3_copilot/config_renderer.py`
-
-```python
-"""
-Configuration Renderer for GNS3 AI Copilot
-
-This module handles rendering of network device configurations
-using Jinja2 templates based on AI-generated structured data.
-"""
-
-import os
-import json
-from typing import Dict, Any, Optional
-from jinja2 import Environment, FileSystemLoader, TemplateError, select_autoescape
-from pathlib import Path
-import logging
-
-log = logging.getLogger(__name__)
-
-
-class ConfigRenderer:
- """
- Renders network device configurations using Jinja2 templates.
-
- The AI generates structured data (JSON), which is then validated
- against schemas and rendered through vendor-specific templates.
- """
-
- def __init__(self, template_dir: Optional[str] = None):
- """
- Initialize the configuration renderer.
-
- Args:
- template_dir: Path to template directory. If None, uses default.
- """
- if template_dir is None:
- template_dir = Path(__file__).parent / "config_templates"
-
- self.template_dir = Path(template_dir)
- self.env = Environment(
- loader=FileSystemLoader(str(self.template_dir)),
- autoescape=select_autoescape(),
- trim_blocks=True,
- lstrip_blocks=True
- )
-
- log.info(f"ConfigRenderer initialized with templates from: {self.template_dir}")
-
- def render(
- self,
- vendor: str,
- os_type: str,
- feature: str,
- data: Dict[str, Any]
- ) -> str:
- """
- Render a configuration template.
-
- Args:
- vendor: Vendor name (cisco, juniper, huawei, etc.)
- os_type: OS type (ios, junos, vrp, etc.)
- feature: Feature name (ospf, bgp, interface, etc.)
- data: Structured data from AI
-
- Returns:
- Rendered configuration as string
-
- Raises:
- TemplateError: If template rendering fails
- FileNotFoundError: If template doesn't exist
- """
- template_path = f"{vendor}/{os_type}/{feature}.j2"
-
- try:
- template = self.env.get_template(template_path)
- config = template.render(**data)
- log.info(f"Successfully rendered template: {template_path}")
- return config
- except TemplateError as e:
- log.error(f"Template rendering error for {template_path}: {e}")
- raise
- except Exception as e:
- log.error(f"Unexpected error rendering {template_path}: {e}")
- raise
-
- def render_multi(
- self,
- vendor: str,
- os_type: str,
- features: Dict[str, Dict[str, Any]]
- ) -> str:
- """
- Render multiple configuration templates and combine them.
-
- Args:
- vendor: Vendor name
- os_type: OS type
- features: Dictionary of feature names and their data
-
- Returns:
- Combined configuration
- """
- configs = []
- for feature, data in features.items():
- config = self.render(vendor, os_type, feature, data)
- configs.append(config)
-
- return "\n".join(configs)
-
- def get_available_templates(self) -> Dict[str, list]:
- """
- Get list of available templates organized by vendor and OS.
-
- Returns:
- Dictionary with vendors as keys and list of available features
- """
- templates = {}
- template_path = Path(self.template_dir)
-
- for vendor_dir in template_path.iterdir():
- if vendor_dir.is_dir() and not vendor_dir.name.startswith('_'):
- vendor = vendor_dir.name
- templates[vendor] = {}
-
- for os_dir in vendor_dir.iterdir():
- if os_dir.is_dir():
- os_type = os_dir.name
- templates[vendor][os_type] = []
-
- for template_file in os_dir.glob("*.j2"):
- feature = template_file.stem
- templates[vendor][os_type].append(feature)
-
- return templates
-
- def validate_data(self, schema_name: str, data: Dict[str, Any]) -> bool:
- """
- Validate structured data against JSON schema.
-
- Args:
- schema_name: Name of schema file
- data: Data to validate
-
- Returns:
- True if valid, raises ValidationError otherwise
- """
- # Import jsonschema only when needed
- try:
- from jsonschema import validate, ValidationError
- except ImportError:
- log.warning("jsonschema not installed, skipping validation")
- return True
-
- schema_path = self.template_dir / "schemas" / f"{schema_name}.json"
-
- if not schema_path.exists():
- log.warning(f"Schema not found: {schema_path}")
- return True
-
- with open(schema_path, 'r') as f:
- schema = json.load(f)
-
- try:
- validate(instance=data, schema=schema)
- return True
- except ValidationError as e:
- log.error(f"Schema validation failed: {e.message}")
- raise
-
-
-class ConfigBuilder:
- """
- Builds complete device configurations by combining multiple features.
- """
-
- def __init__(self, renderer: ConfigRenderer):
- self.renderer = renderer
-
- def build_device_config(
- self,
- vendor: str,
- os_type: str,
- config_data: Dict[str, Any]
- ) -> str:
- """
- Build a complete device configuration.
-
- Args:
- vendor: Vendor name
- os_type: OS type
- config_data: Dictionary with all feature configurations
-
- Returns:
- Complete device configuration
- """
- # Extract metadata
- hostname = config_data.get("hostname", "Router")
- config_parts = [f"hostname {hostname}\n"]
-
- # Render each feature section
- feature_order = [
- "interface",
- "vlan",
- "ospf",
- "bgp",
- "eigrp",
- "rip",
- "acl",
- "nat",
- "qos",
- "multicast"
- ]
-
- for feature in feature_order:
- if feature in config_data:
- try:
- config = self.renderer.render(
- vendor, os_type, feature,
- {feature: config_data[feature]}
- )
- config_parts.append(config)
- except Exception as e:
- log.warning(f"Failed to render {feature}: {e}")
-
- return "\n".join(config_parts)
-```
-
-### Usage Example
-
-```python
-from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer, ConfigBuilder
-
-# Initialize renderer
-renderer = ConfigRenderer()
-
-# AI-generated data for OSPF configuration
-ai_data = {
- "ospf": {
- "enabled": True,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
- ],
- "passive_interfaces": ["GigabitEthernet0/0"]
- }
-}
-
-# Render configuration
-config = renderer.render(
- vendor="cisco",
- os_type="ios",
- feature="ospf",
- data=ai_data
-)
-
-print(config)
-
-# Build complete device configuration
-builder = ConfigBuilder(renderer)
-full_config = builder.build_device_config(
- vendor="cisco",
- os_type="ios",
- config_data={
- "hostname": "R1",
- "interface": {...},
- "ospf": {...},
- "bgp": {...}
- }
-)
-```
-
-## Integration with AI Copilot
-
-### New Tool for Configuration Rendering
-
-```python
-"""
-File: gns3server/agent/gns3_copilot/tools_v2/gns3_render_config.py
-"""
-
-from langchain_core.tools import tool
-from typing import Dict, Any
-import logging
-
-log = logging.getLogger(__name__)
-
-
-@tool
-def render_device_config(
- node_id: str,
- vendor: str,
- os_type: str,
- config_data: Dict[str, Any]
-) -> str:
- """
- Render network device configuration using Jinja2 templates.
-
- Instead of generating full configuration text, the AI should provide
- structured data (dict) that will be rendered through templates.
-
- Args:
- node_id: GNS3 node identifier
- vendor: Device vendor (cisco, juniper, huawei, etc.)
- os_type: Operating system type (ios, junos, vrp, nexus, etc.)
- config_data: Structured configuration data from AI
-
- Returns:
- Rendered configuration string
-
- Example:
- >>> ai_output = {
- ... "ospf": {
- ... "enabled": True,
- ... "process_id": 100,
- ... "router_id": "1.1.1.1",
- ... "networks": [
- ... {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
- ... ]
- ... }
- ... }
- >>> config = render_device_config(
- ... node_id="node-1",
- ... vendor="cisco",
- ... os_type="ios",
- ... config_data=ai_output
- ... )
- """
- from gns3server.agent.gns3_copilot.config_renderer import ConfigRenderer
-
- renderer = ConfigRenderer()
-
- # Validate against schema if available
- try:
- renderer.validate_data(f"{vendor}_{list(config_data.keys())[0]}", config_data)
- except Exception as e:
- log.warning(f"Schema validation failed: {e}")
-
- # Render configuration
- try:
- if len(config_data) == 1:
- # Single feature
- feature = list(config_data.keys())[0]
- config = renderer.render(vendor, os_type, feature, config_data)
- else:
- # Multiple features
- config = renderer.render_multi(vendor, os_type, config_data)
-
- return config
-
- except Exception as e:
- return f"Error rendering configuration: {str(e)}"
-```
-
-## Benefits
-
-1. **Reliability**: Templates are tested and verified, reducing configuration errors
-2. **Efficiency**: AI generates less text (structured data only), saving tokens and processing time
-3. **Consistency**: Uniform configuration style across all generated configs
-4. **Maintainability**: Templates are version controlled and easy to update
-5. **Scalability**: Easy to add new vendors and features
-6. **Validation**: JSON schemas ensure data correctness before rendering
-7. **Vendor Support**: Easy to support multiple vendors with same AI logic
-8. **Testing**: Templates can be unit tested independently
-
-## Future Enhancements
-
-1. **Template Marketplace**: Community-contributed templates
-2. **Auto-discovery**: Detect device vendor/OS from GNS3 node type
-3. **Config Diff**: Show differences before/after configuration
-4. **Best Practices**: Templates embed industry best practices
-5. **Validation**: Post-render validation against device syntax
-6. **Rollback**: Auto-generate rollback configurations
-7. **Documentation**: Templates include inline documentation
-
-## Example Prompts for AI
-
-The AI should be prompted to generate structured data instead of full configs:
-
-**Good Prompt**:
-```
-"Generate OSPF configuration with process ID 100, router-id 1.1.1.1,
-include network 192.168.1.0/24 in area 0. Output as structured JSON data."
-```
-
-**AI Output**:
-```json
-{
- "ospf": {
- "enabled": true,
- "process_id": 100,
- "router_id": "1.1.1.1",
- "networks": [
- {"address": "192.168.1.0", "wildcard": "0.0.0.255", "area": 0}
- ]
- }
-}
-```
-
-Then the agent calls `render_device_config` tool to get the actual configuration.
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
diff --git a/docs/gns3-copilot/todo/multi-user-concurrency-control.md b/docs/gns3-copilot/todo/multi-user-concurrency-control.md
deleted file mode 100644
index 8549e374e..000000000
--- a/docs/gns3-copilot/todo/multi-user-concurrency-control.md
+++ /dev/null
@@ -1,607 +0,0 @@
-# Multi-User Device Concurrency Control
-
-**Status:** TODO
-**Priority:** HIGH
-**Created:** 2025-03-06
-**Author:** GNS3-Copilot Team
-
----
-
-## Problem Statement
-
-When multiple users simultaneously use the GNS3-Copilot Agent to operate on the same network device node, a race condition occurs because all operations connect through the same telnet console port.
-
-### Current Architecture
-
-```
-User A → Agent → Netmiko → telnet console:5000 ─┐
- ├──→ R1 (Shared Console)
-User B → Agent → Netmiko → telnet console:5000 ─┘
-```
-
-### Affected Components
-
-| Tool | Connection Method | File |
-|------|------------------|------|
-| `execute_multiple_device_config_commands` | Netmiko (netmiko_send_config) | `tools_v2/config_tools_nornir.py` |
-| `execute_multiple_device_commands` | Netmiko (netmiko_multiline) | `tools_v2/display_tools_nornir.py` |
-| `vpcs_multi_commands` | telnetlib3 | `tools_v2/vpcs_tools_telnetlib3.py` |
-
-### Conflict Scenario Example
-
-```
-Timeline:
-T1: User A executes "conf t" → Device enters configuration mode
-T2: User B executes "show run" → May see config mode prompt
-T3: User B executes "interface Gig0/0" → Interrupts User A's configuration
-T4: User A's output contains User B's commands (confusion!)
-T5: User A executes "ip address 1.1.1.1 255.255.255.255" → Applies to wrong interface
-```
-
-**Impact:**
-- Configuration applied to wrong interface/context
-- Mixed output from different users
-- Lost commands or unintended configuration changes
-- Unpredictable device state
-
----
-
-## Proposed Solutions
-
-### Solution 1: Device-Level Mutex Lock (RECOMMENDED)
-
-**Implementation Location:** `gns3server/agent/gns3_copilot/utils/device_lock.py`
-
-Create a device-level lock manager to ensure only one user can operate on a device at a time.
-
-#### Architecture
-
-```python
-class DeviceLockManager:
- """Manages exclusive access to network devices"""
-
- def __init__(self):
- # device_name -> asyncio.Lock
- self._locks: Dict[str, asyncio.Lock] = {}
- self._manager_lock = asyncio.Lock()
-
- @asynccontextmanager
- async def acquire_device(self, device_name: str, user_id: str, timeout: float = 30.0):
- """
- Acquire exclusive lock on a device.
-
- Args:
- device_name: Name of the device (e.g., "R-1")
- user_id: User ID requesting the lock
- timeout: Maximum time to wait for lock (seconds)
-
- Raises:
- RuntimeError: If timeout waiting for lock
- """
- lock = self._get_lock(device_name)
-
- logger.info("User %s requesting lock for device %s...", user_id, device_name)
-
- try:
- await asyncio.wait_for(lock.acquire(), timeout=timeout)
- logger.info("✓ User %s acquired lock for device %s", user_id, device_name)
- yield
- except asyncio.TimeoutError:
- logger.warning("✗ User %s timeout waiting for device %s", user_id, device_name)
- raise RuntimeError(
- f"Device {device_name} is busy with another user's operation. "
- f"Please wait a moment and try again."
- )
- finally:
- lock.release()
- logger.info("User %s released lock for device %s", user_id, device_name)
-```
-
-#### Tool Integration
-
-Modify tool `_run()` methods to use locks:
-
-```python
-# In config_tools_nornir.py
-from gns3server.agent.gns3_copilot.utils.device_lock import _device_lock_manager
-from gns3server.agent.gns3_copilot.gns3_client.context_helpers import get_current_llm_config
-
-async def _run(self, tool_input: str, run_manager=None, **kwargs):
- # Get user_id from context
- llm_config = get_current_llm_config()
- user_id = llm_config.get("user_id", "unknown") if llm_config else "unknown"
-
- device_configs_list, project_id = self._validate_tool_input(tool_input)
-
- results = []
- for device_config in device_configs_list:
- device_name = device_config["device_name"]
-
- # Acquire lock before operating on device
- async with _device_lock_manager.acquire_device(device_name, user_id):
- # Execute configuration
- result = await self._execute_config_on_device(device_config, project_id)
- results.append(result)
-
- return results
-```
-
-**Pros:**
-- Simple implementation
-- Clear ownership (user knows who has the lock)
-- Automatic timeout prevents deadlocks
-
-**Cons:**
-- Single-process only (no cross-server locking)
-- Users must wait if device is busy
-
----
-
-### Solution 2: Transactional Operations with Mode Cleanup
-
-Ensure device state consistency before and after operations.
-
-```python
-async def _safe_execute_config(self, connection, config_commands):
- """Safely execute config with guaranteed state cleanup"""
-
- # 1. Ensure privileged mode (not config mode)
- try:
- connection.exit_config_mode()
- connection.find_prompt()
- except:
- pass
-
- try:
- # 2. Execute configuration
- result = connection.send_config_set(config_commands)
- return result
- finally:
- # 3. Always cleanup: exit config mode
- try:
- connection.exit_config_mode()
- except:
- pass
-```
-
-**Pros:**
-- Reduces chance of leaving device in bad state
-- Works as safety layer alongside locks
-
-**Cons:**
-- Doesn't prevent concurrent access (needs Solution 1)
-- Adds overhead to each operation
-
----
-
-### Solution 3: Frontend User Notification
-
-Display device lock status in the UI to inform users.
-
-#### Backend Events
-
-```python
-# In AgentService.stream_chat()
-async with _device_lock_manager.acquire_device(device_name, user_id):
- # Emit lock acquired event
- yield {
- "type": "device_lock_acquired",
- "device_name": device_name,
- "user_id": user_id,
- "timestamp": datetime.utcnow().isoformat(),
- }
-
- try:
- # Execute operations
- result = await self._execute(...)
- yield {"type": "tool_end", "output": result}
- finally:
- # Emit lock released event
- yield {
- "type": "device_lock_released",
- "device_name": device_name,
- "timestamp": datetime.utcnow().isoformat(),
- }
-```
-
-#### Frontend Handling
-
-```javascript
-// WebSocket event listeners
-socket.on('device_lock_acquired', (data) => {
- showNotification(
- `Device ${data.device} is locked by user ${data.user}`,
- 'warning'
- );
- disableDeviceControls(data.device);
-});
-
-socket.on('device_lock_released', (data) => {
- hideNotification(data.device);
- enableDeviceControls(data.device);
-});
-
-socket.on('device_lock_timeout', (data) => {
- showError(
- `Could not acquire lock on ${data.device}. ` +
- `Another user is operating on it. Please wait.`
- );
-});
-```
-
-**Pros:**
-- Better UX (users know why they're waiting)
-- Transparency about device usage
-
-**Cons:**
-- Requires frontend changes
-- More complex WebSocket protocol
-
----
-
-### Solution 4: Distributed Lock (Multi-Server Deployment)
-
-For production deployments with multiple GNS3 Server instances.
-
-#### Redis-based Lock Manager
-
-```python
-# Requires: pip install aioredis
-import aioredis
-
-class RedisDeviceLockManager:
- """Distributed device lock using Redis"""
-
- def __init__(self, redis_url: str = "redis://localhost:6379"):
- self.redis = aioredis.from_url(redis_url)
-
- @asynccontextmanager
- async def acquire_device(self, device_name: str, user_id: str):
- key = f"gns3:device_lock:{device_name}"
- lock = self.redis.lock(
- key,
- timeout=60, # Auto-release after 60s
- blocking_timeout=30 # Wait max 30s
- )
-
- try:
- await lock.acquire()
- # Store metadata
- await self.redis.hset(
- f"{key}:meta",
- mapping={
- "user_id": user_id,
- "acquired_at": datetime.utcnow().isoformat(),
- }
- )
- yield
- finally:
- await self.redis.delete(f"{key}:meta")
- await lock.release()
-```
-
-**Pros:**
-- Works across multiple server instances
-- Centralized lock management
-- Persistent lock state
-
-**Cons:**
-- Requires Redis infrastructure
-- More complex deployment
-
----
-
-## Implementation Plan
-
-### Phase 1: Core Lock Implementation (Priority: HIGH)
-
-**Tasks:**
-
-1. **Create Device Lock Manager**
- - [ ] Create `gns3server/agent/gns3_copilot/utils/device_lock.py`
- - [ ] Implement `DeviceLockManager` class with asyncio locks
- - [ ] Add comprehensive logging for lock acquisition/release
- - [ ] Add unit tests for lock behavior
-
-2. **Integrate with Config Tools**
- - [ ] Modify `config_tools_nornir.py::ExecuteMultipleDeviceConfigCommands._run()`
- - [ ] Extract user_id from request context
- - [ ] Wrap device operations in lock context manager
- - [ ] Handle timeout exceptions gracefully
-
-3. **Integrate with Display Tools**
- - [ ] Modify `display_tools_nornir.py::ExecuteMultipleDeviceCommands._run()`
- - [ ] Add same lock protection for read operations
- - [ ] Consider allowing concurrent reads (readers-writer lock?)
-
-4. **Integrate with VPCS Tools**
- - [ ] Modify `vpcs_tools_telnetlib3.py::VPCSMultiCommands._run()`
- - [ ] Add lock protection
-
-5. **Add Mode Cleanup**
- - [ ] Implement `_safe_execute_config()` helper
- - [ ] Ensure all operations exit config mode after completion
-
-**Estimated Effort:** 2-3 days
-
-**Testing Checklist:**
-- [ ] Single user operation (baseline)
-- [ ] Two users configuring same device simultaneously
-- [ ] Two users reading same device simultaneously
-- [ ] One user configuring, one user reading same device
-- [ ] Lock timeout behavior
-- [ ] Lock release on exception
-- [ ] Concurrent operations on different devices (should not block)
-
----
-
-### Phase 2: User Experience Enhancements (Priority: MEDIUM)
-
-**Tasks:**
-
-1. **Backend Events**
- - [ ] Emit `device_lock_acquired` events
- - [ ] Emit `device_lock_released` events
- - [ ] Emit `device_lock_timeout` events
- - [ ] Include device_name, user_id, timestamp in events
-
-2. **Frontend Integration**
- - [ ] Add WebSocket listeners for lock events
- - [ ] Display lock status badges on device cards
- - [ ] Show toast notifications for lock state changes
- - [ ] Disable controls while locked
- - [ ] Add "Waiting for lock..." indicator
-
-3. **Error Messages**
- - [ ] Localize timeout messages
- - [ ] Add helpful hints (e.g., "Wait 30 seconds and retry")
- - [ ] Include which user has the lock
-
-**Estimated Effort:** 3-4 days
-
----
-
-### Phase 3: Production Readiness (Priority: LOW)
-
-**Tasks:**
-
-1. **Distributed Lock**
- - [ ] Add Redis dependency to `requirements.txt`
- - [ ] Implement `RedisDeviceLockManager`
- - [ ] Add configuration option (memory vs redis)
- - [ ] Document Redis setup
-
-2. **Monitoring**
- - [ ] Add metrics: lock wait time, lock hold time
- - [ ] Add Prometheus exporters
- - [ ] Dashboard for lock statistics
-
-3. **Advanced Features**
- - [ ] Lock queue (FIFO waitlist)
- - [ ] Lock priority (admin vs regular user)
- - [ ] Forced lock release (admin override)
- - [ ] Lock expiration handling
-
-**Estimated Effort:** 5-7 days
-
----
-
-## Design Considerations
-
-### Lock Granularity
-
-**Options:**
-
-| Granularity | Description | Pros | Cons |
-|-------------|-------------|------|------|
-| Per-device | Lock on each device | Fine-grained, good concurrency | More complex |
-| Per-project | Lock entire project | Simple | Blocks unrelated operations |
-| Per-user | One lock per user | Fair | Low concurrency |
-
-**Recommendation:** Start with per-device locks for optimal balance.
-
-### Concurrent Reads
-
-Consider allowing multiple concurrent read operations (show commands) while blocking writes:
-
-```python
-class ReadersWriterDeviceLock:
- """Allows multiple concurrent readers, exclusive writer"""
-
- def __init__(self):
- self._readers = 0
- self._writer_lock = asyncio.Lock()
- self._reader_lock = asyncio.Lock()
-
- async def acquire_read(self):
- """Acquire read lock (shared)"""
- async with self._reader_lock:
- self._readers += 1
- if self._readers == 1:
- await self._writer_lock.acquire()
-
- async def release_read(self):
- """Release read lock"""
- async with self._reader_lock:
- self._readers -= 1
- if self._readers == 0:
- self._writer_lock.release()
-
- async def acquire_write(self):
- """Acquire write lock (exclusive)"""
- await self._writer_lock.acquire()
-
- async def release_write(self):
- """Release write lock"""
- self._writer_lock.release()
-```
-
-### Timeout Strategy
-
-**Recommended timeouts:**
-
-| Operation | Timeout | Rationale |
-|-----------|---------|-----------|
-| Lock acquisition | 30s | User patience limit |
-| Lock auto-release | 120s | Prevent stale locks |
-| Read operation | 60s | show commands are fast |
-| Config operation | 90s | Configuration takes longer |
-
----
-
-## Testing Strategy
-
-### Unit Tests
-
-```python
-# tests/test_device_lock.py
-import pytest
-from utils.device_lock import DeviceLockManager
-
-@pytest.mark.asyncio
-async def test_single_lock_acquisition():
- manager = DeviceLockManager()
-
- async with manager.acquire_device("R-1", "user_a"):
- assert True # Should not raise
-
-@pytest.mark.asyncio
-async def test_concurrent_lock_rejection():
- manager = DeviceLockManager()
- lock_a_acquired = False
-
- async def user_a():
- nonlocal lock_a_acquired
- async with manager.acquire_device("R-1", "user_a"):
- lock_a_acquired = True
- await asyncio.sleep(0.5)
-
- async def user_b():
- await asyncio.sleep(0.1) # Let A acquire first
- with pytest.raises(RuntimeError):
- async with manager.acquire_device("R-1", "user_b", timeout=0.3):
- pass
-
- await asyncio.gather(user_a(), user_b())
- assert lock_a_acquired
-```
-
-### Integration Tests
-
-```python
-@pytest.mark.asyncio
-async def test_concurrent_config_operations():
- """Simulate two users configuring same device"""
- service = AgentService(project_path)
-
- async def user_a_config():
- return await service.stream_chat(
- "Configure loopback on R-1",
- session_id="user_a",
- user_id="user_a"
- )
-
- async def user_b_config():
- await asyncio.sleep(0.2) # Slight delay
- return await service.stream_chat(
- "Configure loopback on R-1", # Same device!
- session_id="user_b",
- user_id="user_b"
- )
-
- results = await asyncio.gather(
- user_a_config(),
- user_b_config(),
- return_exceptions=True
- )
-
- # One should succeed, one should timeout
- assert any(isinstance(r, RuntimeError) for r in results)
-```
-
----
-
-## Rollout Plan
-
-1. **Feature Flag**
- ```python
- # config.py
- ENABLE_DEVICE_LOCKS = os.getenv("GNS3_COPILOT_DEVICE_LOCKS", "true").lower() == "true"
- ```
-
-2. **Gradual Enablement**
- - Week 1: Enable in development environment
- - Week 2: Enable in staging with monitoring
- - Week 3: Enable for 10% of production users
- - Week 4: Full rollout
-
-3. **Monitoring**
- - Track lock acquisition rate
- - Monitor timeout frequency
- - Measure user wait times
-
----
-
-## Open Questions
-
-1. **Should read operations be concurrent?**
- - Pros: Better user experience for diagnostic tasks
- - Cons: More complex implementation, risk of read-during-write
-
-2. **What about bulk operations?**
- - If user operates on 10 devices, should we acquire all locks first?
- - Risk: Deadlock if two users request overlapping device sets
-
-3. **Lock priority?**
- - Should instructors/admins have priority over students?
- - How to signal this in the UI?
-
-4. **Graceful degradation?**
- - If lock service fails, should we:
- - a) Block all operations (safe but disruptive)
- - b) Allow operations with warning (risky)
-
----
-
-## References
-
-- [Python asyncio.Lock documentation](https://docs.python.org/3/library/asyncio-sync.html#asyncio.Lock)
-- [Redlock algorithm (Redis distributed locks)](https://redis.io/topics/distlock)
-- [Netmiko connection management](https://github.com/ktbyers/netmiko)
-
----
-
-## Changelog
-
-| Date | Change |
-|------|--------|
-| 2025-03-06 | Initial document creation |
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/orphan-tool-calls-recovery.md b/docs/gns3-copilot/todo/orphan-tool-calls-recovery.md
deleted file mode 100644
index e3878a651..000000000
--- a/docs/gns3-copilot/todo/orphan-tool-calls-recovery.md
+++ /dev/null
@@ -1,480 +0,0 @@
-# TODO: Fix Orphan Tool Calls Causing Checkpoint State Inconsistency
-
-## Problem Description
-
-When the LangGraph agent terminates abnormally during execution (such as forced service shutdown, process crash, etc.), it may result in a checkpoint containing an `AIMessage` with `tool_calls` but no corresponding `ToolMessage`. This state inconsistency can cause errors during subsequent conversation recovery.
-
-### Terminology
-
-- **Orphan tool_calls**: `AIMessage` contains `tool_calls` field, but there's no corresponding `ToolMessage` in the message list
-- **Checkpoint**: LangGraph's mechanism for persisting conversation state
-- **State inconsistency**: Message state in checkpoint doesn't match expected message pairs (AIMessage + ToolMessage)
-
----
-
-## Trigger Scenarios
-
-### Scenario 1: Process Abnormal Termination (Primary Issue)
-
-```
-Execution flow:
-User message → llm_call → AIMessage(tool_calls) → [Checkpoint saved]
- ↓
- [Process crash/service shutdown]
- ↓
- tool_node not executed
- ↓
- Checkpoint contains:
- - AIMessage (has tool_calls) ✅
- - ToolMessage ❌ missing
-```
-
-**Trigger conditions:**
-- LLM returns a response containing tool_calls
-- Checkpoint has saved AIMessage
-- Service is shut down before tool_node execution (kill -9, Ctrl+C, crash, etc.)
-
-### Scenario 2: Maximum Call Count Reached (Already Handled)
-
-Current code checks remaining steps after tool_node execution via the `recursion_limit_continue` function:
-
-```python
-def recursion_limit_continue(state: MessagesState) -> Literal["llm_call", END]:
- last_message = state["messages"][-1]
- if isinstance(last_message, ToolMessage):
- if state["remaining_steps"] < 4:
- return END
- return "llm_call"
- return END
-```
-
-**Execution flow:**
-```
-remaining_steps = 5
-llm_call → AIMessage(tool_calls) → remaining_steps = 4
- ↓
- should_continue → tool_node (because there are tool_calls)
- ↓
- tool_node → ToolMessage → remaining_steps = 3
- ↓
- recursion_limit_continue → remaining_steps < 4 → END ✅
-```
-
-**Conclusion:** Scenario 2 won't produce orphan tool_calls because tool_node always executes and generates a ToolMessage.
-
----
-
-## Fix Solution
-
-### Core Idea
-
-At the start of `stream_chat`, for existing sessions, detect and fix orphan tool_calls.
-
-### Fix Strategy
-
-**Strategy A: Clear tool_calls (Recommended)**
-
-Create a new `AIMessage` with the same content as the original message but without the `tool_calls` field.
-
-**Advantages:**
-- Simple and clean
-- Won't affect subsequent conversation
-- User can ask the question again
-
-**Disadvantages:**
-- Loses LLM's original intent (but it already crashed, can't be recovered)
-
----
-
-## Implementation Code
-
-### 1. Add Fix Method (`agent_service.py`)
-
-```python
-async def _fix_orphan_tool_calls(self, graph, config: dict, session_id: str):
- """
- Detect and fix orphan tool_calls (AIMessage has tool_calls but no corresponding ToolMessage).
-
- Orphan tool_calls occur when the process crashes before tool_node execution.
-
- Uses LangGraph's aupdate_state API to safely create a new checkpoint version.
- """
- try:
- # 1. Read current state
- state = await graph.aget_state(config)
- if not state or not state.values.get("messages"):
- return
-
- messages = state.values["messages"]
- last_message = messages[-1]
-
- # 2. Detect orphan tool_calls
- if not (hasattr(last_message, "tool_calls") and last_message.tool_calls):
- return
-
- # Check if there's a corresponding ToolMessage
- has_tool_message = any(isinstance(m, ToolMessage) for m in messages)
-
- if has_tool_message:
- return
-
- log.warning("Detected orphan tool_calls: session=%s, will clear", session_id)
-
- # 3. Create fixed message (without tool_calls)
- from langchain.messages import AIMessage
- fixed_message = AIMessage(
- content=last_message.content,
- id=getattr(last_message, "id", None)
- )
-
- # 4. Use LangGraph API to update state (create new checkpoint)
- await graph.aupdate_state(config, {"messages": [fixed_message]})
-
- log.info("Orphan tool_calls fixed: session=%s", session_id)
-
- except Exception as e:
- log.error("Failed to fix orphan tool_calls: %s", e, exc_info=True)
-```
-
-### 2. Call in `stream_chat` (`agent_service.py`)
-
-Add fix logic after getting the graph and before starting the stream:
-
-```python
-async def stream_chat(
- self,
- message: str,
- session_id: str,
- project_id: Optional[str] = None,
- user_id: Optional[str] = None,
- jwt_token: Optional[str] = None,
- mode: str = "text",
- llm_config: Optional[Dict[str, Any]] = None,
-) -> AsyncGenerator[Dict[str, Any], None]:
- # ... existing code ...
-
- # Get or create chat session
- repo = ChatSessionsRepository(self._checkpointer_conn)
- session = await repo.get_session_by_thread(session_id)
- is_new_session = session is None
-
- if is_new_session:
- # Create new session
- session = await repo.create_session(...)
- log.debug("Created new chat session: thread_id=%s", session_id)
-
- # ... set context variables ...
-
- # Build config
- config = {
- "configurable": {
- "thread_id": session_id,
- "project_id": project_id,
- },
- "metadata": {
- "user_id": user_id,
- },
- }
-
- # Build inputs
- inputs = {
- "messages": [HumanMessage(content=message, id=str(uuid4()))],
- "llm_calls": 0,
- "remaining_steps": 20,
- "mode": mode,
- }
-
- # Get the compiled graph
- graph = await self._get_graph()
-
- # 🔧 Fix state: for existing sessions, check and fix orphan tool_calls
- if not is_new_session:
- await self._fix_orphan_tool_calls(graph, config, session_id)
-
- log.debug("LangGraph graph obtained, starting stream")
-
- # ... continue existing code ...
-```
-
-### 3. Required Imports
-
-Ensure `agent_service.py` has the following import:
-
-```python
-from langchain.messages import ToolMessage # For detecting ToolMessage type
-```
-
----
-
-## Impact on Checkpoint Database
-
-### LangGraph Checkpoint Mechanism
-
-LangGraph checkpoints are **versioned** - each state update creates a new record:
-
-```
-checkpoints table structure:
-- thread_id
-- checkpoint_id (incrementing version number)
-- checkpoint (serialized state data)
-- metadata
-- ...
-```
-
-### Security Analysis
-
-| Aspect | Impact | Description |
-|--------|--------|-------------|
-| **Original data** | Preserved unchanged | `aupdate_state` creates new version, doesn't overwrite history |
-| **Database structure** | Fully compatible | Uses LangGraph native API, won't break structure |
-| **Concurrency safety** | Built-in protection | LangGraph has locking mechanism for concurrent access |
-| **Storage overhead** | Minimal | Only adds one checkpoint record (about a few KB) |
-| **Revertibility** | Supported | Can roll back to any version before fix |
-
-### Not Direct Database Manipulation
-
-**❌ Dangerous approach:**
-```python
-# Direct database modification - destructive
-await conn.execute(
- "UPDATE checkpoints SET checkpoint = ? WHERE ...",
- [modified_json]
-)
-```
-
-**Problems:**
-- May break serialization format
-- Doesn't create new version, overwrites history
-- May cause database locking or corruption
-- Violates LangGraph design principles
-
-**✅ Safe approach:**
-```python
-# Use LangGraph's aupdate_state
-await graph.aupdate_state(config, {"messages": [fixed_message]})
-```
-
----
-
-## Testing Methods
-
-### Method 1: Simulated Crash Test (Recommended)
-
-Simulate crash scenarios by forcibly shutting down the service:
-
-```
-Steps:
-1. Start GNS3 service
-2. Send a message that triggers tool_calls (e.g., query topology)
-3. Observe logs, wait for AIMessage return (with tool_calls)
-4. Force shutdown service before tool_node completes:
- - Method 1: kill -9
- - Method 2: Ctrl+C (if supported)
-5. Restart GNS3 service
-6. Continue conversation using same session_id
-7. Observe logs, should see:
- - "Detected orphan tool_calls: session=xxx, will clear"
- - "Orphan tool_calls fixed: session=xxx"
-8. Verify conversation can proceed normally
-```
-
-### Method 2: Unit Tests
-
-Directly construct orphan tool_calls state to test fix logic:
-
-```python
-# tests/test_agent_service.py
-
-import pytest
-from langchain.messages import AIMessage, HumanMessage, ToolMessage
-
-@pytest.mark.asyncio
-async def test_fix_orphan_tool_calls():
- """Test orphan tool_calls fix logic"""
- from gns3server.agent.gns3_copilot.agent_service import AgentService
-
- # Create test agent service
- service = AgentService("/tmp/test_project")
- await service._get_checkpointer()
-
- graph = await service._get_graph()
- config = {"configurable": {"thread_id": "test_session"}}
-
- # Construct orphan state: add normal messages first
- await graph.aupdate_state(
- config,
- {
- "messages": [
- HumanMessage(content="Test message", id="msg_1"),
- AIMessage(
- content="Let me check for you",
- id="msg_2",
- tool_calls=[{
- "id": "call_123",
- "name": "get_topology",
- "args": {"project_id": "test"}
- }]
- )
- # Note: No corresponding ToolMessage
- ],
- "llm_calls": 1,
- "remaining_steps": 20
- }
- )
-
- # Call fix logic
- await service._fix_orphan_tool_calls(graph, config, "test_session")
-
- # Verify fix result
- state = await graph.aget_state(config)
- last_message = state.values["messages"][-1]
-
- # Should no longer have tool_calls
- assert not hasattr(last_message, "tool_calls") or not last_message.tool_calls
- assert last_message.content == "Let me check for you"
-
- # Cleanup
- await service.close()
-
-@pytest.mark.asyncio
-async def test_no_fix_when_normal():
- """Test that normal state isn't incorrectly fixed"""
- from gns3server.agent.gns3_copilot.agent_service import AgentService
-
- service = AgentService("/tmp/test_project")
- await service._get_checkpointer()
-
- graph = await service._get_graph()
- config = {"configurable": {"thread_id": "test_session_2"}}
-
- # Construct normal state: complete AIMessage + ToolMessage pair
- await graph.aupdate_state(
- config,
- {
- "messages": [
- HumanMessage(content="Test message", id="msg_1"),
- AIMessage(
- content="Let me check for you",
- id="msg_2",
- tool_calls=[{
- "id": "call_123",
- "name": "get_topology",
- "args": {"project_id": "test"}
- }]
- ),
- ToolMessage(
- content="Topology info: ...",
- tool_call_id="call_123",
- name="get_topology",
- id="msg_3"
- )
- ],
- "llm_calls": 1,
- "remaining_steps": 20
- }
- )
-
- # Record original message count
- state_before = await graph.aget_state(config)
- msg_count_before = len(state_before.values["messages"])
-
- # Call fix logic
- await service._fix_orphan_tool_calls(graph, config, "test_session_2")
-
- # Verify state unchanged
- state_after = await graph.aget_state(config)
- msg_count_after = len(state_after.values["messages"])
-
- assert msg_count_before == msg_count_after # Should not add new messages
- last_message = state_after.values["messages"][-1]
- assert isinstance(last_message, ToolMessage) # Last is still ToolMessage
-
- # Cleanup
- await service.close()
-```
-
-### Method 3: Enhanced Logging and Monitoring
-
-Even without active triggering, you can verify fix logic works in production:
-
-```python
-# Add detailed logging in _fix_orphan_tool_calls
-log.warning("Detected orphan tool_calls: session=%s", session_id)
-log.info("Original message: tool_calls=%d, content=%s",
- len(last_message.tool_calls),
- last_message.content[:100])
-log.info("After fix: tool_calls=%d",
- len(fixed_message.tool_calls) if hasattr(fixed_message, "tool_calls") else 0)
-```
-
----
-
-## File Modification Checklist
-
-### Files to Modify
-
-1. **`gns3server/agent/gns3_copilot/agent_service.py`**
- - Add `_fix_orphan_tool_calls` method
- - Call fix logic in `stream_chat` method
-
-### Test Files to Add (Optional)
-
-2. **`tests/test_agent_service.py`** (create new or add to existing test file)
- - `test_fix_orphan_tool_calls()` - Test orphan tool_calls fix
- - `test_no_fix_when_normal()` - Test normal state isn't incorrectly fixed
-
----
-
-## Implementation Steps
-
-1. ✅ Create TODO document (current document)
-2. ⬜ Add `_fix_orphan_tool_calls` method in `agent_service.py`
-3. ⬜ Call fix logic in `stream_chat`
-4. ⬜ Test fix effect using simulated crash method
-5. ⬜ Add unit tests (optional)
-6. ⬜ Update related documentation (if necessary)
-
----
-
-## Related Code Files
-
-- **Main modification file**: `gns3server/agent/gns3_copilot/agent_service.py`
-- **Related file**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py`
-- **Test file**: `tests/test_agent_service.py` (to be created)
-
----
-
-## Reference Documentation
-
-- [LangGraph Checkpointer Documentation](https://langchain-ai.github.io/langgraph/concepts/low_level/#checkpointer)
-- [LangGraph State Management](https://langchain-ai.github.io/langgraph/concepts/low_level/#state)
-- [GNS3-Copilot AI Chat API Design](../ai-chat-api-design.md)
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/runtime-agent-params.md b/docs/gns3-copilot/todo/runtime-agent-params.md
deleted file mode 100644
index d2aa06953..000000000
--- a/docs/gns3-copilot/todo/runtime-agent-params.md
+++ /dev/null
@@ -1,521 +0,0 @@
-# Runtime Agent Parameters
-
-## Overview
-
-This document describes the design and implementation plan for adding runtime control parameters to the GNS3-Copilot agent. Currently, iteration limits and tool call constraints are hardcoded. This enhancement will allow users to pass temporary parameters at request time to control agent behavior.
-
-## Problem Statement
-
-### Current Limitations
-
-1. **Hard-coded iteration limit**: The maximum number of LLM-tool iterations is fixed at 20 in `agent_service.py`
-2. **No tool call limit**: There's no runtime control over the maximum number of tool calls per request
-3. **Inflexible for complex tasks**: Long-running automation tasks may require more iterations than the default
-4. **No cost control**: Users cannot limit the number of expensive tool calls (e.g., device configuration operations)
-
-### User Impact
-
-```
-Scenario: User wants to configure OSPF on 10 routers
-- Each router requires ~2-3 tool calls (check config, apply config, verify)
-- Total: ~20-30 tool calls needed
-- Current: No way to predict or control this
-- Desired: User can set max_tool_calls=30 to ensure completion
-```
-
-## Current Architecture
-
-### Parameter Flow
-
-```
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 1. API Layer (chat.py) │
-│ POST /v3/projects/{project_id}/chat/stream │
-│ ChatRequest { message, session_id, temperature?, mode } │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 2. Agent Service (agent_service.py) │
-│ stream_chat(message, session_id, project_id, user_id, jwt, mode) │
-│ → inputs = { │
-│ "messages": [HumanMessage(...)], │
-│ "llm_calls": 0, │
-│ "remaining_steps": 20, ← HARDCODED │
-│ "mode": mode │
-│ } │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 3. Agent Graph (gns3_copilot.py) │
-│ recursion_limit_continue(state): │
-│ if state["remaining_steps"] < 4: return END │
-│ │
-│ tool_node(state): │
-│ → Execute tools without limit check │
-└─────────────────────────────────────────────────────────────────────────┘
-```
-
-### Existing Controls
-
-| Parameter | Location | Value | Description |
-|-----------|----------|-------|-------------|
-| `remaining_steps` | `agent_service.py:281` | 20 (hardcoded) | Total iteration count |
-| Recursion threshold | `gns3_copilot.py` | `< 4` | Stop when remaining < 4 |
-| `temperature` | `chat.py` | Reserved but not implemented | Runtime temperature override |
-
-## Proposed Solution
-
-### Option A: Simple Extension (Recommended)
-
-**Scope**: API Schema + Agent Service modifications only
-
-#### 1. API Schema Changes
-
-**File**: `gns3server/schemas/controller/chat.py`
-
-```python
-class ChatRequest(BaseModel):
- """Chat request model."""
-
- message: str = Field(..., description="User message content")
- session_id: Optional[str] = Field(None, description="Session ID")
- stream: bool = Field(default=True, description="Enable streaming response")
- mode: Literal["text"] = Field(default="text", description="Interaction mode")
-
- # New runtime control parameters
- max_iterations: Optional[int] = Field(
- None,
- ge=1,
- le=100,
- description="Maximum number of LLM-tool iterations (default: 20). "
- "Each iteration = LLM call + optional tool execution."
- )
- max_tool_calls: Optional[int] = Field(
- None,
- ge=1,
- le=50,
- description="Maximum number of tool calls per request (default: unlimited). "
- "Useful for cost control and preventing runaway automation."
- )
-```
-
-#### 2. Agent Service Changes
-
-**File**: `gns3server/agent/gns3_copilot/agent_service.py`
-
-```python
-async def stream_chat(
- self,
- message: str,
- session_id: str,
- project_id: Optional[str] = None,
- user_id: Optional[str] = None,
- jwt_token: Optional[str] = None,
- mode: str = "text",
- llm_config: Optional[Dict[str, Any]] = None,
- # New parameters
- max_iterations: Optional[int] = None,
- max_tool_calls: Optional[int] = None,
-) -> AsyncGenerator[Dict[str, Any], None]:
- """
- Stream chat responses from the agent.
-
- Args:
- message: User message
- session_id: Session/thread ID for conversation continuity
- project_id: GNS3 project ID (optional, for context)
- user_id: User ID for metadata tracking
- jwt_token: JWT token for API authentication (optional)
- mode: Interaction mode (default: "text")
- llm_config: LLM configuration dict (provider, model, api_key, etc.)
- max_iterations: Maximum LLM-tool iterations (default: 20)
- max_tool_calls: Maximum tool calls per request (default: unlimited)
-
- Yields:
- Dict containing SSE-compatible response chunks
- """
- log.info(
- "Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s, "
- "max_iterations=%s, max_tool_calls=%s",
- project_id,
- user_id,
- session_id,
- mode,
- max_iterations,
- max_tool_calls,
- )
-
- # ... existing session setup code ...
-
- # Build inputs with runtime parameters
- inputs = {
- "messages": [HumanMessage(content=message, id=str(uuid4()))],
- "llm_calls": 0,
- "remaining_steps": max_iterations or 20, # Use runtime parameter or default
- "max_tool_calls": max_tool_calls or 999, # New: tool call limit
- "tool_calls_count": 0, # New: counter
- "mode": mode,
- }
-
- # ... rest of existing code ...
-```
-
-#### 3. Agent Graph Changes
-
-**File**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py`
-
-```python
-def tool_node(state: dict, config: RunnableConfig | None = None):
- """
- Performs the tool call with max_tool_calls limit.
-
- Args:
- state: Current agent state containing messages and tool_calls
- config: Runnable configuration (optional)
-
- Returns:
- Dict with tool execution results or error message if limit exceeded
- """
- tool_calls = state["messages"][-1].tool_calls
- result = []
-
- # Check tool call limit
- max_tool_calls = state.get("max_tool_calls", 999)
- current_tool_calls = state.get("tool_calls_count", 0)
-
- if current_tool_calls + len(tool_calls) > max_tool_calls:
- log.warning(
- "Tool call limit exceeded: current=%d, requested=%d, max=%d",
- current_tool_calls,
- len(tool_calls),
- max_tool_calls
- )
- # Return error message for each tool call
- for tool_call in tool_calls:
- result.append(
- ToolMessage(
- content=f"Tool call limit reached ({max_tool_calls} calls). "
- f"Please simplify your request or break it into smaller steps. "
- f"Current tool call count: {current_tool_calls}/{max_tool_calls}.",
- tool_call_id=tool_call["id"],
- name=tool_call["name"]
- )
- )
- return {"messages": result}
-
- # Execute tools normally
- for tool_call in tool_calls:
- tool_name = tool_call["name"]
- tool = tools_by_name[tool_name]
- try:
- observation = tool.invoke(tool_call["args"])
- except Exception as e:
- log.error("Error executing tool %s: %s", tool_name, e)
- observation = f"Error: {str(e)}"
- result.append(
- ToolMessage(
- content=observation,
- tool_call_id=tool_call["id"],
- name=tool_call["name"]
- )
- )
-
- # Update tool call counter
- return {
- "messages": result,
- "tool_calls_count": current_tool_calls + len(tool_calls)
- }
-```
-
-### State Management
-
-The agent state needs to track the new fields:
-
-```python
-# Existing MessagesState already has:
-# - messages: Annotated[List[BaseMessage], add_messages]
-# - llm_calls: int
-# - remaining_steps: int (from RemainingSteps)
-
-# We add:
-# - max_tool_calls: int (per-request limit)
-# - tool_calls_count: int (running counter)
-```
-
-## Implementation Plan
-
-| Step | Task | File(s) | Difficulty | Priority |
-|------|------|---------|------------|----------|
-| 1 | Extend `ChatRequest` schema | `schemas/controller/chat.py` | ⭐ Low | P0 |
-| 2 | Modify `stream_chat` signature | `agent_service.py` | ⭐ Low | P0 |
-| 3 | Use `max_iterations` in inputs | `agent_service.py` | ⭐ Low | P0 |
-| 4 | Implement `max_tool_calls` logic | `gns3_copilot.py` | ⭐⭐ Medium | P1 |
-| 5 | Add tool call counter to state | `gns3_copilot.py` | ⭐ Low | P1 |
-| 6 | Update API documentation | `docs/` | ⭐ Low | P1 |
-| 7 | Add unit tests | `tests/` | ⭐⭐ Medium | P2 |
-
-## Usage Examples
-
-### Basic Usage
-
-```bash
-# Default behavior (no changes needed)
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "配置所有路由器的 OSPF"
- }'
-```
-
-### With Custom Iteration Limit
-
-```bash
-# Allow more iterations for complex multi-device configuration
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "在10台路由器上配置OSPF、BGP和静态路由,然后验证连通性",
- "max_iterations": 50
- }'
-```
-
-### With Tool Call Limit
-
-```bash
-# Limit tool calls for cost control
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "检查所有设备的接口状态",
- "max_tool_calls": 15
- }'
-```
-
-### Combined Parameters
-
-```bash
-# Complex task with both limits
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "配置整个实验室的网络并测试连通性",
- "max_iterations": 40,
- "max_tool_calls": 30
- }'
-```
-
-## Security Considerations
-
-### Parameter Limits
-
-| Parameter | Min | Max | Default | Rationale |
-|-----------|-----|-----|---------|-----------|
-| `max_iterations` | 1 | 100 | 20 | Prevent infinite loops, allow complex tasks |
-| `max_tool_calls` | 1 | 50 | unlimited (999) | Prevent tool abuse, control cost |
-
-### Risk Mitigation
-
-1. **Upper bounds enforced**: Pydantic validation prevents excessive values
-2. **Graceful degradation**: Agent returns informative error messages when limits are reached
-3. **Per-request scope**: Parameters don't persist across sessions
-4. **Audit logging**: All parameters are logged for security analysis
-
-### Edge Cases
-
-```
-Case 1: max_iterations = 1
- → Only one LLM call, no tool execution
- → Useful for simple Q&A without actions
-
-Case 2: max_tool_calls = 1
- → Agent can only call one tool
- → Forces user to break complex tasks into smaller steps
-
-Case 3: LLM ignores limits
- → Agent enforces limits at execution time
- → Returns error when limit exceeded
-```
-
-## Backward Compatibility
-
-✅ **Fully backward compatible**
-
-- All new parameters are `Optional`
-- Default values match current behavior
-- Existing clients continue to work without changes
-- No database migrations required
-
-## Testing Strategy
-
-### Unit Tests
-
-```python
-def test_max_iterations_enforced():
- """Test that agent respects max_iterations parameter"""
- # Create request with max_iterations=5
- # Verify agent stops after 5 iterations
-
-def test_max_tool_calls_enforced():
- """Test that agent respects max_tool_calls parameter"""
- # Create request with max_tool_calls=3
- # Trigger 5 tool calls
- # Verify only 3 execute, rest return error
-
-def test_default_behavior_unchanged():
- """Test that omitting parameters uses defaults"""
- # Create request without new parameters
- # Verify behavior matches current implementation
-```
-
-### Integration Tests
-
-```python
-async def test_complex_multi_device_task():
- """Test complex task with increased limits"""
- # Configure OSPF on 10 routers
- # max_iterations=30, max_tool_calls=25
- # Verify successful completion
-
-async def test_tool_limit_error_message():
- """Test that limit errors are informative"""
- # Set max_tool_calls=2
- # Trigger 3 tool calls
- # Verify third call returns helpful error message
-```
-
-## Future Enhancements
-
-### Phase 2 Features
-
-1. **Per-tool limits**:
- ```python
- max_device_config_calls: Optional[int] = None
- max_diagnostic_calls: Optional[int] = None
- ```
-
-2. **Time-based limits**:
- ```python
- max_execution_time_seconds: Optional[int] = None
- ```
-
-3. **Cost estimation**:
- ```python
- estimate_cost_before_execution: bool = False
- ```
-
-4. **Adaptive limits**:
- ```python
- auto_adjust_limits: bool = False # AI decides optimal limits
- ```
-
-### Advanced Configuration
-
-```python
-class AdvancedAgentControls(BaseModel):
- """Advanced runtime controls for power users"""
-
- # Execution limits
- max_iterations: Optional[int] = None
- max_tool_calls: Optional[int] = None
- max_execution_time_seconds: Optional[int] = None
-
- # Tool-specific limits
- tool_limits: Dict[str, int] = Field(
- default_factory=dict,
- description="Per-tool call limits, e.g., {'execute_multiple_device_commands': 10}"
- )
-
- # Retry behavior
- max_retries_per_tool: int = Field(default=1, ge=0, le=5)
- retry_on_tool_error: bool = Field(default=False)
-
- # Parallel execution
- max_parallel_tools: int = Field(default=5, ge=1, le=20)
-
- # Fallback behavior
- on_limit_reached: Literal["fail", "warn", "continue"] = "warn"
-```
-
-## Related Documentation
-
-- [AI Chat API Design](../ai-chat-api-design.md)
-- [HITL Implementation Plan](./hitl-implementation-plan.md)
-- [Tool Response Format Standard](./tool-response-format-standard.md)
-
-## References
-
-- LangGraph State Management: https://langchain-ai.github.io/langgraph/concepts/low_level/#state
-- Pydantic Field Validation: https://docs.pydantic.dev/latest/concepts/fields/
-- GNS3 Controller API: https://api.gns3.com/
-
-## Discussion Points
-
-### Open Questions
-
-1. **Should limits be per-message or per-session?**
- - Current: Per-message (per request)
- - Alternative: Per-session (accumulate across conversation)
-
-2. **Should we expose `remaining_steps` in the response?**
- - Pro: User knows how many iterations left
- - Con: Exposes internal implementation details
-
-3. **Should we allow dynamic limit adjustment during execution?**
- - Requires streaming parameter updates
- - More complex but more flexible
-
-4. **What about `temperature` override?**
- - Already reserved in schema but not implemented
- - Should we implement it in the same change?
-
-### Decision Required
-
-- [ ] Confirm parameter ranges (min/max values)
-- [ ] Decide on error handling strategy (fail vs warn)
-- [ ] Approve implementation plan
-- [ ] Set target release version
-
----
-
-**Status**: Design Draft - Ready for Review
-
-**Author**: GNS3 Copilot Team
-
-**Last Updated**: 2025-03-06
-
-**Target Version**: TBD
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/runtime-llm-config-override.md b/docs/gns3-copilot/todo/runtime-llm-config-override.md
deleted file mode 100644
index 0d435f8db..000000000
--- a/docs/gns3-copilot/todo/runtime-llm-config-override.md
+++ /dev/null
@@ -1,781 +0,0 @@
-# Runtime LLM Config Override
-
-**Document Status**: Design Phase
-**Priority**: Medium
-**Created**: 2026-03-09
-**Related Docs**:
-- [AI Chat API Design](../ai-chat-api-design.md)
-- [LLM Model Configs API](../llm-model-configs-api.md)
-- [User-Selectable Group Default Config](./user-selectable-group-default-config.md)
-
----
-
-## Table of Contents
-
-- [Problem Description](#problem-description)
-- [Requirements Analysis](#requirements-analysis)
-- [Solution Design](#solution-design)
-- [Implementation Steps](#implementation-steps)
-- [Code Changes Checklist](#code-changes-checklist)
-- [Testing Plan](#testing-plan)
-- [Risk Assessment](#risk-assessment)
-
----
-
-## Problem Description
-
-### Current Behavior
-
-Currently, the Chat API always uses the user's default LLM configuration from the database. Users cannot:
-1. Select a different saved configuration for a specific request
-2. Temporarily override certain parameters (e.g., use a different model, adjust temperature) for a single request
-
-### User Scenarios
-
-**Scenario 1: Quick Model Testing**
-```
-User has multiple configs:
-- "GPT-4o" (default)
-- "Claude 3.5 Sonnet"
-- "Gemini Pro"
-
-User wants to test the same prompt on Claude 3.5 without changing default
-```
-
-**Scenario 2: Temporary Parameter Adjustment**
-```
-User's default config:
-- model: "gpt-4o"
-- temperature: 0.7
-
-User wants to try a more creative response (temperature=1.2) for this request only
-```
-
-**Scenario 3: Cost Optimization**
-```
-User's default config:
-- model: "gpt-4o" (expensive)
-
-User wants to use "gpt-4o-mini" for this simple request
-```
-
-### Current Limitation
-
-**File**: `gns3server/api/routes/controller/chat.py:122`
-
-```python
-# Always gets user's default config
-llm_config = await get_user_llm_config_full(str(user_id), app)
-```
-
-No way to specify alternative config or override parameters at request time.
-
----
-
-## Requirements Analysis
-
-### Functional Requirements
-
-1. **Select Saved Configuration**
- - User can specify `llm_config_id` to use a different saved config
- - Must be a config owned by the user or inherited from their group
- - Validation: If config_id is invalid or inaccessible, return error
-
-2. **Override LLM Parameters**
- - Support temporary override of common LLM parameters:
- - `model`: Model name (e.g., "gpt-4o", "claude-3-5-sonnet-20241022")
- - `temperature`: Sampling temperature (0.0-2.0)
- - `max_tokens`: Maximum tokens to generate
- - `top_p`: Nucleus sampling parameter
- - (Additional provider-specific parameters as needed)
- - Overrides apply only to the current request
- - Original config in database is NOT modified
-
-3. **Parameter Precedence**
- ```
- Request Overrides > Database Config > Provider Defaults
- ```
-
-4. **Backward Compatibility**
- - All new parameters are optional
- - Existing requests without new parameters work unchanged
-
-### Non-Functional Requirements
-
-1. **Security**
- - API key from selected config remains secure
- - Users can only select their accessible configs
- - Overrides are logged for audit
-
-2. **Performance**
- - Minimal overhead for config retrieval and validation
- - No database write for temporary overrides
-
-3. **Maintainability**
- - Clear code structure for override logic
- - Easy to add new overridable parameters in the future
-
----
-
-## Solution Design
-
-### API Schema Changes
-
-**File**: `gns3server/schemas/controller/chat.py`
-
-```python
-class LLMConfigOverride(BaseModel):
- """Temporary LLM configuration overrides for a single request."""
-
- model: Optional[str] = Field(
- None,
- description="Override the model name (e.g., 'gpt-4o', 'claude-3-5-sonnet-20241022'). "
- "Provider and API key still come from the selected or default config."
- )
- temperature: Optional[float] = Field(
- None,
- ge=0.0,
- le=2.0,
- description="Override sampling temperature (0.0-2.0). "
- "Lower values make output more deterministic, higher values more random."
- )
- max_tokens: Optional[int] = Field(
- None,
- ge=1,
- description="Override maximum tokens to generate in the response."
- )
- top_p: Optional[float] = Field(
- None,
- ge=0.0,
- le=1.0,
- description="Override nucleus sampling parameter (0.0-1.0)."
- )
-
-
-class ChatRequest(BaseModel):
- """Chat request model."""
-
- message: str = Field(..., description="User message content")
- session_id: Optional[str] = Field(None, description="Session ID (auto-generated if not provided)")
- stream: bool = Field(default=True, description="Enable streaming response")
-
- # NEW: LLM Configuration Selection
- llm_config_id: Optional[str] = Field(
- None,
- description="LLM configuration ID to use for this request. "
- "Must be a config owned by the user or inherited from their group. "
- "If not provided, uses the user's default LLM config."
- )
-
- # NEW: Runtime Parameter Overrides
- llm_config_override: Optional[LLMConfigOverride] = Field(
- None,
- description="Temporary overrides for LLM parameters. "
- "These overrides apply only to this request and do not modify the stored config. "
- "Overrides take precedence over the selected/default config values."
- )
-
- mode: Literal["text"] = Field(default="text", description="Interaction mode")
-```
-
-### Configuration Resolution Flow
-
-```
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 1. API Layer (chat.py) │
-│ POST /v3/projects/{project_id}/chat/stream │
-│ ChatRequest { │
-│ message, │
-│ llm_config_id?, # Select config │
-│ llm_config_override?: { # Override params │
-│ model?, temperature?, max_tokens?, top_p? │
-│ } │
-│ } │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 2. Config Resolution (get_llm_config_for_request) │
-│ │
-│ if llm_config_id provided: │
-│ → Get specific config by ID │
-│ → Validate: user must have access (own or inherited group) │
-│ → If invalid: return 403 Forbidden │
-│ else: │
-│ → Get user's default config (current behavior) │
-│ │
-│ Decrypt API key from resolved config │
-│ Apply llm_config_override (if provided) │
-│ → Override fields: model, temperature, max_tokens, top_p │
-│ │
-│ Result: { │
-│ provider, api_key, model*, temperature*, max_tokens*, top_p*, ... │
-│ } │
-│ (* = overridden if provided) │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 3. Agent Execution (agent_service.py) │
-│ Set ContextVars with resolved and overridden config │
-│ Proceed with normal Agent flow │
-└─────────────────────────────────────────────────────────────────────────┘
-```
-
-### Implementation Details
-
-#### File: `gns3server/db/tasks.py`
-
-Add new function `get_llm_config_for_request`:
-
-```python
-async def get_llm_config_for_request(
- user_id: str,
- app: FastAPI,
- config_id: Optional[str] = None,
- overrides: Optional[dict] = None
-) -> Optional[dict]:
- """
- Get LLM configuration for a specific request with optional overrides.
-
- Args:
- user_id: User UUID
- app: FastAPI application instance
- config_id: Optional specific config ID to use
- overrides: Optional dict of parameter overrides (model, temperature, etc.)
-
- Returns:
- Dictionary with LLM configuration (with overrides applied) or None if not found.
-
- Raises:
- ValueError: If config_id is specified but not accessible to user
- """
- from uuid import UUID
- from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository
- from gns3server.utils.encryption import decrypt, is_encrypted
-
- try:
- user_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
-
- async with AsyncSession(app.state._db_engine, expire_on_commit=False) as session:
- repo = LLMModelConfigsRepository(session)
-
- # Step 1: Resolve base config
- if config_id:
- # User specified a config - validate access
- config_uuid = UUID(config_id) if isinstance(config_id, str) else config_id
-
- # Get all accessible configs for user
- effective = await repo.get_user_effective_configs(
- user_uuid,
- current_user_id=user_uuid,
- current_user_is_superadmin=False
- )
-
- accessible_config_ids = {c["config_id"] for c in effective["configs"]}
-
- if config_uuid not in accessible_config_ids:
- log.warning(
- f"User {user_id} attempted to use inaccessible config {config_id}"
- )
- raise ValueError(f"Config {config_id} is not accessible to user")
-
- # Get the config (bypass API key hiding since this is system-level)
- config_record = await repo.get_user_config(config_uuid)
- if not config_record:
- log.error(f"Config {config_id} not found in database")
- return None
-
- source = "user_selected"
-
- else:
- # Use user's default config
- result = await repo.get_user_effective_configs(
- user_uuid,
- current_user_id=user_uuid,
- current_user_is_superadmin=False
- )
-
- if not result or not result.get("default_config"):
- log.warning(f"No default LLM configuration found for user {user_id}")
- return None
-
- default_config = result["default_config"]
- config_id_str = default_config["config_id"]
- config_record = await repo.get_user_config(UUID(config_id_str))
-
- if not config_record:
- log.error(f"Default config {config_id_str} not found in database")
- return None
-
- source = "default"
-
- # Step 2: Decrypt API key
- config_data = config_record.config.copy()
- inherited_from_config_id = config_record.inherited_from_config_id
-
- # Handle shadow configs - get API key from parent
- if inherited_from_config_id:
- parent_config = await repo.get_group_config(inherited_from_config_id)
- if parent_config and "api_key" in parent_config.config:
- try:
- encrypted_key = parent_config.config["api_key"]
- if encrypted_key and is_encrypted(encrypted_key):
- config_data["api_key"] = decrypt(encrypted_key)
- else:
- config_data["api_key"] = encrypted_key
- except Exception as e:
- log.error(f"Failed to decrypt inherited API key: {e}")
- return None
- else:
- # Regular config - decrypt directly
- if "api_key" in config_data and config_data["api_key"]:
- try:
- if is_encrypted(config_data["api_key"]):
- config_data["api_key"] = decrypt(config_data["api_key"])
- except Exception as e:
- log.error(f"Failed to decrypt API key: {e}")
- return None
-
- # Step 3: Apply overrides
- if overrides:
- if overrides.get("model"):
- config_data["model"] = overrides["model"]
- log.info(f"Model override applied: {overrides['model']}")
- if overrides.get("temperature") is not None:
- config_data["temperature"] = overrides["temperature"]
- log.info(f"Temperature override applied: {overrides['temperature']}")
- if overrides.get("max_tokens") is not None:
- config_data["max_tokens"] = overrides["max_tokens"]
- log.info(f"Max tokens override applied: {overrides['max_tokens']}")
- if overrides.get("top_p") is not None:
- config_data["top_p"] = overrides["top_p"]
- log.info(f"Top-p override applied: {overrides['top_p']}")
-
- # Step 4: Build final config dict
- llm_config = {
- "config_id": str(config_record.config_id),
- "name": config_record.name,
- "model_type": str(config_record.model_type),
- "source": source,
- "user_id": str(config_record.user_id) if config_record.user_id else None,
- "group_id": str(config_record.group_id) if config_record.group_id else None,
- "inherited_from": str(inherited_from_config_id) if inherited_from_config_id else None,
- **config_data
- }
-
- # Validate required fields
- if not llm_config.get("provider"):
- log.error(f"LLM config missing 'provider' field: {config_record.config_id}")
- return None
-
- if not llm_config.get("model"):
- log.error(f"LLM config missing 'model' field: {config_record.config_id}")
- return None
-
- if not llm_config.get("api_key"):
- log.error(f"LLM config missing 'api_key' field: {config_record.config_id}")
- return None
-
- log.info(
- f"Retrieved LLM config for user {user_id}: "
- f"provider={llm_config.get('provider')}, model={llm_config.get('model')}, "
- f"source={source}, overrides_applied={bool(overrides)}"
- )
-
- return llm_config
-
- except ValueError:
- raise # Re-raise validation errors
- except Exception as e:
- log.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True)
- return None
-```
-
-#### File: `gns3server/api/routes/controller/chat.py`
-
-Modify the stream endpoint to use new function:
-
-```python
-@router.post("/stream", response_model=SkipValidation[ChatResponse])
-async def stream_chat(
- project_id: str,
- request: ChatRequest,
- current_user: schemas.User = Depends(get_current_active_user),
-):
- """Stream chat responses from the GNS3 Copilot Agent."""
-
- # ... existing project validation code ...
-
- # NEW: Resolve LLM config with overrides
- overrides = None
- if request.llm_config_override:
- overrides = request.llm_config_override.model_dump(exclude_none=True)
-
- try:
- llm_config = await get_llm_config_for_request(
- user_id=str(current_user.user_id),
- app=app,
- config_id=request.llm_config_id,
- overrides=overrides
- )
- except ValueError as e:
- raise HTTPException(
- status_code=403,
- detail=str(e)
- )
-
- if not llm_config:
- raise HTTPException(
- status_code=400,
- detail="LLM configuration not found or not accessible. Please configure an LLM model first."
- )
-
- # ... rest of existing code with llm_config ...
-
- # Set ContextVars
- set_current_jwt_token(jwt_token)
- set_current_llm_config(llm_config)
-
- # ... continue with Agent flow ...
-```
-
----
-
-## Implementation Steps
-
-| Step | Task | File(s) | Difficulty | Priority |
-|------|------|---------|------------|----------|
-| 1 | Add `LLMConfigOverride` schema | `schemas/controller/chat.py` | ⭐ Low | P0 |
-| 2 | Add `llm_config_id` and `llm_config_override` to `ChatRequest` | `schemas/controller/chat.py` | ⭐ Low | P0 |
-| 3 | Implement `get_llm_config_for_request` function | `db/tasks.py` | ⭐⭐ Medium | P0 |
-| 4 | Modify `stream_chat` endpoint to use new function | `api/routes/controller/chat.py` | ⭐ Low | P0 |
-| 5 | Update API documentation | `docs/gns3-copilot/ai-chat-api-design.md` | ⭐ Low | P1 |
-| 6 | Add unit tests for config resolution logic | `tests/` | ⭐⭐ Medium | P1 |
-| 7 | Add integration tests for override scenarios | `tests/` | ⭐⭐ Medium | P2 |
-
----
-
-## Usage Examples
-
-### Example 1: Select Different Config
-
-```bash
-# Use a specific saved config instead of default
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "Explain OSPF configuration",
- "llm_config_id": "123e4567-e89b-12d3-a456-426614174000"
- }'
-```
-
-### Example 2: Override Model Only
-
-```bash
-# Use default config but with a different model
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "Configure OSPF on all routers",
- "llm_config_override": {
- "model": "gpt-4o-mini"
- }
- }'
-```
-
-### Example 3: Override Temperature
-
-```bash
-# Use default config but with higher temperature for creativity
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "Write a creative network scenario",
- "llm_config_override": {
- "temperature": 1.2,
- "top_p": 0.95
- }
- }'
-```
-
-### Example 4: Select Config + Override Parameters
-
-```bash
-# Use specific config and override multiple parameters
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "Analyze network topology",
- "llm_config_id": "123e4567-e89b-12d3-a456-426614174000",
- "llm_config_override": {
- "model": "claude-3-5-sonnet-20241022",
- "temperature": 0.3,
- "max_tokens": 4096
- }
- }'
-```
-
-### Example 5: Error Case - Inaccessible Config
-
-```bash
-# Attempting to use another user's config returns 403
-curl -X POST http://localhost:3080/v3/projects/{project_id}/chat/stream \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{
- "message": "Test",
- "llm_config_id": "00000000-0000-0000-0000-000000000000"
- }'
-
-# Response:
-# {
-# "detail": "Config 00000000-0000-0000-0000-000000000000 is not accessible to user"
-# }
-```
-
----
-
-## Code Changes Checklist
-
-### Files to Modify
-
-| File Path | Change Type | Description |
-|-----------|-------------|-------------|
-| `gns3server/schemas/controller/chat.py` | Modify | Add `LLMConfigOverride` model and new fields to `ChatRequest` |
-| `gns3server/db/tasks.py` | Add | Add `get_llm_config_for_request` function |
-| `gns3server/api/routes/controller/chat.py` | Modify | Use `get_llm_config_for_request` with error handling |
-| `docs/gns3-copilot/ai-chat-api-design.md` | Modify | Update API documentation with new parameters |
-
-### New Files
-
-| File Path | Description |
-|-----------|-------------|
-| N/A | No new files (all changes are modifications) |
-
----
-
-## Testing Plan
-
-### Unit Tests
-
-#### 1. Test `get_llm_config_for_request`
-
-- **Test 1.1**: Use default config (no config_id)
- - Input: `config_id=None, overrides=None`
- - Expected: Returns user's default config
-
-- **Test 1.2**: Use specific user config
- - Input: Valid `config_id` owned by user
- - Expected: Returns specified config
-
-- **Test 1.3**: Use inherited group config
- - Input: Valid `config_id` from user's group
- - Expected: Returns specified config with API key from parent
-
-- **Test 1.4**: Use inaccessible config
- - Input: `config_id` from another user
- - Expected: Raises `ValueError`
-
-- **Test 1.5**: Apply model override
- - Input: `overrides={"model": "gpt-4o-mini"}`
- - Expected: Returns config with `model="gpt-4o-mini"`
-
-- **Test 1.6**: Apply temperature override
- - Input: `overrides={"temperature": 1.5}`
- - Expected: Returns config with `temperature=1.5`
-
-- **Test 1.7**: Apply multiple overrides
- - Input: `overrides={"model": "x", "temperature": 0.5, "max_tokens": 1000}`
- - Expected: Returns config with all overrides applied
-
-- **Test 1.8**: Invalid config_id
- - Input: Non-existent `config_id`
- - Expected: Returns `None`
-
-### Integration Tests
-
-#### 1. API Endpoint Tests
-
-- **Test 1.1**: Request without new parameters (backward compatibility)
- - Expected: Works exactly as before
-
-- **Test 1.2**: Request with `llm_config_id` only
- - Expected: Uses specified config
-
-- **Test 1.3**: Request with `llm_config_override` only
- - Expected: Uses default config with overrides
-
-- **Test 1.4**: Request with both `llm_config_id` and `llm_config_override`
- - Expected: Uses specified config with overrides
-
-- **Test 1.5**: Request with inaccessible `llm_config_id`
- - Expected: Returns 403 Forbidden
-
-- **Test 1.6**: Override validation (temperature out of range)
- - Input: `temperature=3.0` (exceeds max 2.0)
- - Expected: Returns 422 Validation Error
-
-#### 2. Agent Integration Tests
-
-- **Test 2.1**: Agent uses overridden config correctly
- - Verify LLM is called with overridden parameters
-
-- **Test 2.2**: Multiple concurrent requests with different configs
- - Verify no cross-contamination between requests
-
----
-
-## Risk Assessment
-
-### Technical Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| Config retrieval performance degradation | Medium | Low | Cache frequently used configs, optimize queries |
-| Override validation bypass | High | Low | Pydantic validation for all override fields |
-| API key leakage in logs | High | Low | Ensure API key is never logged, use [REDACTED] |
-| Incorrect config precedence | Medium | Low | Clear documentation and thorough testing |
-
-### Security Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| User accessing another user's config | High | Low | Validate config accessibility before use |
-| Privilege escalation via config_id | High | Low | Strict access control validation |
-| API key exposure via override | Low | Low | API key cannot be overridden (not in schema) |
-
-### Compatibility Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| Existing clients break | High | Low | All new fields are optional, default behavior unchanged |
-| UI doesn't support new fields | Low | Medium | UI can ignore new fields, phased rollout |
-
----
-
-## Security Considerations
-
-### Access Control
-
-1. **Config Access Validation**
- - User can only specify configs they own or inherited from their group
- - Validation happens before API key decryption
- - 403 Forbidden error for inaccessible configs
-
-2. **Immutable Fields**
- - `api_key` cannot be overridden (not in `LLMConfigOverride`)
- - `provider` cannot be overridden (requires different API key handling)
- - Only safe parameters can be overridden
-
-3. **Audit Logging**
- - Log all override operations with user_id, config_id, and override values
- - Do not log API keys (use [REDACTED] placeholder)
-
-### Parameter Validation
-
-| Parameter | Validation | Rationale |
-|-----------|------------|-----------|
-| `llm_config_id` | Must be valid UUID, accessible to user | Prevent injection attacks |
-| `model` | String, max length 255 | Prevent oversized strings |
-| `temperature` | 0.0 ≤ value ≤ 2.0 | LLM API limits |
-| `max_tokens` | ≥ 1 | Prevent negative/zero values |
-| `top_p` | 0.0 ≤ value ≤ 1.0 | LLM API limits |
-
----
-
-## Future Enhancements
-
-### Phase 2 Features
-
-1. **Additional Override Parameters**
- - `frequency_penalty`: Token frequency penalty
- - `presence_penalty`: Token presence penalty
- - `stop`: Stop sequences
- - Provider-specific parameters (e.g., OpenAI functions)
-
-2. **Config Templates**
- - Predefined override templates (e.g., "creative", "precise", "fast")
- - Users can save and reuse override combinations
-
-3. **Usage Statistics**
- - Track which configs are most commonly used
- - Track which overrides are most commonly applied
- - Provide insights for default config optimization
-
-4. **Config Recommendations**
- - Suggest optimal config based on request content
- - Auto-select cost-effective config for simple queries
-
----
-
-## Related Features
-
-- [User-Selectable Group Default Config](./user-selectable-group-default-config.md) - Setting default config
-- [Runtime Agent Parameters](./runtime-agent-params.md) - Controlling Agent execution behavior
-- [LLM Model Configs API](../llm-model-configs-api.md) - Managing saved configurations
-
----
-
-## References
-
-- [AI Chat API Design](../ai-chat-api-design.md)
-- [Pydantic Field Validation](https://docs.pydantic.dev/latest/concepts/fields/)
-- FastAPI Request Handling: https://fastapi.tiangolo.com/tutorial/body/
-
----
-
-## Discussion Points
-
-### Open Questions
-
-1. **Should we allow `provider` override?**
- - Pro: More flexibility (e.g., switch from OpenAI to Anthropic)
- - Con: Requires different API key handling, more complex
- - **Recommendation**: No - keep it simple for now
-
-2. **Should overrides be visible in response metadata?**
- - Pro: User knows which config/overrides were used
- - Con: Increases response size
- - **Recommendation**: Add to session metadata, not SSE messages
-
-3. **Should we support parameter shortcuts?**
- - Example: `"mode": "fast"` instead of specifying all parameters
- - **Recommendation**: Future enhancement via templates
-
----
-
-**Document Version**: 1.0
-**Last Updated**: 2026-03-09
-**Target Version**: TBD
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
diff --git a/docs/gns3-copilot/todo/sse-interruption-and-agent-cancellation.md b/docs/gns3-copilot/todo/sse-interruption-and-agent-cancellation.md
deleted file mode 100644
index 80e051b83..000000000
--- a/docs/gns3-copilot/todo/sse-interruption-and-agent-cancellation.md
+++ /dev/null
@@ -1,269 +0,0 @@
-# SSE Connection Interruption and Agent Cancellation Design
-
-## Overview
-
-This document describes the behavior when SSE connection is interrupted during agent execution, how statistics are handled, and strategies for graceful agent cancellation.
-
-## Current Behavior Analysis
-
-### What Happens When SSE Connection Drops
-
-| Component | Behavior | Persists After Disconnect |
-|-----------|----------|---------------------------|
-| LangGraph Checkpoint | Auto-saved after each node completes | ✅ Yes |
-| Messages in conversation | Saved to checkpoint | ✅ Yes |
-| Session statistics (message_count, tokens, etc.) | Not updated | ❌ Lost |
-| Auto-generated title | Not synced | ❌ Lost |
-
-### LangGraph Checkpoint Mechanism
-
-LangGraph automatically saves checkpoint after each node completes:
-
-```
-llm_call (AI generates response)
- ↓ checkpoint saved
-should_continue (decides if tools needed)
- ↓ checkpoint saved
-tool_node (executes tools)
- ↓ checkpoint saved
-llm_call (processes tool results)
- ...
-```
-
-**Important**: Checkpoint is saved at node boundaries, not during node execution.
-
-## Statistics Tracking Issue
-
-### Current Implementation
-
-```python
-message_count = 1 # User message
-llm_calls_count = 0
-
-async for event in graph.astream_events(...):
- if event_type == "on_chat_model_start":
- llm_calls_count += 1
- elif event_type == "on_chat_model_end":
- message_count += 1
- elif event_type == "on_tool_end":
- message_count += 1
-```
-
-Statistics are calculated during streaming and only persisted after successful completion:
-
-```python
-try:
- async for event in graph.astream_events(...):
- yield chunk
-except Exception as e:
- yield {"type": "error", ...}
-# Statistics update - only runs on successful completion!
-await repo.update_session(message_count=message_count, ...)
-```
-
-### Problem
-
-When connection drops mid-stream:
-- Statistics are calculated in-memory but never persisted
-- Values may be incomplete/inaccurate (e.g., 2 LLM calls made but only 1 counted)
-
-## Graceful Shutdown Strategy
-
-### Recommended: try/finally Approach
-
-Add `try/finally` to ensure statistics are updated even on disconnection:
-
-```python
-async def stream_chat(...):
- try:
- async for event in graph.astream_events(inputs, config=config, version="v2"):
- try:
- yield chunk # May raise exception on client disconnect
- except Exception:
- log.info("Client disconnected, stopping stream")
- break
- except Exception as e:
- yield {"type": "error", "error": str(e)}
- finally:
- # Always update statistics, even on disconnect
- await repo.update_session(
- thread_id=session_id,
- message_count=message_count,
- llm_calls_count=llm_calls_count,
- ...
- )
-```
-
-### Benefits
-
-- Statistics are recorded even on disconnection
-- Title sync attempt on every request
-- Minimal performance overhead (single DB write)
-
-### Trade-offs
-
-- Statistics may be inaccurate if disconnection happens mid-processing
-- If LLM call fails, partial statistics still recorded
-
-## Agent Cancellation Analysis
-
-### Scenarios and Impact
-
-| Cancellation Timing | State | Issue |
-|---------------------|-------|-------|
-| Before llm_call | User message sent | No response, no issue |
-| After llm_call, has tool_call | AI requested tool execution | ⚠️ Has tool_call, no tool_result |
-| During tool_node | Tool executing | May partially execute |
-| After tool_node | Tool result returned | Clean state |
-
-### Key Concern: Orphan tool_calls
-
-The most dangerous scenario: AI generates `tool_call` but execution hasn't started:
-
-```json
-// Incomplete message:
-{
- "role": "assistant",
- "tool_calls": [{"name": "execute_command", "arguments": "..."}]
- // No corresponding ToolMessage!
-}
-```
-
-### LangGraph Cancellation Handling
-
-LangGraph handles cancellation automatically:
-
-1. **Checkpoint at node boundaries**: Messages are saved after each node completes
-2. **Cancellation preserves state**: When cancelled, checkpoint is saved automatically
-3. **Message consistency**: Either complete (tool_call + ToolMessage) or no tool_call
-
-```python
-# When cancellation happens:
-async def stream_chat(...):
- try:
- async for event in graph.astream_events(...):
- yield chunk
- except CancelledError:
- # LangGraph auto-saves checkpoint before raising
- log.info("Request cancelled, checkpoint saved")
- finally:
- await repo.update_session(...)
-```
-
-### Handling Incomplete Messages
-
-When reconnecting, check for incomplete messages:
-
-```python
-async def get_history(session_id):
- state = await graph.aget_state(config)
- messages = state.values["messages"]
-
- # Check for orphan tool_calls
- last_msg = messages[-1] if messages else None
- if last_msg and last_msg.tool_calls and not has_tool_result(messages):
- # Handle incomplete message
- # Option 1: Show as "interrupted"
- # Option 2: Auto-resume tool execution
- # Option 3: Ask user to retry
-```
-
-## Frontend Integration
-
-### Handling Disconnection
-
-```javascript
-// On connection close:
-window.addEventListener('beforeunload', () => {
- // Connection will close, server will handle cleanup
-});
-
-// On reconnect - fetch history:
-const history = await fetch(`/chat/sessions/${sessionId}/history`);
-const data = await history.json();
-
-// Check for incomplete messages
-if (data.messages.length > 0) {
- const lastMsg = data.messages[data.messages.length - 1];
- if (lastMsg.tool_calls && !lastMsg.content) {
- // Message was interrupted - handle appropriately
- showWarning("Previous response was interrupted");
- }
-}
-```
-
-## Future Enhancements
-
-### Optional: Cancel Endpoint
-
-For explicit cancellation (not just disconnection):
-
-```python
-# Request management
-request_manager = RequestManager()
-
-@router.post("/stream/{request_id}/cancel")
-async def cancel_stream(request_id: str):
- request_manager.cancel(request_id)
-
-# In stream_chat:
-async def stream_chat(request_id: str, ...):
- request_manager.register(request_id)
- try:
- async for event in graph.astream_events(...):
- if request_manager.is_cancelled(request_id):
- break
- yield chunk
- finally:
- request_manager.unregister(request_id)
-```
-
-**Complexity**: Requires request ID tracking, state management, and coordination.
-
-**Current recommendation**: Not necessary - disconnection naturally stops the stream.
-
-## Summary
-
-| Aspect | Current Behavior | Recommended Fix |
-|--------|-----------------|-----------------|
-| Messages | Auto-saved to checkpoint | Already correct |
-| Statistics | Lost on disconnect | Add try/finally |
-| Title sync | Lost on disconnect | Add try/finally |
-| Cancellation | Handled by LangGraph | Already correct |
-| Incomplete messages | Handled on reconnect | Document frontend handling |
-
-## Action Items
-
-1. [ ] Add try/finally to ensure statistics update
-2. [ ] Add client disconnect detection in yield loop
-3. [ ] Document frontend handling for incomplete messages
-4. [ ] Test reconnection scenario with tool_call interruption
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/tool-response-format-standard.md b/docs/gns3-copilot/todo/tool-response-format-standard.md
deleted file mode 100644
index 895544230..000000000
--- a/docs/gns3-copilot/todo/tool-response-format-standard.md
+++ /dev/null
@@ -1,244 +0,0 @@
-# GNS3 Copilot Tool Response Format Standard
-
-## Overview
-
-This document defines the standard response format for GNS3 Copilot tools, ensuring all tools return a unified data structure for easy frontend processing and display.
-
-## Standard Response Format
-
-### Top-level Structure
-
-All tools should return the following standard format:
-
-```python
-{
- "success": bool, # Whether the overall operation succeeded
- "total": int, # Total number of operations
- "successful": int, # Number of successful operations
- "failed": int, # Number of failed operations
- "data": list[dict], # Detailed result list
- "error": str, # Global error message (optional, when operation completely fails)
- "metadata": dict # Metadata (optional)
-}
-```
-
-**Field Descriptions**:
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `success` | `bool` | Yes | Whether the overall operation succeeded (True when `failed == 0`) |
-| `total` | `int` | Yes | Total number of items processed |
-| `successful` | `int` | Yes | Number of successful items |
-| `failed` | `int` | Yes | Number of failed items |
-| `data` | `list[dict]` | Yes | Detailed results for each item |
-| `error` | `str` | No | Global error message (when entire operation fails) |
-| `metadata` | `dict` | No | Metadata (timestamp, execution time, etc.) |
-
-### Single Item Format
-
-Each item in the `data` array should follow this format:
-
-```python
-{
- "id": str, # Device/node/link ID
- "name": str, # Human-readable name
- "status": "success" | "failed", # Item status
- "result": str, # Result or output on success
- "error": str # Error message on failure
-}
-```
-
-**Field Descriptions**:
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `id` | `str` | Yes | Unique identifier for device/node/link |
-| `name` | `str` | Yes | Human-readable name |
-| `status` | `str` | Yes | `"success"` or `"failed"` |
-| `result` | `str` | Conditional | Output when status is `success` |
-| `error` | `str` | Conditional | Error message when status is `failed` |
-
-## Examples
-
-### Success Response Example
-
-```python
-# Execute display commands on multiple devices
-{
- "success": True,
- "total": 3,
- "successful": 2,
- "failed": 1,
- "data": [
- {
- "id": "R1",
- "name": "Router1",
- "status": "success",
- "result": "Cisco IOS Software...\nRouter1# show version\n..."
- },
- {
- "id": "R2",
- "name": "Router2",
- "status": "success",
- "result": "Cisco IOS Software...\nRouter2# show version\n..."
- },
- {
- "id": "R3",
- "name": "Router3",
- "status": "failed",
- "error": "Connection refused"
- }
- ],
- "metadata": {
- "tool_name": "execute_multiple_device_commands",
- "execution_time": 5.2
- }
-}
-```
-
-### Complete Failure Example
-
-```python
-# Entire operation failed (e.g., parameter error)
-{
- "success": False,
- "total": 0,
- "successful": 0,
- "failed": 0,
- "data": [],
- "error": "Invalid project_id format",
- "metadata": {
- "tool_name": "execute_multiple_device_commands"
- }
-}
-```
-
-### Single Device Operation Example
-
-```python
-# Operate on a single device
-{
- "success": True,
- "total": 1,
- "successful": 1,
- "failed": 0,
- "data": [
- {
- "id": "PC1",
- "name": "VPCS-1",
- "status": "success",
- "result": "IP configuration updated: 192.168.1.10/24"
- }
- ],
- "metadata": {}
-}
-```
-
-## Using the Standardization Function
-
-The `normalize_tool_response` function is provided in the `gns3server.agent.gns3_copilot.utils` module to convert various formats to the standard format:
-
-```python
-from gns3server.agent.gns3_copilot.utils import normalize_tool_response
-
-# Normalize tool response
-normalized = normalize_tool_response(raw_response, tool_name="my_tool")
-```
-
-This function supports:
-- List format (`[{...}, {...}]`)
-- Dict format (`{"nodes": [...]}`)
-- String format (automatically parses JSON/Python literal)
-- Mixed format (compatible with legacy tools)
-
-## Compatibility
-
-### Backward Compatibility
-
-The `normalize_tool_response` function is designed to be backward compatible and can handle various formats from existing tools:
-
-- `status` / `error` fields
-- `output` / `result` fields
-- `device_name` / `name` fields
-- `total_nodes` / `total` fields
-
-### Recommended Migration Strategy
-
-1. **New Tools**: Return standard format directly
-2. **Existing Tools**: Keep unchanged, use `normalize_tool_response` to standardize
-3. **Frontend**: Rely on standard format for display processing
-
-## Frontend Integration Recommendations
-
-### Rendering Logic
-
-```javascript
-function renderToolResponse(response) {
- if (!response.success) {
- // Show global error
- showError(response.error);
- return;
- }
-
- // Show statistics summary
- showSummary(response.total, response.successful, response.failed);
-
- // Render each item
- response.data.forEach(item => {
- if (item.status === 'success') {
- showSuccess(item.name, item.result);
- } else {
- showError(item.name, item.error);
- }
- });
-}
-```
-
-### Status Icons
-
-| Status | Icon Suggestion | Color |
-|--------|----------------|-------|
-| `success` | ✓ Green | Green |
-| `failed` | ✗ Red | Red |
-| `unknown` | ? Gray | Gray |
-
-## Version Control
-
-Current standard version: `v1.0`
-
-When the format changes, update the `metadata.version` field, and the frontend adapts accordingly.
-
-## References
-
-- Implementation: `gns3server/agent/gns3_copilot/utils/parse_tool_content.py`
-- Message conversion: `gns3server/agent/gns3_copilot/utils/message_converters.py`
-- Tool examples: `gns3server/agent/gns3_copilot/tools_v2/`
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/tosca-topology-description.md b/docs/gns3-copilot/todo/tosca-topology-description.md
deleted file mode 100644
index 4d86b374f..000000000
--- a/docs/gns3-copilot/todo/tosca-topology-description.md
+++ /dev/null
@@ -1,527 +0,0 @@
-# TOSCA-Based Topology Description for GNS3
-
-## Overview
-
-This document outlines a strategic initiative to adopt **TOSCA (Topology and Orchestration Specification for Cloud Applications)** as the standard format for describing GNS3 network topologies. This approach aims to modernize GNS3 topology management, improve user experience, and align with industry best practices.
-
-## Executive Summary
-
-### Current State
-- GNS3 uses proprietary `.gns3` file format (Python-based)
-- Topology editing requires GUI interface
-- Limited version control capabilities
-- No standard way to share and reuse topologies
-- Difficult to integrate with external automation tools
-
-### Proposed Solution
-- Adopt TOSCA Simple Profile YAML as the standard topology description language
-- Provide dual-mode editing: GUI and YAML
-- Enable template-based topology creation
-- Support version control and collaborative workflows
-- Integrate with TOSCA toolchain ecosystem
-
-### Expected Benefits
-- **Productivity**: 5-10x faster topology creation
-- **Quality**: 80% reduction in configuration errors
-- **Collaboration**: Enable team-based workflows
-- **Portability**: Cross-platform topology definitions
-- **Ecosystem**: Integration with industry-standard tools
-
----
-
-## Background
-
-### What is TOSCA?
-
-**TOSCA** (Topology and Orchestration Specification for Cloud Applications) is an **OASIS standard** for describing cloud application and service topologies. It provides:
-
-- **Standardized YAML syntax** for defining topologies
-- **Type system** for nodes and relationships
-- **Inheritance mechanisms** for template reuse
-- **Workflow definitions** for orchestration
-- **Portability** across platforms and vendors
-
-### Why TOSCA for GNS3?
-
-| Aspect | Current GNS3 | TOSCA-Based |
-|--------|--------------|-------------|
-| Format | Proprietary Python | Open Standard YAML |
-| Readability | Requires code knowledge | Human-readable |
-| Version Control | Binary/blob-like | Git-friendly |
-| Tooling | GNS3-specific | Rich ecosystem |
-| Learning Curve | Steep | Moderate |
-| Industry Alignment | None | Strong |
-
----
-
-## Strategic Benefits
-
-### 1. Standardized YAML Description
-
-#### Human-Readable Topology Definitions
-
-**Before (GNS3 .gns3 file):**
-- Proprietary Python-based format
-- Difficult to understand without GUI
-- Requires specialized tools to edit
-
-**After (TOSCA YAML):**
-- Clear, self-documenting structure
-- Edit with any text editor
-- Instant understanding of network architecture
-
-#### Universal Language
-
-- **Standard syntax**: All users use the same language
-- **Reduced training**: Familiar YAML format for DevOps engineers
-- **Cross-team communication**: Network, DevOps, SRE teams share common language
-
----
-
-### 2. Toolchain Ecosystem
-
-#### Validation Tools
-
-- **Syntax validators**: Catch errors before deployment
-- **Schema validation**: Ensure type correctness
-- **Best practices checkers**: Enforce standards
-- **Integration into CI/CD**: Automated testing pipelines
-
-#### Visualization Tools
-
-- **Auto-generated diagrams**: Visual topology from YAML
-- **Real-time preview**: See changes as you type
-- **Interactive editors**: GUI ↔ YAML bidirectional sync
-
-#### Orchestration Engines
-
-- **Cloudify**: Mature TOSCA orchestrator
-- **OpenStack Heat**: Alternative implementation
-- **Custom tools**: Build on open-source libraries
-
----
-
-### 3. Version Control and Collaboration
-
-### Git-Friendly Workflows
-
-#### Meaningful Diffs
-
-Before:
-```
-Binary files differ
-```
-
-After:
-```diff
-- node: R1
-+ node: Core-Router-1
- type: router
- template: c7200
-```
-
-#### Branch Management
-
-- **Feature branches**: Experiment with new topologies
-- **Isolated development**: Multiple parallel changes
-- **Easy merge**: Text-based merge tools
-
-#### Code Review Process
-
-- **Pull Requests**: Review topology changes
-- **Comments and discussions**: Collaborative refinement
-- **Approval workflows**: Maintained standards
-
-### Real-World Scenarios
-
-**Educational Use Case:**
-- Students submit topology homework via Git PRs
-- Teaching assistants review and comment
-- Automated tests verify requirements
-- Track progress over time
-
-**Enterprise Use Case:**
-- Network engineers propose changes
-- Security team reviews compliance
-- Architecture team validates design
-- Manager approves deployment
-
----
-
-### 4. Template Reuse and Inheritance
-
-### Template Hierarchy
-
-```
-Base Templates
- ↓
-Industry-Specific Templates
- ↓
-Organizational Templates
- ↓
-Project-Specific Topologies
-```
-
-#### Base Templates
-
-- **Standard node types**: Router, Switch, Firewall
-- **Common configurations**: Default settings
-- **Best practices**: Security hardening, performance tuning
-
-#### Domain Templates
-
-- **Enterprise WAN**: Multi-site BGP topology
-- **Data Center**: Spine-leaf fabric
-- **Campus Network**: Hierarchical design
-- **Service Provider**: MPLS backbone
-
-#### Organizational Templates
-
-- **Company standards**: Approved device models
-- **Security policies**: Mandatory configurations
-- **Compliance requirements**: Regulatory constraints
-
-### Efficiency Gains
-
-| Task | Traditional | With Templates |
-|------|-------------|-----------------|
-| Simple topology | 30 minutes | 5 minutes |
-| Complex topology | 2 hours | 30 minutes |
-| Multi-site deployment | Manual | Automated |
-| Consistency check | Manual review | Automated validation |
-
----
-
-### 5. Cross-Platform Portability
-
-### Vendor Neutrality
-
-TOSCA is an **open standard** supported by:
-- Multiple vendors
-- Open-source projects
-- Toolchain ecosystem
-
-### Multi-Environment Deployment
-
-Same topology definition can deploy to:
-- **GNS3**: Local development and testing
-- **EVE-NG**: Remote lab access
-- **Physical devices**: Production deployment
-- **Cloud platforms**: AWS, Azure, GCP
-
-### Integration with Other Tools
-
-#### Configuration Management
-
-- **Ansible**: Use topology for inventory
-- **Terraform**: Infrastructure as code
-- **Python scripts**: Automation workflows
-
-#### Monitoring and Observability
-
-- **Prometheus**: Monitoring targets from topology
-- **ELK Stack**: Log aggregation
-- **Grafana**: Visualization dashboards
-
-#### CI/CD Pipelines
-
-- **Jenkins/GitLab CI**: Automated testing
-- **GitHub Actions**: Workflow automation
-- **ArgoCD**: GitOps deployments
-
----
-
-## Technical Approach
-
-### Architecture Overview
-
-```
-┌─────────────────────────────────────────────────┐
-│ User Interfaces │
-├─────────────────────────────────────────────────┤
-│ GUI Editor │ YAML Editor │ Import/Export │
-└───────────────┴───────────────┴────────────────┘
- │
- ↓
-┌─────────────────────────────────────────────────┐
-│ TOSCA Parser/Validator │
-├─────────────────────────────────────────────────┤
-│ Schema Validation │ Type Checking │ Lint │
-└───────────────┴───────────────┴────────────────┘
- │
- ↓
-┌─────────────────────────────────────────────────┐
-│ GNS3 Core Engine │
-├─────────────────────────────────────────────────┤
-│ Node Management │ Link Management │ APIs │
-└─────────────────────────────────────────────────┘
-```
-
-### Schema Design
-
-#### GNS3 Type System
-
-Extend TOSCA standard types with GNS3-specific nodes:
-
-- **Compute Nodes**: Virtual machines, containers
-- **Network Devices**: Routers, switches, firewalls
-- **Links**: Connections between devices
-- **Configurations**: Device-specific settings
-
-#### Backward Compatibility
-
-- **Dual format support**: Read/write both `.gns3` and `.yaml`
-- **Migration tools**: Convert existing topologies
-- **Gradual adoption**: Users can migrate at their own pace
-
----
-
-## Implementation Plan
-
-### Phase 1: Foundation (3-4 months)
-
-**Goals:**
-- TOSCA parser and validator
-- Basic node type definitions
-- YAML import/export functionality
-- Documentation and tutorials
-
-**Deliverables:**
-- GNS3 TOSCA schema specification
-- YAML parser integration
-- Import/export CLI tools
-- Getting started guide
-
-### Phase 2: GUI Integration (2-3 months)
-
-**Goals:**
-- Bi-directional YAML ↔ GUI editing
-- Real-time validation feedback
-- Visual topology preview
-- Template browser
-
-**Deliverables:**
-- Integrated YAML editor in GNS3 GUI
-- Live validation indicators
-- Template library interface
-- User documentation
-
-### Phase 3: Advanced Features (3-4 months)
-
-**Goals:**
-- Template inheritance and composition
-- Workflow orchestration
-- Testing and validation tools
-- CI/CD integration
-
-**Deliverables:**
-- Template marketplace prototype
-- Automated testing framework
-- Git integration features
-- Best practices guide
-
-### Phase 4: Ecosystem (Ongoing)
-
-**Goals:**
-- Community templates
-- Third-party integrations
-- Advanced tooling
-- Industry partnerships
-
-**Deliverables:**
-- Public template repository
-- Plugin architecture
-- Partner integrations
-- Success stories and case studies
-
----
-
-## Migration Strategy
-
-### For Users
-
-#### Option 1: Gradual Migration
-
-1. Continue using `.gns3` files
-2. Experiment with YAML for new projects
-3. Convert existing topologies as needed
-4. Fully migrate when comfortable
-
-#### Option 2: Dual-Mode Workflow
-
-1. Edit in YAML for complex topologies
-2. Use GUI for visual adjustments
-3. Export both formats as needed
-4. Choose preferred workflow
-
-#### Option 3: Full Adoption
-
-1. Convert all topologies to YAML
-2. Use YAML as primary format
-3. Export to `.gns3` only when required
-4. Leverage full TOSCA ecosystem
-
-### For Developers
-
-#### Extension Points
-
-- **Custom node types**: Define specialized devices
-- **Validation rules**: Enforce organizational standards
-- **Template libraries**: Share within organization
-- **Tooling integration**: Custom automation scripts
-
----
-
-## Success Metrics
-
-### User Adoption
-
-- **3 months**: 10% of users try YAML format
-- **6 months**: 30% use YAML regularly
-- **12 months**: 50% adopt YAML as primary format
-- **24 months**: 70%+ adoption
-
-### Quality Improvements
-
-- **Error rate**: 80% reduction in topology errors
-- **Creation time**: 5-10x faster for complex topologies
-- **Documentation**: 100% of topologies self-documenting
-- **Consistency**: Significant improvement in standards compliance
-
-### Ecosystem Growth
-
-- **Template library**: 100+ community templates
-- **Integrations**: 5+ major tool integrations
-- **Case studies**: 10+ published success stories
-- **Community**: Active contributor base
-
----
-
-## Risk Assessment
-
-### Technical Risks
-
-| Risk | Impact | Mitigation |
-|------|--------|------------|
-| Parser complexity | Medium | Use proven libraries |
-| Performance overhead | Low | Optimized parsing |
-| Schema evolution | Medium | Version management |
-| Backward compatibility | Low | Dual format support |
-
-### Adoption Risks
-
-| Risk | Impact | Mitigation |
-|------|--------|------------|
-| User resistance | Medium | Comprehensive training |
-| Learning curve | Medium | Documentation and examples |
-| Tooling gaps | Low | Leverage existing ecosystem |
-| Vendor lock-in | Low | Open standard |
-
----
-
-## Competitive Analysis
-
-### Similar Approaches
-
-#### Cisco CML (VIRL)
-
-**Strengths:**
-- YAML-based topology definition
-- Mature template system
-- Enterprise features
-
-**Weaknesses:**
-- Proprietary format (not standard TOSCA)
-- Vendor lock-in
-- Limited ecosystem
-
-#### Mininet
-
-**Strengths:**
-- Python API for topology definition
-- SDN research community
-
-**Weaknesses:**
-- Scripting required (no YAML)
-- Limited to Linux networking
-- Not enterprise-ready
-
-#### Our Positioning
-
-**GNS3 with TOSCA:**
-- ✅ Open standard (TOSCA)
-- ✅ Multi-vendor support
-- ✅ Rich ecosystem
-- ✅ Community-driven
-- ✅ Enterprise-ready
-
----
-
-## Conclusion
-
-### Strategic Value
-
-Adopting TOSCA for GNS3 topology description represents a **significant strategic opportunity**:
-
-1. **Modernization**: Align with industry best practices
-2. **Ecosystem**: Tap into TOSCA toolchain and community
-3. **Collaboration**: Enable team-based workflows
-4. **Portability**: Cross-platform topology definitions
-5. **Scalability**: Support enterprise use cases
-
-### Vision
-
-**"Model once, deploy anywhere"** - A GNS3 topology defined in TOSCA can be:
-- Developed locally
-- Tested in simulation
-- Validated automatically
-- Deployed to multiple environments
-- Shared across teams
-- Evolved through version control
-
-### Call to Action
-
-This initiative represents a **fundamental improvement** to how users interact with GNS3. It requires:
-
-- **Community feedback**: Validate requirements and priorities
-- **Contributor participation**: Build open-source solution
-- **Patience**: Phased rollout over 12-18 months
-- **Investment**: Significant development effort
-
-**Expected outcome:** GNS3 becomes the **de facto standard** for network topology definition, education, and automation.
-
----
-
-## References
-
-### Standards and Specifications
-
-- [OASIS TOSCA Specification](https://docs.oasis-open.org/tosca/TOSCA-Simple-Profile-YAML/v1.3)
-- [TOSCA Primer](https://docs.oasis-open.org/tosca/TOSCA-Simple-Profile-YAML/v1.0/cspr02.html)
-- [YANG Data Modeling Language (RFC 7950)](https://datatracker.ietf.org/doc/html/rfc7950)
-- [OpenConfig Network Models](https://openconfig.net/)
-
-### Tools and Resources
-
-- [Cloudify TOSCA Orchestrator](https://cloudify.co)
-- [OpenStack Heat](https://docs.openstack.org/heat/latest/)
-- [TOSCA GmbH](https://github.com/oasis-tcs/tosca-governance)
-- [YangCatalog](https://www.yangcatalog.org/)
-
-### Related Projects
-
-- [GNS3 Server](https://github.com/GNS3/gns3-server)
-- [GNS3 documentation](https://docs.gns3.com/)
-- [Network To Code initiatives](https://www.networktocode.com/)
-
----
-
-**Document Status:** 📋 Design Proposal
-**Category:** Feature Design
-**Priority:** High
-**Complexity:** High
-**Estimated Timeline:** 12-18 months
-
----
-
-*Last Updated: 2025-03-11*
diff --git a/docs/gns3-copilot/todo/user-selectable-group-default-config.md b/docs/gns3-copilot/todo/user-selectable-group-default-config.md
deleted file mode 100644
index ed3f04ae6..000000000
--- a/docs/gns3-copilot/todo/user-selectable-group-default-config.md
+++ /dev/null
@@ -1,959 +0,0 @@
-# User-Selectable Group Default Config
-
-**Document Status**: Design Phase
-**Priority**: Medium
-**Created**: 2026-03-06
-**Related Docs**: [LLM Model Configs API](../llm-model-configs-api.md)
-
----
-
-## Table of Contents
-
-- [Problem Description](#problem-description)
-- [Current State Analysis](#current-state-analysis)
-- [Requirements Analysis](#requirements-analysis)
-- [Solution Design](#solution-design)
-- [Implementation Steps](#implementation-steps)
-- [Code Changes Checklist](#code-changes-checklist)
-- [Testing Plan](#testing-plan)
-- [Risk Assessment](#risk-assessment)
-
----
-
-## Problem Description
-
-### Current Behavior
-
-Regular users cannot select an inherited group LLM model config as their default config, even though they can see the inherited group configs in their config list.
-
-### User Scenario
-
-1. Administrator creates multiple LLM model configs for a user group (e.g., GPT-4, Claude 3.5, Gemini Pro)
-2. The group default is set to GPT-4
-3. Users inherit these configs and can see all group configs in their config list
-4. Users want to use Claude 3.5 as their default, but have no way to set it via API
-
-### Existing Code Limitation
-
-**File**: `gns3server/db/repositories/llm_model_configs.py:194-218`
-
-```python
-async def set_user_default_config(self, user_id: UUID, config_id: UUID) -> bool:
- """Set a user's default LLM model configuration."""
- # ...
-
- # Set new default
- query = update(models.LLMModelConfig).where(
- and_(
- models.LLMModelConfig.config_id == config_id,
- models.LLMModelConfig.user_id == user_id # KEY LIMITATION
- )
- ).values(is_default=True, updated_at=now)
-```
-
-**Problem**: The `user_id == user_id` condition restricts setting only user's own configs. Inherited group configs have `user_id` as `NULL`, so they cannot be set as default.
-
----
-
-## Current State Analysis
-
-### Current Config Retrieval Flow
-
-```
-User requests config list
- ↓
-GET /v3/access/users/{user_id}/llm-model-configs
- ↓
-get_user_effective_configs(user_id)
- ↓
-Returns: {
- configs: [
- { source: "user", ... }, # User's own configs
- { source: "group", ... } # Inherited group configs
- ],
- default_config: { ... } # Current default config
-}
-```
-
-### Default Config Selection Priority
-
-**Current Logic** (`llm_model_configs.py:503-520`):
-
-1. User config marked with `is_default: true`
-2. Group config marked with `is_default: true`
-3. First config in the list (user configs come before group configs)
-
-### Agent Config Retrieval Flow
-
-**Key Discovery**: Agent retrieves config via `user_id`, doesn't care about config source.
-
-**Flow**:
-```
-Agent → get_user_llm_config_full(user_id, app)
- ↓
- get_user_effective_configs(user_id)
- ↓
- Returns default config (auto-decrypts API key)
- ↓
- Agent uses config to call LLM
-```
-
-**Key Files**:
-- `gns3server/db/tasks.py:314-406` - `get_user_llm_config_full`
-- `gns3server/api/routes/controller/chat.py:122` - API entry point
-
-### API Key Visibility Control
-
-| Scenario | User Configs | Group Configs |
-|----------|-------------|---------------|
-| User viewing own configs | **Visible** | **Hidden** (`null`) |
-| Admin viewing other users' configs | **Hidden** | **Hidden** |
-| Agent usage (system-level) | **Visible** | **Visible** (direct DB access) |
-
----
-
-## Requirements Analysis
-
-### Functional Requirements
-
-1. **Users can select group config as default**
- - Users can set any accessible config (own or inherited) as default via API
- - API endpoint remains unchanged: `PUT /v3/access/users/{user_id}/llm-model-configs/default/{config_id}`
-
-2. **Maintain API Key Security**
- - Group config API keys remain hidden when users view config list
- - Agent can access and decrypt group config API keys when using
-
-3. **Backward Compatibility**
- - No impact on existing user configs
- - No impact on Agent calling flow
- - Config list response structure remains consistent
-
-### Non-Functional Requirements
-
-1. **Performance**: No significant query overhead
-2. **Maintainability**: Clear code logic, easy to understand and maintain
-3. **Extensibility**: Future support for config overrides (users modifying certain parameters of inherited configs)
-
----
-
-## Solution Design
-
-### Selection: Shadow Config Approach
-
-Add `inherited_from_config_id` field to `llm_model_configs` table. When user selects a group config as default, create a "shadow config" record.
-
-### Data Model Design
-
-#### Table Structure Modification
-
-**File**: `gns3server/db/models/llm_model_configs.py`
-
-```python
-class LLMModelConfig(BaseTable):
- """LLM model configuration for users and user groups."""
-
- __tablename__ = "llm_model_configs"
-
- config_id = Column(GUID, primary_key=True, default=generate_uuid)
- name = Column(String(100), nullable=False)
- model_type = Column(String(50), nullable=False)
- config = Column(JSON, nullable=False)
- user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=True)
- group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"), nullable=True)
- is_default = Column(Boolean, default=False, nullable=False)
- version = Column(Integer, default=0, nullable=False)
-
- # NEW FIELD: Shadow config references original group config
- inherited_from_config_id = Column(
- GUID,
- ForeignKey("llm_model_configs.config_id", ondelete="CASCADE"),
- nullable=True
- )
-
- # Relationships
- inherited_from = relationship(
- "LLMModelConfig",
- remote_side=[config_id],
- backref="shadow_configs"
- )
-
- # Constraints
- __table_args__ = (
- # Original constraints...
- CheckConstraint(
- "(user_id IS NOT NULL AND group_id IS NULL) OR "
- "(user_id IS NULL AND group_id IS NOT NULL)",
- name="single_owner_check"
- ),
- # NEW CONSTRAINT: Shadow configs must belong to users
- CheckConstraint(
- "inherited_from_config_id IS NULL OR user_id IS NOT NULL",
- name="shadow_config_belong_to_user"
- ),
- # ... other constraints
- )
-```
-
-### Shadow Config Explanation
-
-| Field | Value | Description |
-|------|-------|-------------|
-| `config_id` | New UUID | Shadow config's unique identifier |
-| `name` | Original group config's name | Display name |
-| `model_type` | Original group config's type | Config type |
-| `config` | `{"api_key": "__INHERITED_FROM_GROUP__", ...}` | Config data, API key marked with special value |
-| `user_id` | Current user's ID | Belongs to user |
-| `group_id` | `NULL` | Shadow config doesn't belong to group |
-| `is_default` | `true` | Marked as default config |
-| `inherited_from_config_id` | Original group config's ID | References original config |
-
-### Workflow
-
-#### 1. User Sets Group Config as Default
-
-```
-User Request: PUT /users/{user_id}/llm-model-configs/default/{group_config_id}
- ↓
-set_user_default_config(user_id, group_config_id)
- ↓
-Detects group_config_id is a group config
- ↓
-Creates shadow config:
- - user_id = current user
- - inherited_from_config_id = group_config_id
- - config = original config (API key marked as "__INHERITED_FROM_GROUP__")
- - is_default = true
- ↓
-Deletes old shadow configs and default flags
- ↓
-Commits to database
-```
-
-#### 2. User Views Config List
-
-```
-GET /users/{user_id}/llm-model-configs
- ↓
-get_user_effective_configs(user_id)
- ↓
-Gets user configs (including shadow configs)
- ↓
-For shadow configs:
- - Reads complete data from original group config
- - Hides API key (sets to null)
- - Marks source = "user"
- - Marks inherited_from = original config ID
- ↓
-Returns config list
-```
-
-#### 3. Agent Retrieves Config for Usage
-
-```
-Agent → get_user_llm_config_full(user_id, app)
- ↓
- Gets default config (detects it's a shadow config)
- ↓
- Gets encrypted API key from original group config
- ↓
- Decrypts API key
- ↓
- Returns complete config (including API key)
- ↓
- Agent uses config to call LLM
-```
-
-### Solution Advantages
-
-| Advantage | Description |
-|-----------|-------------|
-| **Data Integrity** | Foreign key constraints ensure referential integrity, cascading deletes handle cleanup |
-| **Backward Compatible** | No modification to existing logic, shadow config is a new feature |
-| **Clear Semantics** | `inherited_from_config_id` clearly indicates inheritance relationship |
-| **Unified API** | Users don't need to care about config source, just select directly |
-| **Extensible** | Shadow config can add override fields in the future (e.g., user-custom parameters) |
-| **No Agent Changes Required** | Agent still retrieves config via `user_id`, automatically compatible |
-
----
-
-## Implementation Steps
-
-### Step 1: Database Migration
-
-Create new migration file: `gns3server/db_migrations/versions/{timestamp}_add_inherited_from_config_id.py`
-
-```python
-"""Add inherited_from_config_id to llm_model_configs table
-
-Revision ID: xxx_add_inherited_from_config_id
-Revises: [previous_revision_id]
-Create Date: 2026-03-06
-
-This migration adds support for shadow configs, allowing users to select
-inherited group configurations as their default.
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-def upgrade():
- # Add the new column
- op.add_column(
- 'llm_model_configs',
- sa.Column(
- 'inherited_from_config_id',
- sa.GUID(),
- nullable=True
- )
- )
-
- # Create foreign key constraint
- op.create_foreign_key(
- 'fk_llm_configs_inherited_from',
- 'llm_model_configs', 'llm_model_configs',
- ['inherited_from_config_id'], ['config_id'],
- ondelete='CASCADE'
- )
-
- # Add check constraint: shadow configs must belong to users
- op.execute("""
- ALTER TABLE llm_model_configs
- ADD CONSTRAINT shadow_config_belong_to_user
- CHECK (inherited_from_config_id IS NULL OR user_id IS NOT NULL)
- """)
-
-
-def downgrade():
- # Remove constraints and column
- op.execute("ALTER TABLE llm_model_configs DROP CONSTRAINT shadow_config_belong_to_user")
- op.drop_constraint('fk_llm_configs_inherited_from', 'llm_model_configs', type_='foreignkey')
- op.drop_column('llm_model_configs', 'inherited_from_config_id')
-```
-
-### Step 2: Modify Data Model
-
-**File**: `gns3server/db/models/llm_model_configs.py`
-
-Add to `LLMModelConfig` class:
-- `inherited_from_config_id` field
-- `inherited_from` relationship
-- `shadow_config_belong_to_user` constraint
-
-### Step 3: Modify Repository Layer
-
-**File**: `gns3server/db/repositories/llm_model_configs.py`
-
-#### 3.1 Modify `set_user_default_config` Method
-
-```python
-async def set_user_default_config(self, user_id: UUID, config_id: UUID) -> bool:
- """
- Set a user's default LLM model configuration.
- Supports setting inherited group configs as default via shadow configs.
-
- Args:
- user_id: User UUID
- config_id: Configuration UUID (can be user's own or inherited group config)
-
- Returns:
- True if successful, False if config not found or not accessible
- """
- from gns3server.utils.encryption import is_encrypted
-
- # Check if config is accessible to user
- effective = await self.get_user_effective_configs(
- user_id,
- current_user_id=user_id
- )
- accessible_config_ids = {c["config_id"] for c in effective["configs"]}
-
- if config_id not in accessible_config_ids:
- return False
-
- # Get the original config
- result = await self._db_session.execute(
- select(models.LLMModelConfig).where(
- models.LLMModelConfig.config_id == config_id
- )
- )
- orig_config = result.scalars().first()
-
- if not orig_config:
- return False
-
- now = datetime.utcnow()
-
- if orig_config.user_id == user_id:
- # Scenario 1: User selects their own config
- # Use the existing is_default mechanism
-
- # Delete old shadow configs
- await self._db_session.execute(
- delete(models.LLMModelConfig)
- .where(
- and_(
- models.LLMModelConfig.user_id == user_id,
- models.LLMModelConfig.inherited_from_config_id.isnot(None)
- )
- )
- )
-
- # Clear all user default flags
- await self._db_session.execute(
- update(models.LLMModelConfig)
- .where(
- and_(
- models.LLMModelConfig.user_id == user_id,
- models.LLMModelConfig.is_default == True
- )
- )
- .values(is_default=False, updated_at=now)
- )
-
- # Set new default
- await self._db_session.execute(
- update(models.LLMModelConfig)
- .where(
- and_(
- models.LLMModelConfig.config_id == config_id,
- models.LLMModelConfig.user_id == user_id
- )
- )
- .values(is_default=True, updated_at=now)
- )
- else:
- # Scenario 2: User selects a group config - create shadow config
-
- # Clear all user default flags
- await self._db_session.execute(
- update(models.LLMModelConfig)
- .where(models.LLMModelConfig.user_id == user_id)
- .values(is_default=False)
- )
-
- # Delete old shadow configs
- await self._db_session.execute(
- delete(models.LLMModelConfig)
- .where(
- and_(
- models.LLMModelConfig.user_id == user_id,
- models.LLMModelConfig.inherited_from_config_id.isnot(None)
- )
- )
- )
-
- # Copy config data, but mark API key as inherited
- shadow_config_data = orig_config.config.copy()
- shadow_config_data["api_key"] = "__INHERITED_FROM_GROUP__"
-
- # Create shadow config
- shadow_config = models.LLMModelConfig(
- name=orig_config.name,
- model_type=orig_config.model_type,
- config=shadow_config_data,
- user_id=user_id,
- group_id=None,
- is_default=True,
- inherited_from_config_id=config_id,
- version=0,
- created_at=now,
- updated_at=now
- )
- self._db_session.add(shadow_config)
-
- await self._db_session.commit()
- return True
-```
-
-#### 3.2 Modify `get_user_effective_configs` Method
-
-Add special logic for shadow config handling:
-
-```python
-# In get_user_effective_configs method
-
-# Process user configs (including shadow configs)
-user_configs = await self.get_user_configs(user_id)
-
-# Build map of group configs for shadow config resolution
-group_configs_map = {}
-group_names_map = {}
-for group in user_groups:
- configs = await self.get_group_configs(group.user_group_id)
- if configs:
- group_configs_map[group.user_group_id] = configs
- group_names_map[group.user_group_id] = group.name
-
-# Flatten group configs for easy access
-all_group_configs = {}
-for configs in group_configs_map.values():
- for config in configs:
- all_group_configs[config.config_id] = config
-
-configs_with_source = []
-
-# Process each user config
-for config in user_configs:
- if config.inherited_from_config_id:
- # This is a shadow config - resolve from parent group config
- parent_config = all_group_configs.get(config.inherited_from_config_id)
- if parent_config:
- config_dict = parent_config.config.copy()
-
- # Hide API key in shadow configs (users viewing their own configs)
- if "api_key" in config_dict:
- config_dict["api_key"] = None
-
- configs_with_source.append({
- "config_id": config.config_id,
- "name": config.name,
- "model_type": config.model_type,
- "config": config_dict,
- "user_id": config.user_id,
- "group_id": None,
- "is_default": config.is_default,
- "version": config.version,
- "created_at": config.created_at,
- "updated_at": config.updated_at,
- "source": "user",
- "inherited_from": config.inherited_from_config_id,
- "group_name": group_names_map.get(parent_config.group_id)
- })
- else:
- # Regular user config - existing logic
- config_dict = config.config.copy()
-
- # API key visibility control
- if "api_key" in config_dict and config_dict["api_key"]:
- if is_viewing_own:
- try:
- if is_encrypted(config_dict["api_key"]):
- config_dict["api_key"] = decrypt(config_dict["api_key"])
- except Exception as e:
- log.warning(f"Failed to decrypt API key: {e}")
- config_dict["api_key"] = None
- else:
- config_dict["api_key"] = None
-
- configs_with_source.append({
- "config_id": config.config_id,
- "name": config.name,
- "model_type": config.model_type,
- "config": config_dict,
- "user_id": config.user_id,
- "group_id": config.group_id,
- "is_default": config.is_default,
- "version": config.version,
- "created_at": config.created_at,
- "updated_at": config.updated_at,
- "source": "user",
- "inherited_from": None,
- "group_name": None
- })
-
-# Add inherited group configs (exclude those already shadowed)
-shadow_inherited_ids = {
- c["inherited_from"]
- for c in configs_with_source
- if c["inherited_from"]
-}
-
-for group_id, configs in group_configs_map.items():
- for config in configs:
- if config.config_id in shadow_inherited_ids:
- continue # Already shadowed, don't duplicate
-
- config_dict = config.config.copy()
- if "api_key" in config_dict:
- config_dict["api_key"] = None
-
- configs_with_source.append({
- "config_id": config.config_id,
- "name": config.name,
- "model_type": config.model_type,
- "config": config_dict,
- "user_id": None,
- "group_id": config.group_id,
- "is_default": config.is_default,
- "version": config.version,
- "created_at": config.created_at,
- "updated_at": config.updated_at,
- "source": "group",
- "inherited_from": None,
- "group_name": group_names_map[group_id]
- })
-
-# Select default_config (shadow configs have priority since marked is_default=true)
-default_config = None
-for config in configs_with_source:
- if config["is_default"] and config["source"] == "user":
- default_config = config
- break
-
-if default_config is None:
- for config in configs_with_source:
- if config["is_default"] and config["source"] == "group":
- default_config = config
- break
-
-if default_config is None and configs_with_source:
- default_config = configs_with_source[0]
-
-return {
- "configs": configs_with_source,
- "default_config": default_config
-}
-```
-
-### Step 4: Modify System-Level Config Retrieval
-
-**File**: `gns3server/db/tasks.py`
-
-Modify `get_user_llm_config_full` function to add shadow config API key decryption logic:
-
-```python
-async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict]:
- """
- Get user's full LLM configuration with decrypted API key for Copilot.
-
- This is a system-level function that bypasses API security restrictions.
- It retrieves the complete configuration including decrypted API keys,
- even for inherited group configurations and shadow configs.
-
- Args:
- user_id: User UUID
- app: FastAPI application instance
-
- Returns:
- Dictionary with LLM configuration (provider, model, api_key, etc.)
- or None if not found.
- """
- from uuid import UUID
- from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository
- from gns3server.utils.encryption import decrypt, is_encrypted
-
- try:
- user_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
-
- async with AsyncSession(app.state._db_engine, expire_on_commit=False) as session:
- repo = LLMModelConfigsRepository(session)
-
- # Get effective configs (own + inherited from groups)
- result = await repo.get_user_effective_configs(
- user_uuid,
- current_user_id=user_uuid,
- current_user_is_superadmin=False
- )
-
- if not result or not result.get("default_config"):
- log.warning(f"No default LLM configuration found for user {user_id}")
- return None
-
- default_config = result["default_config"]
- config_id = default_config["config_id"]
- source = default_config["source"]
- inherited_from = default_config.get("inherited_from")
-
- # Get full config from database
- full_config = await repo.get_user_config(config_id)
-
- if not full_config:
- log.error(f"Failed to retrieve full config: config_id={config_id}")
- return None
-
- # Decrypt API key
- config_data = full_config.config.copy()
- inherited_from_config_id = full_config.inherited_from_config_id
-
- # If shadow config, get API key from parent group config
- if inherited_from_config_id:
- parent_config = await repo.get_group_config(inherited_from_config_id)
- if parent_config and "api_key" in parent_config.config:
- try:
- encrypted_key = parent_config.config["api_key"]
- if encrypted_key and is_encrypted(encrypted_key):
- config_data["api_key"] = decrypt(encrypted_key)
- log.debug(f"Decrypted API key from inherited group config for user {user_id}")
- else:
- config_data["api_key"] = encrypted_key
- except Exception as e:
- log.error(f"Failed to decrypt inherited API key: {e}")
- config_data["api_key"] = None
- else:
- log.error(f"Parent group config not found for shadow config: {inherited_from_config_id}")
- config_data["api_key"] = None
- else:
- # Regular user config - decrypt API key directly
- if "api_key" in config_data and config_data["api_key"]:
- try:
- if is_encrypted(config_data["api_key"]):
- config_data["api_key"] = decrypt(config_data["api_key"])
- log.debug(f"Successfully decrypted API key for user {user_id}")
- except Exception as e:
- log.error(f"Failed to decrypt API key: {e}")
- config_data["api_key"] = None
-
- # Build configuration dict
- llm_config = {
- "config_id": str(full_config.config_id),
- "name": full_config.name,
- "model_type": str(full_config.model_type),
- "source": source,
- "inherited_from": str(inherited_from_config_id) if inherited_from_config_id else None,
- "group_name": default_config.get("group_name"),
- "user_id": str(full_config.user_id) if full_config.user_id else None,
- "group_id": str(full_config.group_id) if full_config.group_id else None,
- **config_data
- }
-
- # Validate required fields
- if not llm_config.get("provider"):
- log.error(f"LLM config missing 'provider' field: {config_id}")
- return None
-
- if not llm_config.get("model"):
- log.error(f"LLM config missing 'model' field: {config_id}")
- return None
-
- log.info(
- f"Retrieved LLM config for user {user_id}: "
- f"provider={llm_config.get('provider')}, model={llm_config.get('model')}, "
- f"source={source}, inherited_from={inherited_from_config_id}"
- )
-
- return llm_config
-
- except Exception as e:
- log.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True)
- return None
-```
-
-### Step 5: Update Schema (Optional)
-
-If you want to display `inherited_from` field in API response, update relevant Schema:
-
-**File**: `gns3server/schemas/controller/chat.py` or corresponding schema file
-
-```python
-class LLMModelConfigWithSource(BaseModel):
- """LLM model configuration with source information."""
- config_id: UUID
- name: str
- model_type: str
- config: Dict[str, Any]
- user_id: Optional[UUID] = None
- group_id: Optional[UUID] = None
- is_default: bool
- version: int
- created_at: datetime
- updated_at: datetime
- source: str # "user" or "group"
- group_name: Optional[str] = None
- inherited_from: Optional[UUID] = None # NEW FIELD
-```
-
-### Step 6: Update API Documentation
-
-**File**: `docs/gns3-copilot/llm-model-configs-api.md`
-
-Add `inherited_from` field description in response schema section:
-
-```markdown
-### LLMModelConfigWithSource
-
-| Field | Type | Description |
-|-------|------|-------------|
-| ...
-| `inherited_from` | UUID (nullable) | For shadow configs, the ID of the inherited group config |
-```
-
----
-
-## Code Changes Checklist
-
-### Files to Modify
-
-| File Path | Change Type | Description |
-|-----------|-------------|-------------|
-| `gns3server/db/models/llm_model_configs.py` | Modify | Add `inherited_from_config_id` field and relationship |
-| `gns3server/db/repositories/llm_model_configs.py` | Modify | Modify `set_user_default_config` and `get_user_effective_configs` |
-| `gns3server/db/tasks.py` | Modify | Modify `get_user_llm_config_full` to support shadow configs |
-| `gns3server/schemas/...` | Modify (Optional) | Add `inherited_from` field to Schema |
-| `gns3server/db_migrations/versions/...` | New | Database migration file |
-| `docs/gns3-copilot/llm-model-configs-api.md` | Modify | Update API documentation |
-
-### New Files
-
-| File Path | Description |
-|-----------|-------------|
-| `gns3server/db_migrations/versions/{timestamp}_add_inherited_from_config_id.py` | Database migration |
-
----
-
-## Testing Plan
-
-### Unit Tests
-
-#### 1. Test `set_user_default_config`
-
-- **Test 1.1**: User sets their own config as default
- - Input: User's config ID
- - Expected: `is_default=true`, old shadow configs deleted
-
-- **Test 1.2**: User sets group config as default
- - Input: Group config ID
- - Expected: Shadow config created, `inherited_from_config_id` points to group config
-
-- **Test 1.3**: User switches default config (from own to group config)
- - Input: Group config ID
- - Expected: Old shadow config deleted, new shadow config created
-
-- **Test 1.4**: User sets non-existent config as default
- - Input: Invalid config ID
- - Expected: Returns `False`
-
-- **Test 1.5**: User sets inaccessible config as default
- - Input: Other user's group config ID
- - Expected: Returns `False`
-
-#### 2. Test `get_user_effective_configs`
-
-- **Test 2.1**: User with only own configs
- - Expected: Returns user configs, no `inherited_from` field
-
-- **Test 2.2**: User with inherited group configs, no default set
- - Expected: Returns user configs + group configs, `default_config` is first user config or first group config
-
-- **Test 2.3**: User set group config as default (shadow config)
- - Expected: Shadow config `source="user"`, `is_default=true`, `inherited_from` points to group config, API key is `null`
-
-- **Test 2.4**: User viewing own configs (API key visibility)
- - Expected: Own config shows API key, shadow config and group config hide API key
-
-#### 3. Test `get_user_llm_config_full`
-
-- **Test 3.1**: User using own default config
- - Expected: Returns config with decrypted API key
-
-- **Test 3.2**: User using shadow config (group config)
- - Expected: Retrieves and decrypts API key from original group config
-
-- **Test 3.3**: Shadow config's original group config deleted
- - Expected: Returns `None` or appropriate error handling
-
-### Integration Tests
-
-#### 1. API Endpoint Tests
-
-- **Test 1.1**: `PUT /users/{user_id}/llm-model-configs/default/{group_config_id}`
- - Request: Set group config as default
- - Expected: Returns 200, config set as default
-
-- **Test 1.2**: `GET /users/{user_id}/llm-model-configs`
- - Expected: Shadow config appears in list, `source="user"`, `inherited_from` field exists
-
-- **Test 1.3**: `GET /users/{user_id}/llm-model-configs/default`
- - Expected: Returns shadow config
-
-#### 2. Agent Integration Tests
-
-- **Test 2.1**: User using shadow config calls Agent
- - Expected: Agent successfully retrieves config and calls LLM
-
-- **Test 2.2**: Multiple users using same group config as default
- - Expected: Each user has their own shadow config, no interference
-
-### Security Tests
-
-- **Test 1**: User views config list, shadow config's API key is hidden
-- **Test 2**: Admin views other user's config, API key is hidden
-- **Test 3**: User cannot set other user's config as default
-- **Test 4**: Cascading delete: Group config deleted, shadow config auto-deleted
-
----
-
-## Risk Assessment
-
-### Technical Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| Database migration failure | High | Low | Thoroughly test migration script, prepare rollback plan |
-| Shadow config out of sync with original config | Medium | Medium | Shadow config dynamically reads from original config, real-time sync |
-| API key decryption failure | High | Low | Add error handling and logging |
-| Performance impact | Low | Low | Limited number of shadow configs, negligible performance impact |
-
-### Business Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| User confusion (shadow config vs own config) | Medium | Medium | Clearly indicate inheritance source in UI |
-| Users unaware of group config updates | Low | Low | Document behavior, or add config version notification in the future |
-
-### Compatibility Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| Existing API clients incompatible with `inherited_from` field | Low | Low | Field is optional, old clients can ignore it |
-| Agent doesn't support shadow config | High | Low | Agent retrieves config via `user_id`, automatically compatible |
-
----
-
-## Future Enhancements
-
-### Potential Future Features
-
-1. **Config Overrides**: Allow users to override certain parameters in shadow config (e.g., `temperature`)
-2. **Change Notifications**: Notify users when group config is updated
-3. **Config Version Tracking**: Record change history of configs
-4. **Config Recommendations**: Recommend default configs based on usage patterns
-
-### Related Features
-
-- Support user config templates (create own config based on group config)
-- Config import/export functionality
-- Batch config management
-
----
-
-## References
-
-- [LLM Model Configs API](../llm-model-configs-api.md)
-- [AI Chat API Design](../ai-chat-api-design.md)
-- SQLAlchemy Foreign Key: https://docs.sqlalchemy.org/en/14/core/metadata.html
-- Alembic Migrations: https://alembic.sqlalchemy.org/en/latest/tutorial.html
-
----
-
-**Document Version**: 1.0
-**Last Updated**: 2026-03-06
-
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
-
diff --git a/docs/gns3-copilot/todo/vision-topology-creation.md b/docs/gns3-copilot/todo/vision-topology-creation.md
deleted file mode 100644
index feaaf6ef8..000000000
--- a/docs/gns3-copilot/todo/vision-topology-creation.md
+++ /dev/null
@@ -1,1009 +0,0 @@
-# Vision-Based Topology Creation
-
-**Document Status**: Design Phase
-**Priority**: High
-**Created**: 2026-03-09
-**Related Docs**:
-- [AI Chat API Design](../ai-chat-api-design.md)
-- [GNS3 Templates API](../../compute/templates_api.md)
-- [FlowNet-Lab Vision Recognition](../../../../FlowNet-Lab/backend/api/v1/vision.py)
-
----
-
-## Table of Contents
-
-- [Overview](#overview)
-- [Problem Description](#problem-description)
-- [Requirements Analysis](#requirements-analysis)
-- [Solution Design](#solution-design)
-- [Implementation Steps](#implementation-steps)
-- [Code Changes Checklist](#code-changes-checklist)
-- [Testing Plan](#testing-plan)
-- [Risk Assessment](#risk-assessment)
-
----
-
-## Overview
-
-This feature enables users to create GNS3 topologies by uploading network topology images. The system uses vision language models (VLM) to analyze the image and generate structured topology data, then uses LLM to map the recognized devices to GNS3 templates and create the topology automatically.
-
-### Workflow
-
-```
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 1. User uploads topology image (base64 or file) │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 2. Vision Model Analysis (Qwen-VL / GPT-4V / Claude 3.5 Sonnet) │
-│ - Analyze image structure │
-│ - Identify devices (routers, switches, hosts, etc.) │
-│ - Identify connections between devices │
-│ - Extract interface and IP information (if visible) │
-│ - Return structured JSON topology │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 3. LLM Processing & Template Mapping │
-│ - Get available GNS3 device templates from project │
-│ - Map recognized device types to appropriate GNS3 templates │
-│ - Handle user preferences (specific device models, etc.) │
-│ - Generate deployment plan │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 4. Topology Creation via Agent Tools │
-│ - Create nodes using mapped templates │
-│ - Create links between nodes │
-│ - Configure interfaces (if IP info available) │
-│ - Optional: Auto-start nodes │
-└────────────────────────────┬────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ 5. Return Result │
-│ - Created topology information │
-│ - Node list with template mappings │
-│ - Link list │
-│ - Configuration summary │
-└─────────────────────────────────────────────────────────────────────────┘
-```
-
----
-
-## Problem Description
-
-### Current Limitations
-
-1. **Manual Topology Creation**: Users must manually create nodes and links in GNS3 UI
-2. **Time-Consuming**: Creating complex topologies with many devices is tedious
-3. **Error-Prone**: Manual configuration can lead to mistakes (wrong connections, missing interfaces)
-4. **No Visual Import**: Existing topology diagrams cannot be automatically imported
-
-### User Scenarios
-
-**Scenario 1: Lab Replication**
-```
-User has a network topology diagram from:
-- Textbook or course material
-- Network documentation
-- Exam scenario
-- Online reference
-
-User wants to quickly recreate this topology in GNS3 for practice
-```
-
-**Scenario 2: Migration from Other Tools**
-```
-User has topology designs from:
-- Packet Tracer
-- VIRL
-- Network visualization tools
-- Drawn diagrams (Visio, draw.io)
-
-User wants to import into GNS3
-```
-
-**Scenario 3: Rapid Prototyping**
-```
-Network architect designs topology in visual tool
-Wants to quickly test in GNS3 without manual recreation
-```
-
----
-
-## Requirements Analysis
-
-### Functional Requirements
-
-1. **Image Input Support**
- - Accept base64 encoded image data
- - Support common image formats (PNG, JPG, JPEG, GIF, BMP, WebP)
- - Maximum image size: 10MB (configurable)
-
-2. **Vision Model Support**
- - Support multiple vision language models:
- - Qwen-VL (qwen-vl-max, qwen3-vl-plus, qwen3-vl-flash)
- - OpenAI GPT-4V / GPT-4o
- - Anthropic Claude 3.5 Sonnet (vision capable)
- - User can select which model to use
- - Graceful fallback if model unavailable
-
-3. **Topology Recognition Output**
- Structured JSON format:
- ```json
- {
- "topology_name": "Topology name",
- "description": "Brief description",
- "devices": [
- {
- "id": "unique_id",
- "name": "device_name",
- "type": "router|switch|host|server|cloud|firewall",
- "model": "device_model_if_visible",
- "position": {"x": 0, "y": 0}
- }
- ],
- "links": [
- {
- "id": "unique_id",
- "source_device": "device_name",
- "source_interface": "interface_name",
- "target_device": "device_name",
- "target_interface": "interface_name",
- "link_type": "ethernet|serial"
- }
- ],
- "interfaces": [
- {
- "device": "device_name",
- "interface": "interface_name",
- "ip_address": "ip_address",
- "subnet_mask": "subnet_mask"
- }
- ],
- "summary": {
- "total_devices": 0,
- "total_links": 0,
- "device_types": {"router": 0, "switch": 0, "host": 0, "other": 0}
- }
- }
- ```
-
-4. **Template Mapping**
- - Automatically map recognized device types to GNS3 templates
- - User can override template mappings
- - Support user-specified device model preferences
- - Handle cases where suitable template not found
-
-5. **Topology Creation**
- - Create nodes using mapped templates
- - Create links between nodes
- - Preserve relative device positions (if available)
- - Optional: Auto-start nodes after creation
-
-6. **User Preferences**
- - Specify preferred device models per device type
- - Choose whether to auto-start nodes
- - Configure default link types
-
-### Non-Functional Requirements
-
-1. **Performance**
- - Vision analysis should complete within 30 seconds
- - Topology creation should complete within 60 seconds (for ~20 devices)
-
-2. **Reliability**
- - Handle vision model errors gracefully
- - Validate recognized topology before creation
- - Provide clear error messages
-
-3. **Security**
- - Validate image size and format
- - Sanitize file names
- - Rate limiting to prevent abuse
-
-4. **Extensibility**
- - Easy to add new vision models
- - Support custom device type mappings
- - Pluggable template selection strategy
-
----
-
-## Solution Design
-
-### Architecture
-
-```
-┌─────────────────────────────────────────────────────────────────────────┐
-│ API Layer │
-│ ┌────────────────────────────────────────────────────────────────────┐ │
-│ │ POST /v3/projects/{project_id}/chat/vision-topology │ │
-│ │ - Accepts image (base64 or file reference) │ │
-│ │ - Accepts optional preferences (device models, auto-start, etc.) │ │
-│ └────────────────────────────────────────────────────────────────────┘ │
-└─────────────────────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ Vision Model Layer │
-│ ┌────────────────────────────────────────────────────────────────────┐ │
-│ │ VisionModelFactory │ │
-│ │ - Creates appropriate vision model based on config │ │
-│ │ - Supports: Qwen-VL, OpenAI, Anthropic │ │
-│ └────────────────────────────────────────────────────────────────────┘ │
-│ ┌────────────────────────────────────────────────────────────────────┐ │
-│ │ BaseVisionModel (abstract) │ │
-│ │ - recognize_topology(image_base64, prompt) -> dict │ │
-│ │ │ │
-│ │ QwenVisionModel │ │
-│ │ OpenAIVisionModel │ │
-│ │ AnthropicVisionModel │ │
-│ └────────────────────────────────────────────────────────────────────┘ │
-└─────────────────────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ Agent Layer │
-│ ┌────────────────────────────────────────────────────────────────────┐ │
-│ │ Vision Topology Agent (LangGraph) │ │
-│ │ - Analyzes recognized topology │ │
-│ │ - Gets available GNS3 templates │ │
-│ │ - Maps device types to templates │ │
-│ │ - Generates creation plan │ │
-│ │ - Executes creation via tools │ │
-│ └────────────────────────────────────────────────────────────────────┘ │
-│ ┌────────────────────────────────────────────────────────────────────┐ │
-│ │ Tools: │ │
-│ │ - get_gns3_templates (existing) │ │
-│ │ - create_node (existing) │ │
-│ │ - create_link (existing) │ │
-│ │ - start_node (existing) │ │
-│ │ - map_device_to_template (new) │ │
-│ └────────────────────────────────────────────────────────────────────┘ │
-└─────────────────────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────────┐
-│ GNS3 Controller API │
-│ - Creates nodes in project │
-│ - Creates links between nodes │
-│ - Configures interfaces │
-└─────────────────────────────────────────────────────────────────────────┘
-```
-
-### API Design
-
-#### New Endpoint
-
-**POST** `/v3/projects/{project_id}/vision-topology`
-
-**Request Body:**
-```json
-{
- "image_base64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
- "custom_prompt": "Optional custom prompt for vision model",
- "preferences": {
- "vision_model": "qwen-vl-max",
- "device_models": {
- "router": "c3740",
- "switch": "vEOS",
- "host": "vpcs",
- "server": "docker-alpine"
- },
- "auto_start": false,
- "default_link_type": "ethernet"
- }
-}
-```
-
-**Response:**
-```json
-{
- "recognized_topology": {
- "topology_name": "OSPF Three Router Topology",
- "description": "Three routers connected in triangle",
- "devices": [...],
- "links": [...],
- "interfaces": [...],
- "summary": {...}
- },
- "template_mappings": {
- "R1": {"template_id": "c3740", "template_name": "Cisco 3740"},
- "R2": {"template_id": "c3740", "template_name": "Cisco 3740"},
- "R3": {"template_id": "c3740", "template_name": "Cisco 3740"}
- },
- "created_nodes": [
- {"node_id": "...", "name": "R1", "template_id": "...", "position": {"x": 100, "y": 100}},
- ...
- ],
- "created_links": [
- {"link_id": "...", "source_node": "...", "target_node": "..."},
- ...
- ],
- "configuration_summary": {
- "total_nodes_created": 3,
- "total_links_created": 3,
- "nodes_started": 0
- }
-}
-```
-
-#### Alternative: Integrate with Chat API
-
-**POST** `/v3/projects/{project_id}/chat/vision`
-
-Same endpoint as `/stream`, but accepts `image_base64` field:
-
-```json
-{
- "message": "Create this topology in GNS3",
- "image_base64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
- "session_id": "optional-session-id",
- "preferences": {
- "device_models": {...}
- }
-}
-```
-
-Returns SSE stream with:
-- `vision_start`: Vision analysis started
-- `vision_progress`: Analysis progress updates
-- `vision_result`: Recognized topology data
-- `creation_start`: Topology creation started
-- `node_created`: Individual node creation events
-- `link_created`: Individual link creation events
-- `done`: Creation complete
-
-### Component Design
-
-#### 1. Vision Model Layer
-
-**File**: `gns3server/agent/gns3_copilot/vision/models.py`
-
-```python
-from abc import ABC, abstractmethod
-from typing import Dict, Any, Optional
-
-class BaseVisionModel(ABC):
- """Abstract base class for vision models."""
-
- def __init__(self, api_key: str, model_name: str):
- self.api_key = api_key
- self.model_name = model_name
-
- @abstractmethod
- async def recognize_topology(
- self,
- image_base64: str,
- prompt: Optional[str] = None
- ) -> Dict[str, Any]:
- """
- Recognize network topology from image.
-
- Args:
- image_base64: Base64 encoded image or data URL
- prompt: Optional custom prompt
-
- Returns:
- Dictionary with topology data (devices, links, interfaces, summary)
-
- Raises:
- RuntimeError: If recognition fails
- """
- pass
-
-
-class QwenVisionModel(BaseVisionModel):
- """Qwen-VL vision model implementation."""
-
- async def recognize_topology(
- self,
- image_base64: str,
- prompt: Optional[str] = None
- ) -> Dict[str, Any]:
- # Implementation using DashScope SDK
- # (migrated from FlowNet-Lab)
- pass
-
-
-class OpenAIVisionModel(BaseVisionModel):
- """OpenAI GPT-4V / GPT-4o vision model implementation."""
-
- async def recognize_topology(
- self,
- image_base64: str,
- prompt: Optional[str] = None
- ) -> Dict[str, Any]:
- # Implementation using OpenAI SDK
- pass
-
-
-class AnthropicVisionModel(BaseVisionModel):
- """Anthropic Claude 3.5 Sonnet vision model implementation."""
-
- async def recognize_topology(
- self,
- image_base64: str,
- prompt: Optional[str] = None
- ) -> Dict[str, Any]:
- # Implementation using Anthropic SDK
- pass
-
-
-class VisionModelFactory:
- """Factory for creating vision models."""
-
- @staticmethod
- def create_model(
- provider: str,
- api_key: str,
- model_name: Optional[str] = None
- ) -> BaseVisionModel:
- """
- Create a vision model instance.
-
- Args:
- provider: Model provider (qwen, openai, anthropic)
- api_key: API key for the provider
- model_name: Specific model name (optional, uses default if None)
-
- Returns:
- BaseVisionModel instance
-
- Raises:
- ValueError: If provider is not supported
- """
- models = {
- "qwen": (QwenVisionModel, model_name or "qwen3-vl-plus"),
- "openai": (OpenAIVisionModel, model_name or "gpt-4o"),
- "anthropic": (AnthropicVisionModel, model_name or "claude-3-5-sonnet-20241022"),
- }
-
- if provider not in models:
- raise ValueError(f"Unsupported vision model provider: {provider}")
-
- model_class, default_model = models[provider]
- return model_class(api_key, model_name or default_model)
-```
-
-#### 2. Vision Topology Agent
-
-**File**: `gns3server/agent/gns3_copilot/agent/vision_topology_agent.py`
-
-```python
-from langgraph.graph import StateGraph, END
-from typing import Dict, Any, List
-
-class VisionTopologyAgent:
- """Agent for creating GNS3 topologies from vision recognition results."""
-
- def __init__(self, project_id: str, compute_service):
- self.project_id = project_id
- self.compute_service = compute_service
-
- async def create_topology_from_vision(
- self,
- recognized_topology: Dict[str, Any],
- preferences: Dict[str, Any]
- ) -> Dict[str, Any]:
- """
- Create GNS3 topology from recognized vision data.
-
- Args:
- recognized_topology: Topology data from vision model
- preferences: User preferences (device models, auto-start, etc.)
-
- Returns:
- Creation result with nodes, links, and summary
- """
- # Step 1: Get available templates
- templates = await self._get_available_templates()
-
- # Step 2: Map devices to templates
- device_mappings = await self._map_devices_to_templates(
- recognized_topology["devices"],
- templates,
- preferences.get("device_models", {})
- )
-
- # Step 3: Create nodes
- created_nodes = []
- for device in recognized_topology["devices"]:
- mapping = device_mappings[device["name"]]
- node = await self._create_node(device, mapping)
- created_nodes.append(node)
-
- # Step 4: Create links
- created_links = []
- for link in recognized_topology["links"]:
- link_result = await self._create_link(link, created_nodes)
- created_links.append(link_result)
-
- # Step 5: Optionally start nodes
- if preferences.get("auto_start", False):
- await self._start_nodes(created_nodes)
-
- return {
- "recognized_topology": recognized_topology,
- "template_mappings": device_mappings,
- "created_nodes": created_nodes,
- "created_links": created_links,
- "configuration_summary": {
- "total_nodes_created": len(created_nodes),
- "total_links_created": len(created_links),
- "nodes_started": len(created_nodes) if preferences.get("auto_start") else 0
- }
- }
-
- async def _get_available_templates(self) -> Dict[str, Any]:
- """Get available GNS3 templates for the project."""
- # Use existing GNS3 API
- pass
-
- async def _map_devices_to_templates(
- self,
- devices: List[Dict[str, Any]],
- templates: Dict[str, Any],
- user_preferences: Dict[str, str]
- ) -> Dict[str, Dict[str, str]]:
- """
- Map recognized devices to GNS3 templates.
-
- Args:
- devices: List of recognized devices
- templates: Available GNS3 templates
- user_preferences: User's preferred device models
-
- Returns:
- Mapping of device names to template IDs
- """
- # Use LLM to intelligently map device types to templates
- # Consider user preferences, available templates, device types
- pass
-
- async def _create_node(
- self,
- device: Dict[str, Any],
- template_mapping: Dict[str, str]
- ) -> Dict[str, Any]:
- """Create a GNS3 node from device and template mapping."""
- # Use existing GNS3 create node API
- pass
-
- async def _create_link(
- self,
- link: Dict[str, Any],
- nodes: List[Dict[str, Any]]
- ) -> Dict[str, Any]:
- """Create a GNS3 link between nodes."""
- # Use existing GNS3 create link API
- pass
-
- async def _start_nodes(self, nodes: List[Dict[str, Any]]):
- """Start all nodes."""
- # Use existing GNS3 start node API
- pass
-```
-
-#### 3. API Endpoint
-
-**File**: `gns3server/api/routes/controller/vision.py`
-
-```python
-from fastapi import APIRouter, HTTPException, Depends
-from pydantic import BaseModel
-
-router = APIRouter(tags=["vision"])
-
-class VisionTopologyRequest(BaseModel):
- """Request model for vision-based topology creation."""
- image_base64: str
- custom_prompt: Optional[str] = None
- preferences: Optional[Dict[str, Any]] = None
-
-
-@router.post("/vision-topology")
-async def create_topology_from_vision(
- project_id: str,
- request: VisionTopologyRequest,
- current_user = Depends(get_current_active_user)
-):
- """
- Create GNS3 topology from network topology image.
-
- Accepts a base64 encoded image and automatically creates
- the topology using vision recognition and Agent tools.
- """
- try:
- # Step 1: Initialize vision model
- vision_model = VisionModelFactory.create_model(
- provider=request.preferences.get("vision_model", "qwen"),
- api_key=await _get_vision_api_key(current_user, request.preferences),
- model_name=request.preferences.get("model_name")
- )
-
- # Step 2: Recognize topology
- recognized_topology = await vision_model.recognize_topology(
- image_base64=request.image_base64,
- prompt=request.custom_prompt
- )
-
- # Step 3: Create topology via agent
- agent = VisionTopologyAgent(project_id, compute_service)
- result = await agent.create_topology_from_vision(
- recognized_topology=recognized_topology,
- preferences=request.preferences or {}
- )
-
- return result
-
- except Exception as e:
- logger.error(f"Failed to create topology from vision: {e}")
- raise HTTPException(status_code=500, detail=str(e))
-```
-
-### Template Mapping Strategy
-
-#### Default Device Type Mappings
-
-| Recognized Type | Default GNS3 Template | Alternative Templates |
-|-----------------|----------------------|----------------------|
-| router | c3740 (Cisco 3740) | c7200, vIOS-L2 |
-| switch | vEOS (VeOS) | vIOS-L2, OVS |
-| host / PC | vpcs | docker-alpine |
-| server | docker-alpine | docker-ubuntu |
-| firewall | asav | none |
-| cloud | cloud | none |
-
-#### LLM-Based Template Selection
-
-For more intelligent mapping:
-
-```
-Prompt to LLM:
-"""
-Given the following information:
-
-1. Recognized device: {name}, type: {type}, model: {model_if_visible}
-2. Available GNS3 templates: {list_of_available_templates}
-3. User preferences: {user_preferred_models}
-
-Select the most appropriate template and explain reasoning.
-
-Return JSON:
-{
- "template_id": "...",
- "template_name": "...",
- "reasoning": "Why this template was selected"
-}
-"""
-```
-
-### Detailed Deployment Workflow (Based on FlowNet-Lab)
-
-#### Step-by-Step Process
-
-```
-1. Analyze Vision Result
- ├─ Extract topology name
- ├─ Identify all devices (names, types, models)
- ├─ Identify all connections
- └─ Note any special requirements
-
-2. Check Existing Projects
- └─ Call list_gns3_projects to avoid duplicates
-
-3. Get Available Templates
- └─ Call get_gns3_templates to see what's available
- └─ Returns: [{"name": "...", "template_id": "...", "template_type": "..."}, ...]
-
-4. Map Devices to Templates
- ├─ Use user preferences if specified
- ├─ Use default mappings for recognized types
- └─ Fallback to LLM-assisted selection
-
-5. Create All Nodes (Single Batch Call)
- ├─ Use create_gns3_node with all nodes at once
- ├─ Position nodes in grid layout (min 250px apart)
- └─ Example positions:
- (-400, -200) (-100, -200) (200, -200)
- (-400, 0) (-100, 0) (200, 0)
- (-400, 200) (-100, 200) (200, 200)
-
-6. Read Topology for Port Names
- ├─ Call gns3_topology_reader with project_id
- ├─ Extract actual port names from created nodes
- └─ CRITICAL: Port names vary by template
- └─ Cisco: Ethernet0/0, GigabitEthernet0/0
- └─ VPCS: Ethernet0
- └─ Docker: eth0, eth1
-
-7. Update Node Names
- └─ Call update_gns3_node_name to assign meaningful names (R1, R2, SW1, PC1)
-
-8. Create Links Using Actual Port Names
- ├─ Call create_gns3_link for each connection
- ├─ Use port names from topology reader (Step 6)
- └─ NEVER guess port names - always use topology data
-
-9. Optionally Start Nodes
- └─ Call start_gns3_node_quick to send start commands
-```
-
-#### Critical Deployment Rules
-
-1. **Always get templates first** before creating nodes
-2. **Create all nodes in one call** for efficiency
-3. **Get topology before creating links** to obtain actual port names
-4. **Position nodes properly** - minimum 250 pixels apart
-5. **Use actual port names from topology** when creating links:
- - Port names vary by template type
- - Examples: Ethernet0/0, GigabitEthernet0/0, eth0
- - Never guess - always use topology data
-6. **Match device types to templates correctly**
-
-#### Node Positioning Strategy
-
-Grid layout pattern from FlowNet-Lab:
-
-```python
-def calculate_node_positions(device_count):
- """Calculate grid positions for nodes with minimum 250px spacing."""
- positions = []
- cols = min(4, device_count) # Max 4 columns
- x_offset = 300 # 250px spacing + margin
- y_offset = 200 # Vertical spacing
-
- start_x = -(cols - 1) * x_offset // 2
- start_y = -((device_count + cols - 1) // cols - 1) * y_offset // 2
-
- for i, device in enumerate(devices):
- row = i // cols
- col = i % cols
- x = start_x + col * x_offset
- y = start_y + row * y_offset
- positions.append({"x": x, "y": y})
-
- return positions
-```
-
----
-
-## Implementation Steps
-
-| Phase | Step | Task | File(s) | Difficulty | Priority |
-|-------|------|------|---------|------------|----------|
-| 1 | 1.1 | Create base vision model classes | `agent/gns3_copilot/vision/models.py` | ⭐⭐ Medium | P0 |
-| 1 | 1.2 | Implement QwenVisionModel | `agent/gns3_copilot/vision/models.py` | ⭐⭐ Medium | P0 |
-| 1 | 1.3 | Implement OpenAIVisionModel | `agent/gns3_copilot/vision/models.py` | ⭐⭐ Medium | P1 |
-| 1 | 1.4 | Implement AnthropicVisionModel | `agent/gns3_copilot/vision/models.py` | ⭐⭐ Medium | P1 |
-| 2 | 2.1 | Create VisionTopologyAgent | `agent/gns3_copilot/agent/vision_topology_agent.py` | ⭐⭐⭐ High | P0 |
-| 2 | 2.2 | Implement template mapping logic | `agent/gns3_copilot/agent/vision_topology_agent.py` | ⭐⭐⭐ High | P0 |
-| 2 | 2.3 | Implement node/link creation | `agent/gns3_copilot/agent/vision_topology_agent.py` | ⭐⭐ Medium | P0 |
-| 3 | 3.1 | Create API endpoint | `api/routes/controller/vision.py` | ⭐⭐ Medium | P0 |
-| 3 | 3.2 | Add request/response schemas | `schemas/controller/vision.py` | ⭐ Low | P0 |
-| 3 | 3.3 | Integrate with existing auth | `api/routes/controller/vision.py` | ⭐ Low | P0 |
-| 4 | 4.1 | Add vision API key to LLM config | `db/models/llm_model_configs.py` | ⭐⭐ Medium | P1 |
-| 4 | 4.2 | Update user config UI | (Frontend) | ⭐⭐⭐ High | P2 |
-| 5 | 5.1 | Add unit tests | `tests/test_vision_models.py` | ⭐⭐ Medium | P1 |
-| 5 | 5.2 | Add integration tests | `tests/test_vision_topology.py` | ⭐⭐⭐ High | P2 |
-| 6 | 6.1 | Update API documentation | `docs/gns3-copilot/` | ⭐ Low | P1 |
-
----
-
-## Code Migration from FlowNet-Lab
-
-### Files to Migrate
-
-| FlowNet-Lab File | gns3-server Destination | Modifications Needed |
-|------------------|------------------------|---------------------|
-| `src/gns3_copilot/agent/qwen_vision_model.py` | `agent/gns3_copilot/vision/models.py` | Refactor into class, add async support |
-| `backend/api/v1/vision.py` | `api/routes/controller/vision.py` | Adapt to GNS3 architecture, add Agent integration |
-| `src/gns3_copilot/agent/model_factory.py` | `agent/gns3_copilot/vision/__init__.py` | Update factory pattern |
-
-### Key Modifications
-
-1. **Remove Dependencies**
- - FlowNet-Lab specific config loading
- - Custom logging setup (use gns3server logger)
-
-2. **Add Dependencies**
- - GNS3 Controller API client
- - GNS3 database models
- - Existing Agent tools
-
-3. **Update Configuration**
- - Use gns3server's config system
- - Store vision API keys in user LLM config
-
-4. **Add Error Handling**
- - GNS3-specific error codes
- - Integration with GNS3 project status checks
-
----
-
-## Testing Plan
-
-### Unit Tests
-
-#### 1. Vision Model Tests
-
-- **Test 1.1**: Qwen-VL model initialization
- - Valid API key → success
- - Invalid API key → error
- - Missing API key → error
-
-- **Test 1.2**: Topology recognition
- - Valid topology image → structured JSON
- - Invalid image → graceful error
- - Malformed JSON response → error handling
-
-#### 2. Template Mapping Tests
-
-- **Test 2.1**: Default template mapping
- - Router → c3740
- - Switch → vEOS
- - Host → vpcs
-
-- **Test 2.2**: User preference override
- - User prefers different router model → use preference
-
-- **Test 2.3**: Unknown device type
- - Unknown type → default or error
-
-#### 3. Agent Tests
-
-- **Test 3.1**: Node creation
- - Single device → single node created
- - Multiple devices → multiple nodes created
-
-- **Test 3.2**: Link creation
- - Valid link → link created between nodes
-
-- **Test 3.3**: Full topology
- - 3 routers, 3 links → complete topology created
-
-### Integration Tests
-
-#### 1. End-to-End Tests
-
-- **Test 1.1**: Simple topology
- - 2 routers, 1 link → verify creation
-
-- **Test 1.2**: Complex topology
- - 5+ devices, multiple links → verify creation
-
-- **Test 1.3**: With IP configuration
- - Topology with visible IPs → verify interface config
-
-#### 2. Error Handling Tests
-
-- **Test 2.1**: Invalid image
- - Corrupted base64 → 400 error
-
-- **Test 2.2**: Project not opened
- - Closed project → 403 error
-
-- **Test 2.3**: Vision model failure
- - API error → graceful degradation
-
-### Performance Tests
-
-- **Test 1**: Vision analysis time
- - Should complete within 30 seconds
-
-- **Test 2**: Topology creation time
- - 20 devices should create within 60 seconds
-
----
-
-## Risk Assessment
-
-### Technical Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| Vision model API rate limits | High | Medium | Implement rate limiting, queue system |
-| Poor recognition accuracy | High | Medium | Support multiple vision models, user verification step |
-| Template mapping failures | Medium | Medium | LLM-assisted mapping, user override options |
-| Large image handling | Medium | Low | Size limits, image optimization |
-| Vision API key management | High | Low | Encrypt storage, per-user keys |
-
-### Business Risks
-
-| Risk | Impact | Probability | Mitigation |
-|------|--------|-------------|------------|
-| User expectation mismatch | High | Medium | Clear documentation, example images |
-| Cost of vision APIs | Medium | Medium | Usage tracking, cost warnings |
-| Complex topology failures | Medium | High | Incremental creation, rollback support |
-
----
-
-## Future Enhancements
-
-### Phase 2 Features
-
-1. **Multi-Image Support**
- - Stitch multiple images together
- - Handle multi-page diagrams
-
-2. **Handwriting Recognition**
- - Read handwritten labels and notes
- - Extract configuration commands
-
-3. **Optical Character Recognition (OCR)**
- - Extract IP addresses, subnet masks
- - Read configuration snippets
-
-4. **Interactive Verification**
- - Show recognized topology for user confirmation
- - Allow manual corrections before creation
-
-5. **Template Suggestions**
- - Suggest alternative templates
- - Show compatibility warnings
-
-6. **Configuration Generation**
- - Generate basic device configurations
- - Apply common network protocols
-
-### Phase 3 Features
-
-1. **Topology Comparison**
- - Compare created topology with original image
- - Highlight differences
-
-2. **Auto-Configuration**
- - Configure routing protocols based on topology
- - Set up IP addressing schemes
-
-3. **Learning from User Corrections**
- - Learn from user template preference changes
- - Improve mapping suggestions over time
-
----
-
-## References
-
-- [FlowNet-Lab Vision Recognition](../../../../FlowNet-Lab/backend/api/v1/vision.py)
-- [Qwen-VL Documentation](https://help.aliyun.com/zh/dashscope/developer-reference/vl-plus-api)
-- [OpenAI Vision API](https://platform.openai.com/docs/guides/vision)
-- [Anthropic Claude Vision](https://docs.anthropic.com/claude/docs/vision)
-- [GNS3 Templates API](../../compute/templates_api.md)
-
----
-
-**Document Version**: 1.0
-**Last Updated**: 2026-03-09
-**Target Version**: TBD
-
----
-
-## License
-
-**Copyright © 2025 Yue Guobin (岳国宾)**
-
-This work is licensed under the [Creative Commons Attribution-ShareAlike 4.0
-International License (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
-
-
-
-### Summary
-
-You are free to:
-
-- **Share** — Copy and redistribute the material in any medium or format
-- **Adapt** — Remix, transform, and build upon the material for any purpose
-
-Under the following terms:
-
-- **Attribution** — You must give appropriate credit to **Yue Guobin (岳国宾)**, provide
- a link to the license, and indicate if changes were made.
-- **ShareAlike** — If you remix, transform, or build upon the material, you must
- distribute your contributions under the **same license** (CC BY-SA 4.0).
-
-Full license text: [DESIGN_DOCS_LICENSE](../DESIGN_DOCS_LICENSE.md)
diff --git a/docs/gns3-server/rbac-acl-implementation-guide.md b/docs/gns3-server/rbac-acl-implementation-guide.md
deleted file mode 100644
index 5cfe5abb8..000000000
--- a/docs/gns3-server/rbac-acl-implementation-guide.md
+++ /dev/null
@@ -1,571 +0,0 @@
-# GNS3 RBAC + ACL Permission System Implementation Guide
-
-**Document Version**: 1.0
-**Created**: 2026-03-06
-**Applicable Version**: GNS3 Server v3.0+
-
----
-
-## Table of Contents
-
-- [System Overview](#system-overview)
-- [Core Concepts](#core-concepts)
-- [Data Model](#data-model)
-- [Permission Check Flow](#permission-check-flow)
-- [Usage Examples](#usage-examples)
-- [Best Practices](#best-practices)
-- [Common Issues](#common-issues)
-
----
-
-## System Overview
-
-GNS3 Server implements a **two-tier permission control system** that combines **RBAC** (Role-Based Access Control) and **ACL** (Access Control List) features:
-
-```
-┌─────────────────────────────────────────────────────┐
-│ Tier 1: RBAC (Define Capabilities) │
-│ │
-│ Role → Privilege │
-│ Answers: "What operations can a user perform?" │
-└─────────────────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────────────────┐
-│ Tier 2: ACL (Explicit Authorization) │
-│ │
-│ Default: Deny All │
-│ Unless: ACE Explicitly Allows │
-│ Answers: "On which resources can these operations be used?" │
-└─────────────────────────────────────────────────────┘
-```
-
-### Core Principle
-
-**Default Deny, Explicit Allow**
-
-Like network device ACLs, all access requests are denied by default unless explicitly allowed by an ACE (Access Control Entry).
-
-```
-No ACE → ❌ Access Denied
-ACE with allowed=False → ❌ Access Denied
-ACE with allowed=True → ✅ Access Allowed
-```
-
----
-
-## Core Concepts
-
-### 1. Privilege
-
-Privileges define the operations a user can perform.
-
-```python
-Privilege Naming Format: .
-
-Examples:
-- Project.Audit # View projects
-- Project.Allocate # Create/delete projects
-- Project.Modify # Modify projects
-- Node.Console # Access node console
-- Link.Capture # Capture link traffic
-```
-
-**Predefined Privileges**: 38 built-in privileges (see `gns3server/db/models/privileges.py`)
-
-### 2. Role
-
-Roles are collections of privileges that simplify permission management.
-
-```python
-Built-in Roles:
-- Administrator: All privileges
-- User: Common privileges for projects, nodes, links, snapshots, etc.
-- Auditor: Read-only privileges (*.Audit)
-- Template manager: Template and symbol management
-- User manager: User and group management
-- ACL manager: Role and ACE management
-- No Access: No privileges
-```
-
-### 3. User Group
-
-User groups are used to batch-manage users.
-
-```python
-Built-in Groups:
-- Administrators: Administrator group
-- Users: Regular user group
-```
-
-### 4. ACE (Access Control Entry)
-
-ACEs are the core of access control, defining **on which resources which roles can be used**.
-
-```python
-ACE Structure:
-{
- "path": "/projects", # Resource path
- "user_id": "uuid", # User ID (choose one with group_id)
- "group_id": "uuid", # User group ID (choose one with user_id)
- "role_id": "uuid", # Role ID
- "allowed": true, # Whether to allow (default true)
- "propagate": true, # Whether to propagate to child paths (default true)
- "ace_type": "user" # "user" or "group"
-}
-```
-
-**Important**:
-- `path`: File system-style paths like `/projects`, `/projects/123`
-- `role_id`: The role associated with the ACE, which defines available privileges
-- `allowed`: Explicit allow or deny (default true)
-- `propagate`: Whether permissions are inherited by child paths (default true)
-
----
-
-## Data Model
-
-### Entity Relationships
-
-```
-User ────< UserGroup > (many-to-many via user_group_map)
- │ │
- │ └───< ACE (group_id)
- │
- └───< ACE (user_id)
- │
- ├── path (resource path)
- ├── role → Role → Privilege (privilege)
- ├── allowed (allow/deny)
- └── propagate (whether to propagate)
-```
-
-### Database Tables
-
-| Table | Description | Key Fields |
-|-------|-------------|------------|
-| `users` | Users | `user_id`, `username`, `is_superadmin` |
-| `user_groups` | User groups | `user_group_id`, `name` |
-| `roles` | Roles | `role_id`, `name`, `is_builtin` |
-| `privileges` | Privileges | `privilege_id`, `name` |
-| `acl` (ACE) | Access Control Entries | `ace_id`, `path`, `user_id`, `group_id`, `role_id`, `allowed`, `propagate` |
-| `privilege_role_map` | Role-privilege association | `privilege_id`, `role_id` |
-| `user_group_map` | User-group association | `user_id`, `user_group_id` |
-
----
-
-## Permission Check Flow
-
-### Complete Flowchart
-
-```
-User Request: GET /projects/123, requires Project.Audit privilege
- ↓
-┌─────────────────────────────────────────────────────┐
-│ 1. Extract Request Information │
-│ - User ID │
-│ - Path: /projects/123 │
-│ - Required privilege: Project.Audit │
-└─────────────────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────────────────┐
-│ 2. Special Check: Superadmin │
-│ If is_superadmin = True │
-│ → ✅ Allow directly (bypass RBAC + ACL) │
-└─────────────────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────────────────┐
-│ 3. Query User ACEs │
-│ SELECT * FROM ace │
-│ JOIN privilege_role_map ON ace.role_id = ... │
-│ JOIN privileges ON ... │
-│ WHERE │
-│ ace.user_id = │
-│ AND privileges.name = 'Project.Audit' │
-│ AND ace.path matches /projects/123 │
-└─────────────────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────────────────┐
-│ 4. Check User ACEs │
-│ If matching ACE found: │
-│ - if allowed = False → ❌ Deny │
-│ - if allowed = True → ✅ Allow │
-│ If not found: │
-│ → Continue checking group ACEs │
-└─────────────────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────────────────┐
-│ 5. Query Group ACEs │
-│ Query ACEs for all groups the user belongs to │
-│ (same logic as user ACEs) │
-└─────────────────────────────────────────────────────┘
- ↓
-┌─────────────────────────────────────────────────────┐
-│ 6. Check Group ACEs │
-│ If matching group ACE found: │
-│ - if allowed = False → ❌ Deny │
-│ - if allowed = True → ✅ Allow │
-│ If not found: │
-│ → ❌ Access denied (deny by default) │
-└─────────────────────────────────────────────────────┘
-```
-
-### Path Matching Rules
-
-Path matching follows the **specific-to-general** principle:
-
-```
-Request Path: /projects/123/nodes/456
-
-Check Order:
-1. /projects/123/nodes/456 (most specific)
-2. /projects/123/nodes
-3. /projects/123
-4. /projects
-5. / (most general)
-```
-
-**Impact of propagate Parameter**:
-
-```python
-# ACE 1: path="/projects", propagate=True
-✅ Allow: /projects, /projects/123, /projects/123/nodes
-# Permission propagates to all child paths
-
-# ACE 2: path="/projects", propagate=False
-✅ Allow: /projects
-❌ Deny: /projects/123, /projects/123/nodes
-# Permission does not propagate, only exact match allowed
-```
-
----
-
-## Usage Examples
-
-### Scenario 1: Allow User to Access All Projects
-
-```python
-# Create ACE
-POST /v3/access/aces
-{
- "path": "/projects",
- "user_id": "550e8400-e29b-41d4-a716-446655440000",
- "role_id": "",
- "allowed": true,
- "propagate": true
-}
-
-# Result: User can access all projects (/projects/*)
-```
-
-### Scenario 2: Allow User to Access Only Specific Project
-
-```python
-# Create ACE (exact path)
-POST /v3/access/aces
-{
- "path": "/projects/my-project-id",
- "user_id": "550e8400-e29b-41d4-a716-446655440000",
- "role_id": "",
- "allowed": true,
- "propagate": false
-}
-
-# Result: User can only access /projects/my-project-id
-# Cannot access other projects
-```
-
-### Scenario 3: Use Group Permissions
-
-```python
-# Create ACE for group
-POST /v3/access/aces
-{
- "path": "/projects",
- "group_id": "",
- "role_id": "",
- "allowed": true,
- "propagate": true
-}
-
-# Result: All members of "Users" group can access all projects
-```
-
-### Scenario 4: Explicitly Deny Specific Resource
-
-```python
-# User can access all projects
-ACE: path="/projects", user=A, allowed=true, propagate=true
-
-# But deny access to a specific secret project
-ACE: path="/projects/secret", user=A, allowed=false
-
-# Result: User can access all projects except /projects/secret
-```
-
-### Scenario 5: Use Resource Pools
-
-```python
-# Grant user access to resource pool
-POST /v3/access/aces
-{
- "path": "/pools/pool-123",
- "user_id": "550e8400-e29b-41d4-a716-446655440000",
- "role_id": "",
- "allowed": true,
- "propagate": true
-}
-
-# Result: User can access all resources in pool-123
-```
-
----
-
-## Best Practices
-
-### 1. Use Groups for Permission Management (Recommended)
-
-**Recommended** ✅:
-```python
-# Create ACE for "Users" group
-ACE(path="/projects", group="Users", role="User", allowed=true)
-```
-
-**Not Recommended** ❌:
-```python
-# Create separate ACE for each user
-ACE(path="/projects", user="user1", role="User", allowed=true)
-ACE(path="/projects", user="user2", role="User", allowed=true)
-ACE(path="/projects", user="user3", role="User", allowed=true)
-# ... Repeat for hundreds of users
-```
-
-### 2. Use propagate to Reduce Configuration
-
-**Recommended** ✅:
-```python
-# Use propagate=True
-ACE(path="/projects", group="Users", role="User", allowed=true, propagate=true)
-# One ACE covers all projects and sub-resources
-```
-
-**Not Recommended** ❌:
-```python
-# Create separate ACE for each project
-ACE(path="/projects/1", group="Users", role="User", allowed=true)
-ACE(path="/projects/2", group="Users", role="User", allowed=true)
-ACE(path="/projects/3", group="Users", role="User", allowed=true)
-# ... Difficult to maintain
-```
-
-### 3. Use Default ACEs
-
-**Problem**: Fresh system install has no ACEs by default, users cannot access any resources.
-
-**Solution**: Create default ACEs for default user groups
-
-```python
-# Initialization script
-async def create_default_aces():
- users_group = await get_group_by_name("Users")
- user_role = await get_role_by_name("User")
-
- # Create default ACE for "Users" group
- await create_ace({
- "path": "/",
- "group_id": users_group.id,
- "role_id": user_role.id,
- "allowed": true,
- "propagate": true
- })
-```
-
-### 4. Audit Permission Configuration
-
-Regularly check ACE configuration:
-
-```python
-# Query all ACEs
-GET /v3/access/aces
-
-# Check user's actual permissions
-GET /v3/access/users/me
-# Returns user's groups, accessible pools, ACE list
-```
-
----
-
-## Common Issues
-
-### Q1: Why can't a user access resources even with role privileges?
-
-**A**: This is the most common issue. RBAC defines "what can be done," but ACL limits "where it can be done."
-
-**Checklist**:
-1. Does the user have a matching ACE?
-2. Does the ACE `path` match the request path?
-3. Is the ACE `allowed` set to `true`?
-4. Does the associated `role` have the required privilege?
-
-```bash
-# Check user's ACEs
-curl -X GET http://localhost:3080/v3/access/aces \
- -H "Authorization: Bearer "
-
-# Check user's groups
-curl -X GET http://localhost:3080/v3/access/users/me \
- -H "Authorization: Bearer "
-```
-
-### Q2: What is the purpose of the propagate parameter?
-
-**A**: `propagate` controls whether permissions are inherited by child paths.
-
-- `propagate=true`: Permission propagates to all child paths
-- `propagate=false`: Permission applies only to the exact path
-
-```
-ACE: path="/projects", propagate=true
-→ Allow: /projects, /projects/1, /projects/1/nodes, ...
-
-ACE: path="/projects", propagate=false
-→ Allow: /projects
-→ Deny: /projects/1, /projects/1/nodes, ...
-```
-
-### Q3: What is the priority of user ACEs vs group ACEs?
-
-**A**: User ACEs take priority over group ACEs.
-
-```python
-# User ACE
-ACE(path="/projects", user=A, role=Auditor, allowed=true)
-
-# Group ACE (user's group)
-ACE(path="/projects", group=Users, role=User, allowed=true)
-
-# Result: User ACE takes priority, user uses Auditor role
-```
-
-### Q4: How to deny access to specific resources?
-
-**A**: Create an ACE with `allowed=false`.
-
-```python
-# User can access all projects
-ACE(path="/projects", user=A, role=User, allowed=true, propagate=true)
-
-# But deny access to secret project
-ACE(path="/projects/secret", user=A, role=User, allowed=false)
-```
-
-**Note**: The deny ACE path must be more specific (longer path).
-
-### Q5: Are superadmins subject to RBAC + ACL restrictions?
-
-**A**: No. Users with `is_superadmin=true` bypass all permission checks.
-
-```python
-# Superadmin
-{
- "username": "admin",
- "is_superadmin": true
-}
-
-# No ACE required to access any resource
-```
-
-### Q6: What is the path format for resource pools?
-
-**A**: Resource pools use the `/pools/{pool_id}` format.
-
-```python
-# Grant user access to resource pool
-ACE(path="/pools/pool-123", user=A, role=User, allowed=true)
-
-# Project paths within the pool
-# /pools/pool-123/projects/project-1
-```
-
-**Note**: There is an inconsistency in the code between `/pool` and `/pools`. Recommendation: use `/pools` (plural form).
-
-### Q7: How to debug permission issues?
-
-**A**: Enable debug logging and check the permission check flow.
-
-```python
-# Enable debug logging
-import logging
-logging.getLogger("gns3server.db.repositories.rbac").setLevel(logging.DEBUG)
-
-# View logs
-# DEBUG:gns3server.db.repositories.rbac:Checking user admin has privilege Project.Audit on '/projects/123'
-```
-
----
-
-## API Reference
-
-### Permission Check Related API Endpoints
-
-| Endpoint | Method | Description | Required Privilege |
-|----------|--------|-------------|-------------------|
-| `/v3/access/users/me` | GET | Get current user info (includes groups, pools, ACEs) | None (authenticated user) |
-| `/v3/access/users` | GET | Get all users | User.Audit |
-| `/v3/access/users/{user_id}` | GET | Get specific user | User.Audit |
-| `/v3/access/groups` | GET | Get all groups | Group.Audit |
-| `/v3/access/roles` | GET | Get all roles | Role.Audit |
-| `/v3/access/privileges` | GET | Get all privileges | Role.Audit |
-| `/v3/access/aces` | GET | Get all ACEs | ACE.Audit |
-| `/v3/access/aces` | POST | Create ACE | ACE.Allocate |
-| `/v3/access/aces/{ace_id}` | PUT | Update ACE | ACE.Modify |
-| `/v3/access/aces/{ace_id}` | DELETE | Delete ACE | ACE.Allocate |
-
----
-
-## Code Reference
-
-| Component | File Path |
-|-----------|-----------|
-| Data Models | `gns3server/db/models/` |
-| - Users and Groups | `users.py` |
-| - Roles and Privileges | `roles.py`, `privileges.py` |
-| - ACE | `acl.py` |
-| RBAC Repository | `gns3server/db/repositories/rbac.py` |
-| Permission Check Dependency | `gns3server/api/routes/controller/dependencies/rbac.py` |
-| API Routes | `gns3server/api/routes/controller/` |
-| - User Routes | `users.py` |
-| - RBAC Routes | `roles.py`, `acl.py` |
-| Schemas | `gns3server/schemas/controller/rbac.py` |
-
----
-
-## Summary
-
-GNS3's RBAC + ACL system is a powerful and flexible permission control framework:
-
-### Key Points
-
-1. **Two-Tier Protection**: RBAC defines capabilities, ACL limits scope
-2. **Default Deny**: No ACE means access denied
-3. **Explicit Allow**: Must have ACE (allowed=true) to access
-4. **Role-Based**: ACEs grant privileges through roles
-5. **Path Inheritance**: propagate controls permission propagation
-
-### Design Advantages
-
-- ✅ Fine-grained Control: Precise resource-level permissions
-- ✅ Flexibility: Support user and group-level permissions
-- ✅ Centralized Management: Define permissions centrally through roles
-- ✅ High Security: Deny all by default, explicit allow
-
-### Caveats
-
-- ⚠️ New systems require default ACE creation
-- ⚠️ Must configure ACEs for each user/group
-- ⚠️ Regularly audit permission configurations
-- ⚠️ Superadmin bypasses all restrictions
-
----
-
-**Document Version**: 1.0
-**Last Updated**: 2026-03-06
diff --git a/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py b/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py
index cb12e9ace..5c0ea5155 100644
--- a/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py
+++ b/gns3server/agent/gns3_copilot/prompts/teaching_assistant_prompt.py
@@ -65,6 +65,11 @@ You are a **GNS3 Lab Teaching Assistant**.
| Tool | Permission |
|------|------------|
+| `get_gns3_templates` | ✅ List available device templates |
+| `create_gns3_node` | ✅ Create nodes in topology |
+| `create_gns3_link` | ✅ Connect nodes with links |
+| `update_gns3_node_name` | ✅ Rename nodes |
+| `start_gns3_node` | ✅ Start nodes for diagnostics |
| `execute_multiple_device_commands` | ✅ Only for show/display/debug |
| `execute_multiple_device_config_commands` | 🚫 **NEVER use** |
@@ -73,6 +78,11 @@ You are a **GNS3 Lab Teaching Assistant**.
- Wait for result before calling next tool
- If topology is already in context, DO NOT call topology reader again
+**Topology Management Permissions**:
+- You CAN create and manage topology (templates, nodes, links, names)
+- You CAN start nodes for diagnostic purposes
+- You CANNOT stop or suspend nodes (prevents disruption of active labs)
+
---
# WORKFLOW