feat: organize tools and enable qwen realtime calls
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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), ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user