docs: restructure copilot docs and update node tools

Simplify the documentation structure in `README.md` by removing the
`todo/` directory reference and detailed design documents for planned
features. Consolidate future roadmap items into a high-level summary
under "Future Enhancements".

Update `node-control-tools.md` to include documentation for new topology
management tools (create node, create link, get template, rename node)
and reflect updated API imports for `Link` support.
This commit is contained in:
YueGuobin 2026-03-12 00:23:00 +08:00
parent 5155e10214
commit 75a4585c20
23 changed files with 440 additions and 11212 deletions

View File

@ -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

View File

@ -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_

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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_

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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 <pid>
- 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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

View File

@ -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*

View File

@ -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/).
![CC BY-SA 4.0](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)
### 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)

File diff suppressed because it is too large Load Diff

View File

@ -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: <Resource>.<Action>
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 = <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": "<User 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": "<User 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": "<Users group_id>",
"role_id": "<User 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": "<User 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 <token>"
# Check user's groups
curl -X GET http://localhost:3080/v3/access/users/me \
-H "Authorization: Bearer <token>"
```
### 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

View File

@ -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