YueGuobin 511b155da8 feat(ai-chat-api): implement streaming tool calls with incremental parameter accumulation
- Update API documentation to reflect new streaming tool call mechanism
- Add `message_id` optional field to content and tool_call events
- Change tool_call structure from array to single object with incremental updates
- Add `tool_call_id` to tool_start events for better event correlation
- Implement ToolCallStreamAccumulator class to handle parameter accumulation
- Provide frontend example code for handling streaming tool calls
- Maintain backward compatibility with existing session_id tracking
2026-03-05 22:29:01 +08:00

174 lines
7.2 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Guobin Yue
# Author: Guobin Yue
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
Tool call streaming accumulator for handling progressive tool call arguments
Maintains state for streaming tool call chunks
Based on FlowNet-Lab implementation
"""
from typing import Any, Dict, List, Optional
class ToolCallStreamAccumulator:
"""
Accumulates tool call information from streaming events
Handles the progressive build-up of tool call arguments
This class processes LangGraph streaming events and accumulates
tool call arguments that come in chunks, emitting progressive
tool_call events to the frontend.
"""
def __init__(self) -> None:
# Current active tool call being accumulated
# Format: {"id": str, "name": str, "args_string": str}
self._current_tool_call: Optional[Dict[str, str]] = None
def process_event(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Process a streaming event and return one or more response chunks
Args:
event: LangGraph streaming event (on_chat_model_stream)
Returns:
List of response chunks to send to frontend
Processing phases:
1. Initialize: Extract tool ID and name from tool_calls
2. Accumulate: Concatenate argument strings from tool_call_chunks
3. Complete: Mark as complete when finish_reason is "tool_calls" or "stop"
"""
event_type = event.get("event", "")
chunks = []
if event_type == "on_chat_model_stream":
chunk = event.get("data", {}).get("chunk", {})
# ========== Phase 1: Initialize tool call from tool_calls ==========
# Get metadata (ID and name) from tool_calls
if hasattr(chunk, "tool_calls") and chunk.tool_calls:
for tool_call in chunk.tool_calls:
if isinstance(tool_call, dict):
tc_id = tool_call.get("id")
tc_name = tool_call.get("name", "")
else:
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
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
self._current_tool_call = {
"id": tc_id,
"name": tc_name if tc_name else "UNKNOWN_TOOL",
"args_string": "",
}
# 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": ""
}
}
})
# ========== 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:
tool_data = self._current_tool_call
for tc_chunk in chunk.tool_call_chunks:
# Default to "" instead of None
if isinstance(tc_chunk, dict):
args_chunk = tc_chunk.get("args", "")
else:
args_chunk = getattr(tc_chunk, "args", "")
# Core: string concatenation
if isinstance(args_chunk, str):
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"]
}
}
})
# ========== 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
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
}
}
})
# Clear the current tool call state
self._current_tool_call = None
# 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")
})
return chunks
def reset(self) -> None:
"""Reset the accumulator state"""
self._current_tool_call = None