style: fix E501 line-too-long errors in gns3_copilot

Fix all 423 E501 line length violations across 26 files to comply with
   PEP 8 88-character line limit.

   Changes:
   - Split long f-strings across multiple lines
   - Break long docstring descriptions and parameter lists
   - Split markdown table rows and list examples
   - Break long URL construction f-strings
   - Split long logger messages and comments
   - Add noqa: E501 for SVG strings (cannot be split)

   Modified files:
   - agent/: context_manager.py, gns3_copilot.py, model_factory.py
   - gns3_client/: connector_factory.py, context_helpers.py, custom_gns3fy.py,
                   gns3_project_info.py, gns3_topology_reader.py
   - prompts/: __init__.py, lab_automation_assistant_prompt.py,
               prompt_loader.py, teaching_assistant_prompt.py
   - tools_v2/: __init__.py, config_tools_nornir.py, display_tools_nornir.py,
                gns3_create_link.py, gns3_create_node.py, gns3_get_node_temp.py,
                gns3_start_node.py, gns3_update_node_name.py,
                vpcs_tools_telnetlib3.py
   - utils/: __init__.py, command_filter.py, get_gns3_device_port.py,
             gns3_drawing_utils.py, llm_config_helper.py, message_converters.py,
             parse_tool_content.py, tool_call_stream.py

   All files now pass ruff E501 checks.

   Co-Authored-By: Yue Guobin <yueguobin@outlook.com>
This commit is contained in:
YueGuobin committed 2026-03-10 01:05:17 +08:00
1 parent 25f2d1b56b
commit 7c3b832bca
32 files changed
+1967 -762

No files matched your search

