feat: organize tools and enable qwen realtime calls

This commit is contained in:
Xin Wang
2026-08-14 00:15:00 +08:00
parent eb111a3890
commit 5be21f5ccc
71 changed files with 5771 additions and 2026 deletions
+3
View File
@@ -250,6 +250,7 @@ async def run_pipeline(
assistant_id: str | None = None,
channel: str = "webrtc",
protocol_adapter: PipelineProtocolAdapter | None = None,
client_tool_names: set[str] | None = None,
) -> None:
"""在给定 transport 上构建并运行管线,直到连接结束。
@@ -469,6 +470,7 @@ async def run_pipeline(
should_ignore_update=lambda: call_end.ending,
)
client_tools = ClientToolBroker()
client_tools.set_capabilities(client_tool_names)
# Confirmation nodes mute the user aggregator, which intentionally drops
# InterruptionFrame. Relay confirmation interruptions from the next
# processor instead so LLM, assistant aggregation, TTS, and output all see
@@ -1119,6 +1121,7 @@ async def run_realtime_pipeline(
on_state_change=queue_call_end_state,
)
client_tools = ClientToolBroker()
client_tools.set_capabilities(client_tool_names)
async def interrupt_for_client_tool_result() -> None:
"""Release confirmations after the realtime response is interrupted.
+4
View File
@@ -561,6 +561,10 @@ class RealtimeDynamicVariableProcessor(FrameProcessor):
if content:
self._brain.record_user_message(content)
await self._refresh_instructions()
elif event_type == "assistant-text-start":
await self._brain.on_assistant_text_start(
str(message.get("turn_id") or "")
)
elif event_type == "assistant-text-end":
await self._brain.on_assistant_text_end(
str(message.get("turn_id") or ""),
+53 -20
View File
@@ -52,6 +52,7 @@ SUPPORTED_TURN_DETECTION_MODES = frozenset({"none", "server_vad", "smart_turn"})
ExtraEventHandler = Callable[[dict[str, Any]], Awaitable[None] | None]
SpeechStartedHandler = Callable[[], Awaitable[None]]
ResponseDoneHandler = Callable[[str, bool], Awaitable[None]]
class QwenAudioRealtimeService(AIService):
@@ -117,6 +118,7 @@ class QwenAudioRealtimeService(AIService):
self._warned_input_sample_rate = False
self._response_active = False
self._active_response_id = ""
self._audio_suppressed = False
self._assistant_turn_id: str | None = None
self._assistant_text = ""
@@ -130,12 +132,14 @@ class QwenAudioRealtimeService(AIService):
self._tools: list[RealtimeTool] = []
self._tool_session = RealtimeToolSession(
self._send_tool_event,
wait_for_response_boundary=self._response_done.wait,
suppress_response=self._cancel_active_response,
)
self._fixed_speech_completion: asyncio.Future[None] | None = None
self._suppress_response_transcript = False
self._speech_started_handler: SpeechStartedHandler | None = None
self._response_done_handler: ResponseDoneHandler | None = None
self._function_names: dict[str, str] = {}
self._responses_with_tool_calls: set[str] = set()
async def start(self, frame: StartFrame) -> None:
await super().start(frame)
@@ -201,6 +205,7 @@ class QwenAudioRealtimeService(AIService):
return
if isinstance(frame, InterruptionFrame):
self._tool_session.invalidate_pending_follow_ups()
await self._cancel_active_response()
await self.push_frame(frame, direction)
@@ -209,6 +214,7 @@ class QwenAudioRealtimeService(AIService):
"""Append text to Qwen's conversation and optionally start a response."""
if not text:
return
self._tool_session.invalidate_pending_follow_ups()
await self._send_event(
{
"type": "conversation.item.create",
@@ -224,6 +230,7 @@ class QwenAudioRealtimeService(AIService):
async def interrupt(self) -> None:
"""Cancel current model output and notify the rest of the pipeline."""
self._tool_session.invalidate_pending_follow_ups()
await self._cancel_active_response()
await self.broadcast_interruption()
@@ -268,6 +275,12 @@ class QwenAudioRealtimeService(AIService):
) -> None:
self._speech_started_handler = handler
def set_response_done_handler(
self,
handler: ResponseDoneHandler | None,
) -> None:
self._response_done_handler = handler
async def speak(self, text: str) -> None:
"""Ask Qwen to speak a fixed greeting, then remove the hidden request.
@@ -345,7 +358,7 @@ class QwenAudioRealtimeService(AIService):
"type": "session.update",
"session": {
"instructions": instructions,
"tools": [tool.provider_schema() for tool in tools],
"tools": [tool.qwen_schema() for tool in tools],
"tool_choice": "auto",
},
},
@@ -375,7 +388,7 @@ class QwenAudioRealtimeService(AIService):
"output_audio_format": "pcm",
"turn_detection": self._turn_detection_config(),
"max_history_turns": self._max_history_turns,
"tools": [tool.provider_schema() for tool in self._tools],
"tools": [tool.qwen_schema() for tool in self._tools],
"tool_choice": "auto",
}
@@ -427,12 +440,14 @@ class QwenAudioRealtimeService(AIService):
self._response_done.set()
self._pending_events.clear()
self._response_active = False
self._active_response_id = ""
self._user_transcript_pending = False
self._user_transcript_item_id = ""
self._user_transcript_timestamp = ""
self._deferred_assistant_messages.clear()
self._tool_session.clear()
await self._tool_session.shutdown()
self._function_names.clear()
self._responses_with_tool_calls.clear()
self._entry_trigger_item_id = None
self._resolve_fixed_speech()
if websocket and websocket.state is State.OPEN:
@@ -458,6 +473,8 @@ class QwenAudioRealtimeService(AIService):
self._websocket = None
self._session_ready.clear()
self._response_done.set()
await self._tool_session.shutdown()
self._responses_with_tool_calls.clear()
self._greeting_request_item_id = None
self._entry_trigger_item_id = None
self._resolve_fixed_speech()
@@ -481,6 +498,10 @@ class QwenAudioRealtimeService(AIService):
await self._send_event(payload, wait_until_ready=False)
elif event_type == "response.created":
self._response_active = True
response = event.get("response")
self._active_response_id = str(
response.get("id") if isinstance(response, dict) else ""
)
self._response_done.clear()
self._audio_suppressed = False
elif event_type == "response.audio.delta":
@@ -513,6 +534,7 @@ class QwenAudioRealtimeService(AIService):
await self._release_deferred_assistant_messages()
elif event_type == "input_audio_buffer.speech_started":
user_turn_timestamp = time_now_iso8601()
self._tool_session.invalidate_pending_follow_ups()
await self._cancel_active_response()
await self.broadcast_interruption()
if self._speech_started_handler is not None:
@@ -526,7 +548,13 @@ class QwenAudioRealtimeService(AIService):
elif event_type == "response.done":
response = event.get("response")
status = response.get("status") if isinstance(response, dict) else None
response_id = str(
(response.get("id") if isinstance(response, dict) else "")
or event.get("response_id")
or self._active_response_id
)
interrupted = status in {"cancelled", "incomplete", "interrupted", "failed"}
response_content = self._assistant_text
self._response_active = False
await self.push_frame(TTSStoppedFrame())
await self._finish_assistant_text(interrupted=interrupted)
@@ -534,16 +562,22 @@ class QwenAudioRealtimeService(AIService):
await self._delete_entry_trigger()
self._resolve_fixed_speech()
self._response_done.set()
self._tool_session.finish_response(
response_id,
cancelled=interrupted,
)
had_tool_calls = response_id in self._responses_with_tool_calls
self._responses_with_tool_calls.discard(response_id)
if self._response_done_handler is not None and not had_tool_calls:
await self._response_done_handler(response_content, interrupted)
self._active_response_id = ""
elif event_type == "response.output_item.added":
self._remember_function_call(event)
elif event_type in {
"response.function_call_arguments.done",
"response.output_item.done",
}:
self.create_task(
self._handle_function_call_event(dict(event)),
name="qwen_audio_realtime_tool_call",
)
await self._handle_function_call_event(dict(event))
elif event_type == "error":
error = event.get("error")
message = error.get("message") if isinstance(error, dict) else str(error)
@@ -591,18 +625,17 @@ class QwenAudioRealtimeService(AIService):
)
if not name:
return
try:
await self._tool_session.handle_call(
name=name,
call_id=call_id,
arguments=source.get("arguments", event.get("arguments")),
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - background provider task
logger.exception(f"Qwen-Audio Realtime 工具调用处理失败:{exc}")
finally:
self._function_names.pop(call_id, None)
response_id = str(event.get("response_id") or self._active_response_id)
if response_id:
self._responses_with_tool_calls.add(response_id)
self._tool_session.submit_call(
response_id=response_id,
name=name,
call_id=call_id,
arguments=source.get("arguments", event.get("arguments")),
output_index=int(event.get("output_index") or 0),
)
self._function_names.pop(call_id, None)
def _remember_function_call(self, event: dict[str, Any]) -> None:
item = event.get("item")
+252 -58
View File
@@ -4,23 +4,29 @@ from __future__ import annotations
import asyncio
import json
from collections import deque
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from typing import Any, Literal
from loguru import logger
RealtimeToolFollowUp = Literal["continue", "none", "suppress"]
@dataclass(frozen=True)
class RealtimeTool:
"""Small JSON-schema tool definition understood by both providers."""
"""Provider-neutral JSON-schema function definition."""
name: str
description: str
properties: dict[str, Any] = field(default_factory=dict)
required: tuple[str, ...] = ()
def provider_schema(self) -> dict[str, Any]:
def openai_schema(self) -> dict[str, Any]:
"""Serialize the flat schema used by OpenAI-compatible providers."""
return {
"type": "function",
"name": self.name,
@@ -32,13 +38,26 @@ class RealtimeTool:
},
}
def qwen_schema(self) -> dict[str, Any]:
"""Serialize Qwen-Audio's documented nested function schema."""
flat = self.openai_schema()
return {
"type": "function",
"function": {
"name": flat["name"],
"description": flat["description"],
"parameters": flat["parameters"],
},
}
@dataclass(frozen=True)
class RealtimeToolResult:
"""Tool output plus whether the model should continue immediately."""
"""Tool output and the action taken after every call in one response."""
output: dict[str, Any]
continue_response: bool = True
follow_up: RealtimeToolFollowUp = "continue"
after_output: Callable[[], Awaitable[None]] | None = None
@@ -46,96 +65,271 @@ RealtimeToolDispatcher = Callable[
[str, dict[str, Any], str], Awaitable[RealtimeToolResult]
]
SendProviderEvent = Callable[[dict[str, Any]], Awaitable[None]]
WaitForResponseBoundary = Callable[[], Awaitable[Any]]
SuppressProviderResponse = Callable[[], Awaitable[None]]
@dataclass(frozen=True)
class _PendingCall:
name: str
call_id: str
arguments: dict[str, Any]
output_index: int
argument_error: str = ""
@dataclass
class _ResponseCalls:
calls: dict[str, _PendingCall] = field(default_factory=dict)
cancelled: bool = False
processing: bool = False
class RealtimeToolSession:
"""Serialize provider calls and answer every call id at most once."""
"""Coordinate all function calls emitted by one provider response.
Qwen may emit more than one function call in a response. Calls are collected
until ``response.done``, executed in output order, and followed by at most
one new model response. Duplicate argument/output-item events are ignored by
call id.
"""
def __init__(
self,
send_event: SendProviderEvent,
*,
wait_for_response_boundary: WaitForResponseBoundary | None = None,
suppress_response: SuppressProviderResponse | None = None,
) -> None:
self._send_event = send_event
self._wait_for_response_boundary = wait_for_response_boundary
self._suppress_response = suppress_response
self._dispatcher: RealtimeToolDispatcher | None = None
self._handled_call_ids: set[str] = set()
self._lock = asyncio.Lock()
self._handled_call_order: deque[str] = deque()
self._finished_response_ids: set[str] = set()
self._finished_response_order: deque[str] = deque()
self._responses: dict[str, _ResponseCalls] = {}
self._tasks: set[asyncio.Task[None]] = set()
def set_dispatcher(self, dispatcher: RealtimeToolDispatcher | None) -> None:
self._dispatcher = dispatcher
async def handle_call(
def submit_call(
self,
*,
response_id: str,
name: str,
call_id: str,
arguments: str | dict[str, Any] | None,
output_index: int = 0,
) -> None:
"""Register one complete call without yielding to the receive loop."""
if not call_id:
logger.warning("Realtime function call 缺少 call_id,已忽略")
return
async with self._lock:
if call_id in self._handled_call_ids:
return
self._handled_call_ids.add(call_id)
parsed = self._parse_arguments(arguments)
try:
if self._dispatcher is None:
result = RealtimeToolResult(
{"status": "error", "message": "当前会话未注册工具处理器"}
)
else:
result = await self._dispatcher(name, parsed, call_id)
except Exception as exc: # noqa: BLE001 - return tool errors to provider
logger.exception(f"Realtime 工具 {name} 执行失败:{exc}")
result = RealtimeToolResult(
{
"status": "error",
"message": f"工具执行失败:{type(exc).__name__}",
}
)
if call_id in self._handled_call_ids:
return
self._handled_call_ids.add(call_id)
self._handled_call_order.append(call_id)
if len(self._handled_call_order) > 4096:
expired_call_id = self._handled_call_order.popleft()
self._handled_call_ids.discard(expired_call_id)
group_id = response_id or f"response_{call_id}"
if group_id in self._finished_response_ids:
return
group = self._responses.setdefault(group_id, _ResponseCalls())
if group.cancelled or group.processing:
return
parsed_arguments, argument_error = self._parse_arguments(arguments)
group.calls[call_id] = _PendingCall(
name=name,
call_id=call_id,
arguments=parsed_arguments,
output_index=output_index,
argument_error=argument_error,
)
await self._send_event(
def finish_response(self, response_id: str, *, cancelled: bool = False) -> None:
"""Seal one response and process its calls in a background task."""
if not response_id:
if len(self._responses) != 1:
return
response_id = next(iter(self._responses))
self._remember_finished_response(response_id)
group = self._responses.get(response_id)
if group is None:
return
group.cancelled = group.cancelled or cancelled
if group.processing:
return
group.processing = True
task = asyncio.create_task(
self._process_response(response_id, group),
name=f"realtime_tools_{response_id}",
)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def wait_pending(self) -> None:
"""Wait for current batches; primarily useful for orderly shutdown/tests."""
tasks = tuple(self._tasks)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def cancel_response(self, response_id: str) -> None:
"""Prevent unstarted calls and any follow-up for a stale response."""
group = self._responses.get(response_id)
if group is not None:
group.cancelled = True
def invalidate_pending_follow_ups(self) -> None:
"""Make in-flight tool groups stale without cancelling active I/O."""
for group in self._responses.values():
group.cancelled = True
def cancel_all(self) -> None:
"""Cancel pending work when the provider connection closes."""
for group in self._responses.values():
group.cancelled = True
for task in tuple(self._tasks):
task.cancel()
self._responses.clear()
async def _process_response(
self,
response_id: str,
group: _ResponseCalls,
) -> None:
try:
results: list[RealtimeToolResult] = []
calls = sorted(
group.calls.values(),
key=lambda item: (item.output_index, item.call_id),
)
for call in calls:
if group.cancelled:
await self._send_output(
call.call_id,
{"status": "cancelled", "message": "调用已被新的用户输入取代"},
)
continue
result = await self._dispatch(call)
results.append(result)
await self._send_output(call.call_id, result.output)
if group.cancelled:
return
for result in results:
if result.after_output is not None:
try:
await result.after_output()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - isolate callbacks
logger.exception(f"Realtime 工具结果回调失败:{exc}")
follow_up = self._combined_follow_up(results)
if follow_up == "suppress" and self._suppress_response is not None:
await self._suppress_response()
elif follow_up == "continue":
await self._send_event({"type": "response.create"})
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - background provider task
logger.exception(f"Realtime response 工具处理失败:{exc}")
finally:
self._responses.pop(response_id, None)
async def _send_output(self, call_id: str, output: dict[str, Any]) -> None:
await self._send_event(
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps(
output,
ensure_ascii=False,
separators=(",", ":"),
),
},
}
)
async def _dispatch(self, call: _PendingCall) -> RealtimeToolResult:
if call.argument_error:
return RealtimeToolResult(
{"status": "error", "message": call.argument_error}
)
if self._dispatcher is None:
return RealtimeToolResult(
{"status": "error", "message": "当前会话未注册工具处理器"}
)
try:
return await self._dispatcher(
call.name,
call.arguments,
call.call_id,
)
except Exception as exc: # noqa: BLE001 - return stable errors to provider
logger.exception(f"Realtime 工具 {call.name} 执行失败:{exc}")
return RealtimeToolResult(
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps(
result.output,
ensure_ascii=False,
separators=(",", ":"),
),
},
"status": "error",
"message": f"工具执行失败:{type(exc).__name__}",
}
)
# Function-call argument events arrive before the provider's
# response.done boundary. Starting another response sooner is
# rejected by both supported realtime providers. The provider
# receive loop stays free while this background task waits.
if self._wait_for_response_boundary is not None:
await self._wait_for_response_boundary()
if result.after_output is not None:
await result.after_output()
if result.continue_response:
await self._send_event({"type": "response.create"})
@staticmethod
def _combined_follow_up(
results: list[RealtimeToolResult],
) -> RealtimeToolFollowUp:
actions = {result.follow_up for result in results}
if "suppress" in actions:
return "suppress"
if "continue" in actions:
return "continue"
return "none"
def clear(self) -> None:
self.cancel_all()
self._handled_call_ids.clear()
self._handled_call_order.clear()
self._finished_response_ids.clear()
self._finished_response_order.clear()
async def shutdown(self) -> None:
"""Cancel and join background calls before releasing a connection."""
tasks = tuple(self._tasks)
self.clear()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def _remember_finished_response(self, response_id: str) -> None:
if response_id in self._finished_response_ids:
return
self._finished_response_ids.add(response_id)
self._finished_response_order.append(response_id)
if len(self._finished_response_order) > 512:
expired = self._finished_response_order.popleft()
self._finished_response_ids.discard(expired)
@staticmethod
def _parse_arguments(
arguments: str | dict[str, Any] | None,
) -> dict[str, Any]:
) -> tuple[dict[str, Any], str]:
if isinstance(arguments, dict):
return dict(arguments)
if not arguments:
return {}
return dict(arguments), ""
if arguments is None or arguments == "":
return {}, ""
try:
parsed = json.loads(arguments)
except (TypeError, json.JSONDecodeError):
return {}
return dict(parsed) if isinstance(parsed, dict) else {}
return {}, "工具参数不是有效的 JSON 对象"
if not isinstance(parsed, dict):
return {}, "工具参数必须是 JSON 对象"
return dict(parsed), ""
+32 -20
View File
@@ -79,12 +79,12 @@ class StepFunRealtimeService(AIService):
self._response_done.set()
self._pending_events: list[dict[str, Any]] = []
self._assistant_turn_id: str | None = None
self._active_response_id = ""
self._assistant_text = ""
self._assistant_timestamp = ""
self._tools: list[RealtimeTool] = []
self._tool_session = RealtimeToolSession(
self._send_tool_event,
wait_for_response_boundary=self._response_done.wait,
)
self._fixed_speech_completion: asyncio.Future[None] | None = None
self._fixed_speech_request_item_id: str | None = None
@@ -151,6 +151,7 @@ class StepFunRealtimeService(AIService):
await self.send_text(text, run_immediately=frame.run_llm is not False)
return
if isinstance(frame, InterruptionFrame):
self._tool_session.invalidate_pending_follow_ups()
await self._send_event({"type": "response.cancel"}, wait_until_ready=False)
await self._finish_assistant_text(interrupted=True)
self._resolve_fixed_speech()
@@ -158,6 +159,7 @@ class StepFunRealtimeService(AIService):
await self.push_frame(frame, direction)
async def send_text(self, text: str, *, run_immediately: bool = True) -> None:
self._tool_session.invalidate_pending_follow_ups()
await self._send_event(
{
"type": "conversation.item.create",
@@ -172,6 +174,7 @@ class StepFunRealtimeService(AIService):
await self._send_event({"type": "response.create"})
async def interrupt(self) -> None:
self._tool_session.invalidate_pending_follow_ups()
await self._send_event({"type": "response.cancel"}, wait_until_ready=False)
await self._finish_assistant_text(interrupted=True)
self._resolve_fixed_speech()
@@ -291,7 +294,7 @@ class StepFunRealtimeService(AIService):
self._websocket = None
self._session_ready.clear()
self._response_done.set()
self._tool_session.clear()
await self._tool_session.shutdown()
self._function_names.clear()
self._fixed_speech_request_item_id = None
self._entry_trigger_item_id = None
@@ -328,6 +331,7 @@ class StepFunRealtimeService(AIService):
self._websocket = None
self._session_ready.clear()
self._response_done.set()
await self._tool_session.shutdown()
self._fixed_speech_request_item_id = None
self._entry_trigger_item_id = None
self._resolve_fixed_speech()
@@ -344,6 +348,10 @@ class StepFunRealtimeService(AIService):
for payload in pending:
await self._send_event(payload, wait_until_ready=False)
elif event_type == "response.created":
response = event.get("response")
self._active_response_id = str(
response.get("id") if isinstance(response, dict) else ""
)
self._response_done.clear()
elif event_type == "response.audio.delta":
audio = event.get("delta")
@@ -369,6 +377,7 @@ class StepFunRealtimeService(AIService):
elif event_type == "conversation.item.input_audio_transcription.completed":
await self._send_transcript("user", str(event.get("transcript") or ""))
elif event_type == "input_audio_buffer.speech_started":
self._tool_session.invalidate_pending_follow_ups()
await self._send_event({"type": "response.cancel"}, wait_until_ready=False)
await self.broadcast_interruption()
self._resolve_fixed_speech()
@@ -387,16 +396,23 @@ class StepFunRealtimeService(AIService):
await self._delete_entry_trigger()
self._resolve_fixed_speech()
self._response_done.set()
response_id = str(
(response.get("id") if isinstance(response, dict) else "")
or event.get("response_id")
or self._active_response_id
)
self._tool_session.finish_response(
response_id,
cancelled=interrupted,
)
self._active_response_id = ""
elif event_type == "response.output_item.added":
self._remember_function_call(event)
elif event_type in {
"response.function_call_arguments.done",
"response.output_item.done",
}:
self.create_task(
self._handle_function_call_event(dict(event)),
name="stepfun_realtime_tool_call",
)
await self._handle_function_call_event(dict(event))
elif event_type == "error":
error = event.get("error")
message = error.get("message") if isinstance(error, dict) else str(error)
@@ -415,7 +431,7 @@ class StepFunRealtimeService(AIService):
"input_audio_format": "pcm16",
"output_audio_format": "pcm16",
"turn_detection": self._turn_detection_config(),
"tools": [tool.provider_schema() for tool in self._tools],
"tools": [tool.openai_schema() for tool in self._tools],
"tool_choice": "auto",
},
},
@@ -458,7 +474,7 @@ class StepFunRealtimeService(AIService):
"type": "session.update",
"session": {
"instructions": instructions,
"tools": [tool.provider_schema() for tool in tools],
"tools": [tool.openai_schema() for tool in tools],
"tool_choice": "auto",
},
},
@@ -493,18 +509,14 @@ class StepFunRealtimeService(AIService):
)
if not name:
return
try:
await self._tool_session.handle_call(
name=name,
call_id=call_id,
arguments=source.get("arguments", event.get("arguments")),
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - background provider task
logger.exception(f"StepFun Realtime 工具调用处理失败:{exc}")
finally:
self._function_names.pop(call_id, None)
self._tool_session.submit_call(
response_id=str(event.get("response_id") or self._active_response_id),
name=name,
call_id=call_id,
arguments=source.get("arguments", event.get("arguments")),
output_index=int(event.get("output_index") or 0),
)
self._function_names.pop(call_id, None)
def _remember_function_call(self, event: dict[str, Any]) -> None:
item = event.get("item")