feat(agent): add gns3-copilot AI assistant integration module

Integrate the gns3-copilot AI assistant module to provide intelligent
   automation and interaction capabilities for GNS3 network emulation.

   Key components:
   - AI agent framework with LLM integration (supports Qwen vision model)
   - GNS3 client library for project topology management
   - Extensive prompt templates for various network operation scenarios
   - Tool library for node creation, linking, configuration, and management
   - Support for English level assessment (A1-C2) and specialized personas
   - Network drawing and topology visualization tools
   - Linux device automation via Nornir/Telnetlib
   - Window controller for UI interaction

   Features:
   - Multi-modal AI agent with vision capabilities
   - Automated network topology deployment and configuration
   - Interactive node and drawing management
   - File-based project operations (read, write, list)
   - Specialized prompts for different scenarios and skill levels
   - Comprehensive tool set for network device management
This commit is contained in:
YueGuobin committed 2026-03-03 23:08:07 +08:00
1 parent 7a2d15cb64
commit 46b262a02c
62 files changed
+18114

No files matched your search

@@ -0,0 +1,50 @@
"""
FlowNet-Lab Public Model Package
This package provides reusable public models and utilities for GNS3 network automation tasks.
It contains common functionality that can be shared across different tools and modules.
Main modules:
- get_gns3_device_port: Device port information retrieval from GNS3 topology
- parse_tool_content: Tool execution result parsing and formatting utilities
Author: Guobin Yue
"""
# Import main utility functions
from .app_config import (
DEFAULT_CONFIG,
get_all_config,
get_config,
init_config,
reset_config,
set_config,
)
from .get_gns3_device_port import get_device_ports_from_topology
from .parse_tool_content import format_tool_response, parse_tool_content
# Dynamic version management
try:
from importlib.metadata import version
__version__ = version("gns3-copilot")
except Exception:
__version__ = "unknown"
__author__ = "Guobin Yue"
__description__ = "AI-powered network automation assistant for GNS3"
__url__ = "https://github.com/yueguobin/gns3-copilot"
# Export main utility functions
__all__ = [
"DEFAULT_CONFIG",
"get_config",
"set_config",
"get_all_config",
"init_config",
"reset_config",
"get_device_ports_from_topology",
"parse_tool_content",
"format_tool_response",
]
@@ -0,0 +1,206 @@
"""
Application Configuration Manager for FlowNet-Lab.
This module provides a unified interface for managing application configuration
stored in SQLite database. It includes default values for all configuration
items and helper functions for reading and saving configuration.
Functions:
get_config(key, default=None): Retrieve a configuration value with default
set_config(key, value): Save a configuration value to database
get_all_config(): Retrieve all configuration values
init_config(): Initialize database with default values
Constants:
DEFAULT_CONFIG: Dictionary containing all configuration keys and their defaults
"""
import logging
from typing import Any
from gns3_copilot.utils.config_db import (
clear_all,
get_all_values,
get_value,
init_db,
set_value,
)
logger = logging.getLogger(__name__)
# Default configuration values for all application settings
DEFAULT_CONFIG: dict[str, str] = {
# GNS3 Server Configuration
#"GNS3_SERVER_HOST": "",
#"GNS3_SERVER_URL": "http://127.0.0.1:3080/",
#"API_VERSION": "3",
#"GNS3_SERVER_USERNAME": "",
#"GNS3_SERVER_PASSWORD": "",
#"GNS3_SERVER_JWT_TOKEN": "",
# Model Configuration
"MODE_PROVIDER": "openai",
"MODEL_NAME": "gpt-4",
"MODEL_API_KEY": "",
"BASE_URL": "",
"TEMPERATURE": "0.0",
# Voice Configuration
#"VOICE": "False",
# Linux Telnet Configuration
"LINUX_TELNET_USERNAME": "",
"LINUX_TELNET_PASSWORD": "",
# Prompt Configuration
#"ENGLISH_LEVEL": "Normal Prompt",
# Reading Page Configuration
#"CALIBRE_SERVER_URL": "",
#"READING_NOTES_DIR": "notes",
# UI Configuration
#"CONTAINER_HEIGHT": "1200",
#"ZOOM_SCALE_TOPOLOGY": "0.8",
# Other Settings
#"LANGUAGE": "zh",
# DashScope API Configuration
#"DASHSCOPE_API_KEY": "",
# DashScope STT (Speech-to-Text) Configuration
#"DASHSCOPE_STT_MODEL": "fun-asr-realtime",
#"DASHSCOPE_STT_FORMAT": "wav",
#"DASHSCOPE_STT_SAMPLE_RATE": "16000",
#"DASHSCOPE_STT_LANGUAGE_HINTS": "zh,en",
# DashScope TTS (Text-to-Speech) Configuration
#"DASHSCOPE_TTS_MODEL": "cosyvoice-v3-flash",
#"DASHSCOPE_TTS_VOICE": "longanyang",
#"DASHSCOPE_TTS_FORMAT": "mp3",
#"DASHSCOPE_TTS_VOLUME": "50",
#"DASHSCOPE_TTS_SPEECH_RATE": "1.0",
#"DASHSCOPE_TTS_PITCH_RATE": "1.0",
}
def _get_default(key: str) -> str | None:
"""Get the default value for a configuration key.
Args:
key: Configuration key
Returns:
Default value if key exists, None otherwise
"""
return DEFAULT_CONFIG.get(key)
def get_config(key: str, default: str | None = None) -> str:
"""Retrieve a configuration value from the database.
If the key doesn't exist in the database, it will use the default value
from DEFAULT_CONFIG. If the key is not in DEFAULT_CONFIG either, it will
use the provided default parameter.
Args:
key: The configuration key to retrieve
default: Fallback default value if key not in DEFAULT_CONFIG
Returns:
The configuration value as a string
Example:
>>> get_config("GNS3_SERVER_URL")
'http://127.0.0.1:3080/'
>>> get_config("CUSTOM_KEY", "default_value")
'default_value'
"""
# Get default value from DEFAULT_CONFIG or use provided default
default_value = default if default is not None else _get_default(key)
# Retrieve value from database
value = get_value(key, default_value)
if value is None:
return default_value if default_value is not None else ""
# Ensure value is a string
return str(value) if value else default_value if default_value else ""
def set_config(key: str, value: str) -> bool:
"""Save a configuration value to the database.
Args:
key: The configuration key to save
value: The configuration value to store
Returns:
True if save was successful, False otherwise
Example:
>>> set_config("GNS3_SERVER_URL", "http://192.168.1.100:3080")
True
"""
return set_value(key, value)
def get_all_config() -> dict[str, str]:
"""Retrieve all configuration values from the database.
Returns:
Dictionary with all configuration key-value pairs
Example:
>>> config = get_all_config()
>>> print(config["GNS3_SERVER_URL"])
'http://127.0.0.1:3080/'
"""
return get_all_values()
def init_config() -> None:
"""Initialize the configuration database with default values.
This function initializes the database and populates it with default
values for all configuration keys. If a key already exists in the
database, its value will not be overwritten.
This should be called once at application startup.
Example:
>>> init_config()
# Database is now initialized with default values
"""
try:
# Initialize database and create tables
init_db()
# Set default values for all keys (only if not already set)
for key, default_value in DEFAULT_CONFIG.items():
existing_value = get_value(key)
if existing_value is None:
set_value(key, default_value)
logger.info(
"Configuration database initialized with %d keys", len(DEFAULT_CONFIG)
)
except Exception as e:
logger.error("Failed to initialize configuration: %s", e)
raise
def reset_config() -> None:
"""Reset all configuration to default values.
This function clears all existing configuration values and
restores them to defaults defined in DEFAULT_CONFIG.
Example:
>>> reset_config()
# All configuration values are now set to defaults
"""
try:
# Clear all existing values
clear_all()
# Re-initialize with defaults
init_config()
logger.info("Configuration reset to defaults")
except Exception as e:
logger.error("Failed to reset configuration: %s", e)
raise
@@ -0,0 +1,73 @@
"""
Public module for getting device port information from GNS3 topology
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def get_device_ports_from_topology(
device_names: list[str],
project_id: str | None = None,
) -> dict[str, dict[str, Any]]:
"""
Get device connection information from GNS3 topology
Args:
device_names: List of device names to look up
project_id: UUID of the specific GNS3 project to retrieve topology from
Returns:
Dictionary mapping device names to their connection data:
{
"device_name": {
"port": console_port,
"groups": ["cisco_IOSv_telnet"]
}
}
Devices that don't exist or missing console_port will not be included
"""
# Log received parameters
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
try:
# Lazy import to avoid circular dependency
from gns3_copilot.gns3_client import GNS3TopologyTool
# Get topology information
topo = GNS3TopologyTool()
topology = topo._run(project_id=project_id)
# Dynamically build hosts_data from topology
hosts_data: dict[str, dict[str, Any]] = {}
if not topology:
logger.warning("Unable to get topology information")
return hosts_data
for device_name in device_names:
# Check if device exists in topology
if device_name not in topology.get("nodes", {}):
logger.warning("Device '%s' not found in topology", device_name)
continue
node_info = topology["nodes"][device_name]
if "console_port" not in node_info:
logger.warning("Device '%s' missing console_port", device_name)
continue
# Add device to hosts_data
hosts_data[device_name] = {
"port": node_info["console_port"],
"groups": ["cisco_IOSv_telnet"],
}
logger.info("Returning %d device port mappings", len(hosts_data))
return hosts_data
except Exception as e:
logger.error("Error getting device port information: %s", e)
return {}
@@ -0,0 +1,437 @@
"""
Drawing utility functions for GNS3 area annotations.
Calculates drawing parameters and generates SVG content for network area annotations.
Supports ellipse and rectangle shapes for two-node annotations.
"""
import math
from typing import Any, Literal
# Default parameters
DEFAULT_DEVICE_WIDTH = 50
DEFAULT_DEVICE_HEIGHT = 50
DEFAULT_FONT_SIZE = 14
DEFAULT_SHAPE_Z = 0
DEFAULT_TEXT_Z = 1
# Z-order thresholds (pixels)
Z_ORDER_LARGE_AREA_THRESHOLD = 500 # z=1 for large areas
Z_ORDER_MEDIUM_AREA_THRESHOLD = 300 # z=2 for medium areas
# Color schemes (business professional style)
# Text uses 'stroke', shape uses 'fill' with 'fill-opacity'
COLOR_SCHEMES = {
# Core/Backbone - biz_blue
"CORE_BACKBONE": {
"stroke": "#FFFFFF", # White (for text on dark background)
"fill": "#2980B9", # Blue background
"fill_opacity": 0.8, # Transparency level
},
# Normal Areas - biz_blue_light
"NORMAL_AREA": {
"stroke": "#000000", # Black (for text on light background)
"fill": "#5AA9DD", # Light blue background
"fill_opacity": 0.8, # Transparency level
},
# Logical Isolation - biz_purple
"ISOLATION": {
"stroke": "#FFFFFF", # White (for text on dark background)
"fill": "#9B59B6", # Purple background
"fill_opacity": 0.8, # Transparency level
},
# Management/Infrastructure - biz_orange
"MANAGEMENT_INFRA": {
"stroke": "#FFFFFF", # White (for text on medium-dark background)
"fill": "#E67E22", # Orange background
"fill_opacity": 0.8, # Transparency level
},
# Redundancy/High Availability - biz_orange_bright
"HIGH_AVAILABILITY": {
"stroke": "#000000", # Black (for text on bright background)
"fill": "#F39C12", # Bright orange background
"fill_opacity": 0.8, # Transparency level
},
# External/Boundary - biz_red_bright
"EXTERNAL": {
"stroke": "#FFFFFF", # White (for text on dark background)
"fill": "#E74C3C", # Bright red background
"fill_opacity": 0.8, # Transparency level
},
# Security/Trusted - biz_green_bright
"SECURITY_TRUSTED": {
"stroke": "#FFFFFF", # White (for text on medium-dark background)
"fill": "#2ECC71", # Bright green background
"fill_opacity": 0.8, # Transparency level
},
# Cloud/Tunnel - biz_cyan_dark
"CLOUD_TUNNEL": {
"stroke": "#FFFFFF", # White (for text on dark background)
"fill": "#00CED1", # Dark cyan background
"fill_opacity": 0.8, # Transparency level
},
# Default - biz_gray
"DEFAULT": {
"stroke": "#FFFFFF", # White (for text on medium-dark background)
"fill": "#A5CC78", # Gray background
"fill_opacity": 0.8, # Transparency level
},
}
def calculate_two_node_shape(
node1: dict,
node2: dict,
area_name: str,
shape_type: Literal["ellipse", "rectangle"] = "ellipse",
text_offset_ratio: float = 0.0,
) -> dict[str, Any]:
"""
Calculate shape annotation parameters for two nodes.
Args:
node1: First node with 'x', 'y', 'height', 'width' (top-left)
node2: Second node with 'x', 'y', 'height', 'width' (top-left)
area_name: Name of area (e.g., "Area 0", "AS 100")
shape_type: "ellipse" or "rectangle" (default: "ellipse")
text_offset_ratio: Ratio to offset text (0=center, positive=offset)
Returns:
Dict with shape, text SVG params, and metadata
"""
node1_width = node1.get("width", DEFAULT_DEVICE_WIDTH)
node1_height = node1.get("height", DEFAULT_DEVICE_HEIGHT)
node2_width = node2.get("width", DEFAULT_DEVICE_WIDTH)
node2_height = node2.get("height", DEFAULT_DEVICE_HEIGHT)
node1_center_x = node1["x"] + (node1_width / 2)
node1_center_y = node1["y"] + (node1_height / 2)
node2_center_x = node2["x"] + (node2_width / 2)
node2_center_y = node2["y"] + (node2_height / 2)
distance = math.sqrt(
(node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2
)
angle_rad = math.atan2(
node2_center_y - node1_center_y, node2_center_x - node1_center_x
)
angle_deg = round(math.degrees(angle_rad))
angle_rad = math.radians(angle_deg)
center_x = (node1_center_x + node2_center_x) / 2
center_y = (node1_center_y + node2_center_y) / 2
color_scheme = _get_color_scheme(area_name)
text_svg = generate_text_svg(area_name, color_scheme)
text_svg_width = len(area_name) * 8 + 20
text_svg_height = DEFAULT_FONT_SIZE + 16
if shape_type == "ellipse":
rx = distance / 2
ry = math.sqrt((node1_width / 2) ** 2 + (node1_height / 2) ** 2)
shape_width = rx * 2
shape_height = ry * 2
svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad))
svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad))
shape_svg = generate_ellipse_svg(
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
)
offset_distance = ry * text_offset_ratio
metadata = {
"center_x": center_x,
"center_y": center_y,
"distance": distance,
"shape_width": shape_width,
"shape_height": shape_height,
"angle_deg": angle_deg,
"rx": rx,
"ry": ry,
}
else: # rectangle
shape_width = distance
shape_height = max(node1_width, node1_height, node2_width, node2_height)
svg_x = center_x - (
(shape_width / 2) * math.cos(angle_rad)
- (shape_height / 2) * math.sin(angle_rad)
)
svg_y = center_y - (
(shape_width / 2) * math.sin(angle_rad)
+ (shape_height / 2) * math.cos(angle_rad)
)
shape_svg = generate_rectangle_svg(
int(shape_width), int(shape_height), color_scheme
)
offset_distance = (shape_height / 2) * text_offset_ratio
metadata = {
"center_x": center_x,
"center_y": center_y,
"distance": distance,
"shape_width": shape_width,
"shape_height": shape_height,
"angle_deg": angle_deg,
"rx": None,
"ry": None,
}
if text_offset_ratio != 0:
perpendicular_x = -math.sin(angle_rad)
perpendicular_y = math.cos(angle_rad)
text_offset_x = perpendicular_x * offset_distance
text_offset_y = perpendicular_y * offset_distance
text_x = int(center_x + text_offset_x - text_svg_width / 2)
text_y = int(center_y + text_offset_y - text_svg_height / 2)
else:
text_x = int(center_x - text_svg_width / 2)
text_y = int(center_y - text_svg_height / 2)
return {
"shape": {
"svg": shape_svg,
"x": int(svg_x),
"y": int(svg_y),
"z": DEFAULT_SHAPE_Z,
"rotation": int(angle_deg),
"type": shape_type,
},
"text": {
"svg": text_svg,
"x": text_x,
"y": text_y,
"z": DEFAULT_TEXT_Z,
"rotation": 0,
},
"metadata": metadata,
}
def generate_ellipse_svg(
rx: int,
ry: int,
color_scheme: dict[str, Any],
svg_width: int,
svg_height: int,
) -> str:
"""Generate SVG for ellipse."""
return f'''<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>'''
def generate_rectangle_svg(
width: int,
height: int,
color_scheme: dict[str, Any],
) -> str:
"""Generate SVG for rectangle."""
return f'''<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>'''
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
"""Generate SVG for text label."""
text_width = len(text) * 8 + 20
text_height = DEFAULT_FONT_SIZE + 16
return f'''<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>'''
def _hsv_to_hex(h: int, s: int, v: int) -> str:
"""Convert HSV to HEX color."""
h_norm = (h % 360) / 360
s_norm = s / 100
v_norm = v / 100
c: float = v_norm * s_norm
x: float = c * (1 - abs((h_norm / 60) % 2 - 1))
m: float = v_norm - c
r: float
g: float
b: float
if 0 <= h_norm < 60:
r, g, b = c, x, 0.0
elif 60 <= h_norm < 120:
r, g, b = x, c, 0.0
elif 120 <= h_norm < 180:
r, g, b = 0.0, c, x
elif 180 <= h_norm < 240:
r, g, b = 0.0, x, c
elif 240 <= h_norm < 300:
r, g, b = x, 0.0, c
else:
r, g, b = c, 0.0, x
r = int((r + m) * 255)
g = int((g + m) * 255)
b = int((b + m) * 255)
return f"#{r:02x}{g:02x}{b:02x}"
def calculate_z_order(area_size: float) -> int:
"""
Calculate z-order based on area size for proper layering.
Args:
area_size: Size of area (width for rectangles, 2*rx for ellipses)
Returns:
Z-order: 1=back (large), 2=middle, 3=front (small)
"""
if area_size >= Z_ORDER_LARGE_AREA_THRESHOLD:
return 1
elif area_size >= Z_ORDER_MEDIUM_AREA_THRESHOLD:
return 2
else:
return 3
def _get_color_scheme(area_name: str) -> dict[str, Any]:
"""
Get color scheme based on area name using keyword inference.
Maps labels to semantic groups for professional business styling.
"""
if area_name in COLOR_SCHEMES:
return COLOR_SCHEMES[area_name]
label = area_name.upper()
# 1. Routing Domain Core/Backbone
if "AREA 0" in label or "BACKBONE" in label or "CORE" in label:
return COLOR_SCHEMES["CORE_BACKBONE"]
if "BGP" in label or "AS " in label:
return COLOR_SCHEMES["CORE_BACKBONE"]
# 2. Routing Domain Normal Areas
if "AREA " in label or "LEVEL" in label:
return COLOR_SCHEMES["NORMAL_AREA"]
# 3. Logical Isolation
if (
"VRF" in label
or "VLAN" in label
or "MSTP" in label
or "VXLAN" in label
or "MPLS" in label
):
return COLOR_SCHEMES["ISOLATION"]
# 4. High Availability
if (
"VRRP" in label
or "HSRP" in label
or "HA" in label
or "STACK" in label
or "M-LAG" in label
):
return COLOR_SCHEMES["HIGH_AVAILABILITY"]
# 5. External/Internet
if (
"INET" in label
or "OUT" in label
or "EXTERNAL" in label
or "INTERNET" in label
or "DMZ" in label
):
return COLOR_SCHEMES["EXTERNAL"]
# 6. Management
if "MGMT" in label or "OOB" in label or "MANAGEMENT" in label:
return COLOR_SCHEMES["MANAGEMENT_INFRA"]
# 7. Security/Trusted
if "SECURITY" in label or "TRUSTED" in label or "VPN" in label:
return COLOR_SCHEMES["SECURITY_TRUSTED"]
# 8. Cloud/Tunnel
if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label:
return COLOR_SCHEMES["CLOUD_TUNNEL"]
# Legacy keyword matching for backward compatibility
label_lower = area_name.lower()
protocol_keywords = [
("ospf", "NORMAL_AREA"),
("is-is", "NORMAL_AREA"),
("rip", "NORMAL_AREA"),
("eigrp", "NORMAL_AREA"),
("bgp", "CORE_BACKBONE"),
("vxlan", "ISOLATION"),
("mpls", "ISOLATION"),
("vrf", "ISOLATION"),
("vlan", "ISOLATION"),
("vrrp", "HIGH_AVAILABILITY"),
("hsrp", "HIGH_AVAILABILITY"),
("gre", "CLOUD_TUNNEL"),
("ipsec", "SECURITY_TRUSTED"),
]
for keyword, scheme_key in protocol_keywords:
if keyword in label_lower:
return COLOR_SCHEMES[scheme_key]
return COLOR_SCHEMES["DEFAULT"]
def calculate_two_node_ellipse(
node1: dict,
node2: dict,
area_name: str,
text_offset_ratio: float = 0.0,
) -> dict[str, Any]:
"""
Calculate ellipse annotation parameters for two nodes.
Wrapper around calculate_two_node_shape with shape_type="ellipse".
"""
result = calculate_two_node_shape(
node1, node2, area_name, "ellipse", text_offset_ratio
)
return {
"ellipse": result["shape"],
"text": result["text"],
"metadata": result["metadata"],
}
def calculate_two_node_rectangle(
node1: dict,
node2: dict,
area_name: str,
text_offset_ratio: float = 0.0,
) -> dict[str, Any]:
"""
Calculate rectangle annotation parameters for two nodes.
Wrapper around calculate_two_node_shape with shape_type="rectangle".
"""
result = calculate_two_node_shape(
node1, node2, area_name, "rectangle", text_offset_ratio
)
metadata = result["metadata"]
return {
"rectangle": result["shape"],
"text": result["text"],
"metadata": {
"center_x": metadata["center_x"],
"center_y": metadata["center_y"],
"distance": metadata["distance"],
"rect_width": metadata["shape_width"],
"rect_height": metadata["shape_height"],
"angle_deg": metadata["angle_deg"],
},
}
@@ -0,0 +1,295 @@
"""
Public module for parsing tool execution results
This module is specifically designed to parse and format results returned by tools_v2
after tool execution, supporting multiple formats and providing unified error handling.
Mainly used for result display in UI interfaces.
Supported formats:
- JSON strings
- Python literal strings
- Dictionary objects
- List objects (JSON arrays)
- Primitive types (int, float, bool, str)
- Error message strings
- Plain text output
Author: Guobin Yue
"""
import ast
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def parse_tool_content(
content: str | dict | list | int | float | bool | None,
fallback_to_raw: bool = True,
strict_mode: bool = False,
) -> dict[str, Any] | list[Any] | Any:
"""
Parse tool execution results into structured data, specifically for UI display.
This function can handle various input types including strings, dictionaries, lists,
and primitive types. It ensures the returned data can be properly serialized
by json.dumps.
Args:
content: Content returned by tools (can be str, dict, list, int, float, bool, or None)
fallback_to_raw: Whether to return raw content when parsing fails, default True
strict_mode: Strict mode, raises exceptions when parsing fails, default False
Returns:
Union[Dict[str, Any], List[Any], Any]: Parsed data that can be serialized by json.dumps:
- Successfully parsed JSON/Python literal data
- Original dict/list objects (passed through)
- Primitive types (int, float, bool, str)
- {"raw": content} when unable to parse but fallback_to_raw=True
- {"error": "error_message"} when parsing fails and fallback_to_raw=False
- {} for None input
Raises:
ValueError: When strict_mode=True and parsing fails
TypeError: When content type is unsupported and strict_mode=True
Examples:
>>> parse_tool_content('{"status": "success", "data": [1, 2, 3]}')
{'status': 'success', 'data': [1, 2, 3]}
>>> parse_tool_content({"status": "success"})
{'status': 'success'}
>>> parse_tool_content([1, 2, 3])
[1, 2, 3]
>>> parse_tool_content(42)
42
>>> parse_tool_content("{'name': 'PC1', 'status': 'ok'}")
{'name': 'PC1', 'status': 'ok'}
>>> parse_tool_content("Invalid JSON input: ...")
{'raw': 'Invalid JSON input: ...'}
>>> parse_tool_content("{}")
{}
>>> parse_tool_content(None)
{}
"""
# Log received input parameters
logger.info(
"Received parameters: fallback_to_raw=%s, strict_mode=%s, content=%s",
fallback_to_raw,
strict_mode,
content,
)
# Handle None input
if content is None:
result: dict[str, Any] = {}
logger.info("Content is None, returning: %s", result)
return result
# Handle dictionary objects (already parsed)
if isinstance(content, dict):
logger.info("Content is already a dictionary, returning: %s", content)
return content
# Handle list objects (JSON arrays)
if isinstance(content, list):
logger.info("Content is already a list, returning: %s", content)
return content
# Handle primitive types that are JSON serializable
if isinstance(content, (str, int, float, bool)):
# For strings, we need to try parsing them
if isinstance(content, str):
# Empty string handling
if not content.strip():
result = {}
logger.info("Content is empty or whitespace, returning: %s", result)
return result
s = content.strip()
# Handle empty dictionary case
if s == "{}":
result = {}
logger.info("Content is empty dictionary, returning: %s", result)
return result
# Try to parse as Python literal
# (higher priority as many tools return Python format strings)
try:
result = ast.literal_eval(s)
logger.info(
"Successfully parsed as Python literal, returning: %s", result
)
return result
except (ValueError, SyntaxError):
pass
# Try to parse as JSON
try:
result = json.loads(s)
logger.info("Successfully parsed as JSON, returning: %s", result)
return result
except json.JSONDecodeError:
pass
# Handle parsing failure for strings
error_msg = "Unable to parse content as JSON or Python literal"
logger.warning("%s: %s", error_msg, s)
if strict_mode:
raise ValueError("%s. Content: %s", error_msg, s)
if fallback_to_raw:
result = {"raw": s}
logger.info("Returning raw content as fallback: %s", result)
return result
result = {"error": error_msg}
logger.info("Returning error: %s", result)
return result
# For non-string primitives (int, float, bool), return as-is
logger.info(
"Content is a primitive type %s, returning: %s",
type(content).__name__,
content,
)
return content
# Handle unsupported types
error_msg = ( # type: ignore[unreachable]
"Content must be str, dict, list, int, float, bool, or None, got "
f"{type(content).__name__}"
)
logger.error(error_msg)
if strict_mode:
raise TypeError(error_msg)
if fallback_to_raw:
result = {"raw": str(content)}
logger.info("Returning raw content as fallback: %s", result)
return result
result = {"error": error_msg}
logger.info("Returning error: %s", result)
return result
def format_tool_response(
content: str | dict | list | int | float | bool | None, indent: int = 2
) -> str:
"""
Format tool response as a beautiful JSON string for UI display.
This function ensures that the output is always a valid JSON string that can be
properly displayed in UI interfaces.
Args:
content: Content returned by tools (can be str, dict, list, int, float, bool, or None)
indent: JSON indentation spaces, default 2
Returns:
str: Formatted JSON string, always valid JSON
"""
logger.info("format_tool_response received input content: %s", content)
logger.info("format_tool_response parameter indent: %s", indent)
try:
parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False)
# Ensure the result can be serialized to JSON
result = json.dumps(parsed, ensure_ascii=False, indent=indent)
logger.info("format_tool_response returning: %s", result)
return result
except (TypeError, ValueError) as e:
# If the parsed result cannot be serialized, convert to string and wrap
logger.error("Cannot serialize parsed result to JSON: %s", e)
try:
result = json.dumps(
{"raw": str(content)}, ensure_ascii=False, indent=indent
)
logger.info("format_tool_response returning fallback: %s", result)
return result
except Exception:
# Last resort: return a simple error message
result = json.dumps(
{"error": "Unable to format response"},
ensure_ascii=False,
indent=indent,
)
logger.info("format_tool_response returning error: %s", result)
return result
except Exception as e:
logger.error("Error formatting tool response: %s", e)
result = json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent)
logger.info("format_tool_response returning error: %s", result)
return result
# Test function to verify the implementation
def _test_parse_tool_content() -> None:
"""Test function to verify parse_tool_content works correctly with all input types"""
test_cases: list[tuple[Any, Any]] = [
# String inputs
(
'{"status": "success", "data": [1, 2, 3]}',
{"status": "success", "data": [1, 2, 3]},
),
("{'name': 'PC1', 'status': 'ok'}", {"name": "PC1", "status": "ok"}),
("[1, 2, 3]", [1, 2, 3]),
('"hello"', "hello"),
("42", 42),
("true", True),
("3.14", 3.14),
("{}", {}),
(" {} ", {}),
("", {}),
(" ", {}),
("Invalid JSON input", {"raw": "Invalid JSON input"}),
# Direct object inputs
({"status": "success"}, {"status": "success"}),
([1, 2, 3], [1, 2, 3]),
("hello", "hello"),
(42, 42),
(True, True),
(3.14, 3.14),
(None, {}),
]
print("Testing parse_tool_content function:")
for i, (input_data, expected) in enumerate(test_cases):
result = parse_tool_content(input_data)
status = "✓" if result == expected else "✗"
print(f"Test {i + 1}: {status} Input: {repr(input_data)} -> {result}")
print("\nTesting format_tool_response function:")
format_tests = [
'{"status": "success"}',
"{}",
None,
"Invalid input",
{"direct": "dict"},
[1, 2, 3],
42,
True,
]
for i, input_data in enumerate(format_tests):
result = format_tool_response(input_data)
# Verify it's valid JSON
try:
json.loads(result)
valid = "✓"
except Exception:
valid = "✗"
print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}")
if __name__ == "__main__":
_test_parse_tool_content()