@@ -26,7 +26,7 @@
GNS3-Copilot Public Model Package
This package provides reusable public models and utilities for GNS3 network automation tasks.
This package provides reusable public models and utilities for GNS3 automation tasks.
It contains common functionality that can be shared across different tools and modules.
Main modules:
@@ -32,7 +32,6 @@ availability.
"""
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -45,6 +44,7 @@ def _get_gns3_copilot_root() -> Path:
# Go up to the gns3_copilot directory (utils parent)
return current_file.parent.parent
# Default forbidden commands (fallback if file not found)
DEFAULT_FORBIDDEN_COMMANDS = [
"traceroute",
@@ -101,7 +101,10 @@ def _load_forbidden_commands() -> list[str]:
forbidden_commands.append(line.lower())
if not forbidden_commands:
logger.warning("No forbidden commands found in %s. Using default list.", file_path)
logger.warning(
"No forbidden commands found in %s. Using default list.",
file_path,
)
_forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy()
else:
logger.info(
@@ -135,7 +138,9 @@ def reload_forbidden_commands() -> None:
"""
global _forbidden_commands_cache
_forbidden_commands_cache = None
logger.info("Forbidden commands cache cleared. Will reload on next access.")
logger.info(
"Forbidden commands cache cleared. Will reload on next access."
)
def get_forbidden_commands() -> list[str]:
@@ -57,7 +57,9 @@ def get_device_ports_from_topology(
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)
logger.info(
"Called with device_names=%s, project_id=%s", device_names, project_id
)
try:
# Lazy import to avoid circular dependency
@@ -77,7 +79,9 @@ def get_device_ports_from_topology(
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)
logger.warning(
"Device '%s' not found in topology", device_name
)
continue
node_info = topology["nodes"][device_name]
@@ -99,15 +103,30 @@ def get_device_ports_from_topology(
# Use defaults if not found in tags
if device_type is None:
device_type = "cisco_ios_telnet"
logger.debug("Device '%s': device_type not found in tags, using default: cisco_ios_telnet", device_name)
logger.debug(
"Device '%s': device_type not found in tags, using default: "
"cisco_ios_telnet",
device_name,
)
else:
logger.debug("Device '%s': extracted device_type=%s from tags", device_name, device_type)
logger.debug(
"Device '%s': extracted device_type=%s from tags",
device_name,
device_type,
)
if platform is None:
platform = "cisco_ios"
logger.debug("Device '%s': platform not found in tags, using default: cisco_ios", device_name)
logger.debug(
"Device '%s': platform not found in tags, using default: cisco_ios",
device_name,
)
else:
logger.debug("Device '%s': extracted platform=%s from tags", device_name, platform)
logger.debug(
"Device '%s': extracted platform=%s from tags",
device_name,
platform,
)
# Add device to hosts_data
hosts_data[device_name] = {
@@ -135,9 +135,14 @@ def calculate_two_node_shape(
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)
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_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)
@@ -157,10 +162,16 @@ def calculate_two_node_shape(
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))
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))
shape_svg = generate_ellipse_svg(
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
)
offset_distance = ry * text_offset_ratio
@@ -177,12 +188,22 @@ def calculate_two_node_shape(
else: # rectangle
shape_width = distance
shape_height = max(node1_width, node1_height, node2_width, node2_height)
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))
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)
shape_svg = generate_rectangle_svg(
int(shape_width), int(shape_height), color_scheme
)
offset_distance = (shape_height / 2) * text_offset_ratio
@@ -238,7 +259,7 @@ def generate_ellipse_svg(
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>"""
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>""" # noqa: E501
def generate_rectangle_svg(
@@ -247,7 +268,7 @@ def generate_rectangle_svg(
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>"""
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>""" # noqa: E501
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
@@ -255,7 +276,7 @@ def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
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>"""
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>""" # noqa: E501
def _hsv_to_hex(h: int, s: int, v: int) -> str:
@@ -333,15 +354,33 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
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:
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:
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:
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
@@ -353,7 +392,12 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
return COLOR_SCHEMES["SECURITY_TRUSTED"]
# 8. Cloud/Tunnel
if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label:
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
@@ -392,7 +436,9 @@ def calculate_two_node_ellipse(
Wrapper around calculate_two_node_shape with shape_type="ellipse".
"""
result = calculate_two_node_shape(node1, node2, area_name, "ellipse", text_offset_ratio)
result = calculate_two_node_shape(
node1, node2, area_name, "ellipse", text_offset_ratio
)
return {
"ellipse": result["shape"],
"text": result["text"],
@@ -411,7 +457,9 @@ def calculate_two_node_rectangle(
Wrapper around calculate_two_node_shape with shape_type="rectangle".
"""
result = calculate_two_node_shape(node1, node2, area_name, "rectangle", text_offset_ratio)
result = calculate_two_node_shape(
node1, node2, area_name, "rectangle", text_offset_ratio
)
metadata = result["metadata"]
return {
"rectangle": result["shape"],
@@ -30,7 +30,9 @@ This module provides utility functions to retrieve LLM model configurations
with decrypted API keys by directly accessing the database.
Usage:
from gns3server.agent.gns3_copilot.utils.llm_config_helper import get_user_llm_config_with_app
from gns3server.agent.gns3_copilot.utils.llm_config_helper import (
get_user_llm_config_with_app,
)
# Get user's default LLM config (with API key)
config = await get_user_llm_config_with_app(user_id, app)
@@ -51,7 +53,9 @@ from fastapi import FastAPI
logger = logging.getLogger(__name__)
async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[Dict[str, Any]]:
async def get_user_llm_config_with_app(
user_id: UUID, app: FastAPI
) -> Optional[Dict[str, Any]]:
"""
Get user's default LLM model configuration with decrypted API key.
@@ -91,5 +95,8 @@ async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[
return config
except Exception as e:
logger.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True)
logger.error(
f"Failed to retrieve LLM config for user {user_id}: {e}",
exc_info=True,
)
return None
@@ -55,7 +55,8 @@ def convert_langchain_to_openai(lc_message) -> Dict[str, Any]:
Convert LangChain message to OpenAI-compatible format.
Args:
lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage, ToolMessage)
lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage,
ToolMessage)
Returns:
Dictionary in OpenAI-compatible format
@@ -90,7 +91,10 @@ def convert_langchain_to_openai(lc_message) -> Dict[str, Any]:
{
"id": tc_dict.get("id", str(uuid.uuid4())),
"type": "function",
"function": {"name": tc_dict.get("name", ""), "arguments": tc_dict.get("args", {})},
"function": {
"name": tc_dict.get("name", ""),
"arguments": tc_dict.get("args", {}),
},
}
)
msg["tool_calls"] = tool_calls
@@ -149,7 +153,11 @@ def convert_openai_to_langchain(msg: Dict[str, Any]):
return ai_msg
elif role == "tool":
return ToolMessage(content=content, name=msg.get("name", ""), tool_call_id=msg.get("tool_call_id", ""))
return ToolMessage(
content=content,
name=msg.get("name", ""),
tool_call_id=msg.get("tool_call_id", ""),
)
elif role == "system":
return SystemMessage(content=content)
@@ -176,7 +184,11 @@ def convert_stream_event_to_openai(event: Dict[str, Any]) -> Dict[str, Any]:
content = getattr(chunk, "content", "")
if content:
return {"type": "content", "content": content, "message_id": event.get("metadata", {}).get("msg_id")}
return {
"type": "content",
"content": content,
"message_id": event.get("metadata", {}).get("msg_id"),
}
# Check for tool call chunks
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
@@ -191,12 +203,19 @@ def convert_stream_event_to_openai(event: Dict[str, Any]) -> Dict[str, Any]:
"tool_call": {
"id": tc_id,
"type": "function",
"function": {"name": tc_name or "", "arguments": tc_args or ""},
"function": {
"name": tc_name or "",
"arguments": tc_args or "",
},
},
}
elif event_type == "on_tool_start":
return {"type": "tool_start", "tool_name": event.get("name", ""), "metadata": event.get("metadata", {})}
return {
"type": "tool_start",
"tool_name": event.get("name", ""),
"metadata": event.get("metadata", {}),
}
elif event_type == "on_tool_end":
tool_output = event.get("data", {}).get("output", "")
@@ -85,12 +85,14 @@ def parse_tool_content(
by json.dumps.
Args:
content: Content returned by tools (can be str, dict, list, int, float, bool, or None)
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:
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)
@@ -158,7 +160,9 @@ def parse_tool_content(
# Empty string handling
if not content.strip():
result = {}
logger.info("Content is empty or whitespace, returning: %s", result)
logger.info(
"Content is empty or whitespace, returning: %s", result
)
return result
s = content.strip()
@@ -166,14 +170,19 @@ def parse_tool_content(
# Handle empty dictionary case
if s == "{}":
result = {}
logger.info("Content is empty dictionary, returning: %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)
logger.info(
"Successfully parsed as Python literal, returning: %s",
result,
)
return result
except (ValueError, SyntaxError):
pass
@@ -181,7 +190,9 @@ def parse_tool_content(
# Try to parse as JSON
try:
result = json.loads(s)
logger.info("Successfully parsed as JSON, returning: %s", result)
logger.info(
"Successfully parsed as JSON, returning: %s", result
)
return result
except json.JSONDecodeError:
pass
@@ -210,7 +221,8 @@ def parse_tool_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__}"
"Content must be str, dict, list, int, float, bool, or None, got "
f"{type(content).__name__}"
)
logger.error(error_msg)
@@ -226,7 +238,9 @@ def parse_tool_content(
return result
def format_tool_response(content: str | dict | list | int | float | bool | None, indent: int = 2) -> str:
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.
@@ -234,7 +248,8 @@ def format_tool_response(content: str | dict | list | int | float | bool | None,
properly displayed in UI interfaces.
Args:
content: Content returned by tools (can be str, dict, list, int, float, bool, or None)
content: Content returned by tools (can be str, dict, list, int, float, bool,
or None)
indent: JSON indentation spaces, default 2
Returns:
@@ -244,7 +259,9 @@ def format_tool_response(content: str | dict | list | int | float | bool | None,
logger.info("format_tool_response parameter indent: %s", indent)
try:
parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False)
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)
@@ -253,7 +270,9 @@ def format_tool_response(content: str | dict | list | int | float | bool | None,
# 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)
result = json.dumps(
{"raw": str(content)}, ensure_ascii=False, indent=indent
)
logger.info("format_tool_response returning fallback: %s", result)
return result
except Exception:
@@ -267,12 +286,16 @@ def format_tool_response(content: str | dict | list | int | float | bool | None,
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)
result = json.dumps(
{"error": str(e)}, ensure_ascii=False, indent=indent
)
logger.info("format_tool_response returning error: %s", result)
return result
def normalize_tool_response(response: dict | list | str, tool_name: str = "unknown") -> dict:
def normalize_tool_response(
response: dict | list | str, tool_name: str = "unknown"
) -> dict:
"""
Normalize tool response to standard format for consistent frontend display.
@@ -298,15 +321,24 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
Examples:
>>> normalize_tool_response({"status": "success", "output": "OK"})
{'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}}
{'success': True, 'total': 1, 'successful': 1, 'failed': 0,
'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}}
>>> normalize_tool_response([{"device_name": "R1", "status": "success"}])
{'success': True, 'total': 1, 'successful': 1, 'failed': 0, 'data': [...], 'metadata': {}}
{'success': True, 'total': 1, 'successful': 1, 'failed': 0,
'data': [...], 'metadata': {}}
"""
metadata = {"tool_name": tool_name, "normalized_at": datetime.utcnow().isoformat()}
metadata = {
"tool_name": tool_name,
"normalized_at": datetime.utcnow().isoformat(),
}
# Handle error responses
if isinstance(response, dict) and "error" in response and len(response) == 1:
if (
isinstance(response, dict)
and "error" in response
and len(response) == 1
):
return {
"success": False,
"total": 0,
@@ -319,29 +351,56 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
# Handle empty responses
if not response:
return {"success": True, "total": 0, "successful": 0, "failed": 0, "data": [], "metadata": metadata}
return {
"success": True,
"total": 0,
"successful": 0,
"failed": 0,
"data": [],
"metadata": metadata,
}
# Handle list responses (most tools return list of device results)
if isinstance(response, list):
successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success")
successful = sum(
1
for item in response
if isinstance(item, dict) and item.get("status") == "success"
)
failed = len(response) - successful
normalized_data = []
for item in response:
if isinstance(item, dict):
normalized_item = {
"id": item.get("device_id") or item.get("node_id") or item.get("id") or "",
"id": item.get("device_id")
or item.get("node_id")
or item.get("id")
or "",
"name": item.get("device_name") or item.get("name") or "",
"status": item.get("status", "unknown"),
}
if normalized_item["status"] == "success":
normalized_item["result"] = item.get("output") or item.get("result") or ""
normalized_item["result"] = (
item.get("output") or item.get("result") or ""
)
else:
normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error"
normalized_item["error"] = (
item.get("error")
or item.get("output")
or "Unknown error"
)
normalized_data.append(normalized_item)
else:
# Non-dict items in list
normalized_data.append({"id": "", "name": "", "status": "unknown", "result": str(item)})
normalized_data.append(
{
"id": "",
"name": "",
"status": "unknown",
"result": str(item),
}
)
return {
"success": failed == 0,
@@ -367,8 +426,14 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
}
# Legacy format: extract common fields
total = response.get("total_nodes") or response.get("total") or response.get("count", 1)
successful = response.get("successful_nodes") or response.get("successful") or 0
total = (
response.get("total_nodes")
or response.get("total")
or response.get("count", 1)
)
successful = (
response.get("successful_nodes") or response.get("successful") or 0
)
failed = response.get("failed_nodes") or response.get("failed") or 0
# Extract data from various possible locations
@@ -393,9 +458,13 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
if not data and "status" in response:
data = [
{
"name": response.get("device_name") or response.get("name") or "",
"name": response.get("device_name")
or response.get("name")
or "",
"status": response["status"],
"result": response.get("output") or response.get("result") or "",
"result": response.get("output")
or response.get("result")
or "",
"error": response.get("error") or "",
}
]
@@ -425,14 +494,23 @@ def normalize_tool_response(response: dict | list | str, tool_name: str = "unkno
"total": 1,
"successful": 1,
"failed": 0,
"data": [{"id": "", "name": "", "status": "unknown", "result": str(response)}],
"data": [
{
"id": "",
"name": "",
"status": "unknown",
"result": str(response),
}
],
"metadata": metadata,
}
# 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 function to verify parse_tool_content works correctly with all input
types
"""
test_cases: list[tuple[Any, Any]] = [
# String inputs
(
@@ -486,7 +564,9 @@ def _test_parse_tool_content() -> None:
valid = "✓"
except Exception:
valid = "✗"
print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}")
print(
f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}"
)
if __name__ == "__main__":
@@ -79,10 +79,11 @@ class ToolCallStreamAccumulator:
tc_id = getattr(tool_call, "id", None)
tc_name = getattr(tool_call, "name", "")
# Only when ID is not empty, consider it as the start of a new tool call
# Only when ID is not empty, consider it as the start of a new
# tool call
if tc_id:
# Initialize current tool state (this is the only time to get ID)
# Note: only one tool can be called at a time
# Initialize current tool state (this is the only time to
# get ID). Note: only one tool can be called at a time
self._current_tool_call = {
"id": tc_id,
"name": tc_name if tc_name else "UNKNOWN_TOOL",
@@ -90,19 +91,24 @@ class ToolCallStreamAccumulator:
}
# Send initial tool_call event with empty args
chunks.append({
"type": "tool_call",
"tool_call": {
"id": tc_id,
"type": "function",
"function": {
"name": self._current_tool_call["name"],
"arguments": ""
}
chunks.append(
{
"type": "tool_call",
"tool_call": {
"id": tc_id,
"type": "function",
"function": {
"name": self._current_tool_call[
"name"
],
"arguments": "",
},
},
}
})
)
# ========== Phase 2: Concatenate parameter strings from tool_call_chunks ==========
# ========== Phase 2: Concatenate parameter strings from
# tool_call_chunks ==========
# Concatenate parameter strings from tool_call_chunk
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
if self._current_tool_call:
@@ -116,43 +122,58 @@ class ToolCallStreamAccumulator:
# Core: string concatenation
if isinstance(args_chunk, str):
tool_data["args_string"] += args_chunk
tool_data[
"args_string"
] += args_chunk
# Send updated tool_call event with accumulated args
chunks.append({
"type": "tool_call",
"tool_call": {
"id": tool_data["id"],
"type": "function",
"function": {
"name": tool_data["name"],
"arguments": tool_data["args_string"]
}
chunks.append(
{
"type": "tool_call",
"tool_call": {
"id": tool_data["id"],
"type": "function",
"function": {
"name": tool_data["name"],
"arguments": tool_data[
"args_string"
],
},
},
}
})
)
# ========== Phase 3: Determine if tool_calls_chunks output is complete ==========
# ========== Phase 3: Determine if tool_calls_chunks output is
# complete ==========
# Check finish_reason == "tool_calls" or "STOP"
response_metadata = getattr(chunk, "response_metadata", {})
finish_reason = response_metadata.get("finish_reason") if isinstance(response_metadata, dict) else None
finish_reason = (
response_metadata.get("finish_reason")
if isinstance(response_metadata, dict)
else None
)
if (finish_reason == "tool_calls") or (finish_reason == "stop" and self._current_tool_call is not None):
if (finish_reason == "tool_calls") or (
finish_reason == "stop" and self._current_tool_call is not None
):
if self._current_tool_call:
tool_data = self._current_tool_call
# Send final complete tool_call event
chunks.append({
"type": "tool_call",
"tool_call": {
"id": tool_data["id"],
"type": "function",
"function": {
"name": tool_data["name"],
"arguments": tool_data["args_string"],
"complete": True # Mark as complete
}
chunks.append(
{
"type": "tool_call",
"tool_call": {
"id": tool_data["id"],
"type": "function",
"function": {
"name": tool_data["name"],
"arguments": tool_data["args_string"],
"complete": True, # Mark as complete
},
},
}
})
)
# Clear the current tool call state
self._current_tool_call = None
@@ -160,11 +181,13 @@ class ToolCallStreamAccumulator:
# Also handle regular content (when not in tool call mode)
content = getattr(chunk, "content", "")
if content and not self._current_tool_call:
chunks.append({
"type": "content",
"content": content,
"message_id": event.get("metadata", {}).get("msg_id")
})
chunks.append(
{
"type": "content",
"content": content,
"message_id": event.get("metadata", {}).get("msg_id"),
}
)
return chunks