feat(workflow): add edge-tool routing and realtime runtime

This commit is contained in:
Xin Wang
2026-08-04 22:29:04 +08:00
parent 1902ffc240
commit 59588ba88d
25 changed files with 1952 additions and 107 deletions

View File

@@ -0,0 +1,128 @@
"""Provider-neutral function calling for speech-to-speech sessions."""
from __future__ import annotations
import asyncio
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
@dataclass(frozen=True)
class RealtimeTool:
"""Small JSON-schema tool definition understood by both providers."""
name: str
description: str
properties: dict[str, Any] = field(default_factory=dict)
required: tuple[str, ...] = ()
def provider_schema(self) -> dict[str, Any]:
return {
"type": "function",
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": self.properties,
"required": list(self.required),
},
}
@dataclass(frozen=True)
class RealtimeToolResult:
"""Tool output plus whether the model should continue immediately."""
output: dict[str, Any]
continue_response: bool = True
after_output: Callable[[], Awaitable[None]] | None = None
RealtimeToolDispatcher = Callable[
[str, dict[str, Any], str], Awaitable[RealtimeToolResult]
]
SendProviderEvent = Callable[[dict[str, Any]], Awaitable[None]]
class RealtimeToolSession:
"""Serialize provider calls and answer every call id at most once."""
def __init__(self, send_event: SendProviderEvent) -> None:
self._send_event = send_event
self._dispatcher: RealtimeToolDispatcher | None = None
self._handled_call_ids: set[str] = set()
self._lock = asyncio.Lock()
def set_dispatcher(self, dispatcher: RealtimeToolDispatcher | None) -> None:
self._dispatcher = dispatcher
async def handle_call(
self,
*,
name: str,
call_id: str,
arguments: str | dict[str, Any] | None,
) -> None:
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__}",
}
)
await self._send_event(
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps(
result.output,
ensure_ascii=False,
separators=(",", ":"),
),
},
}
)
if result.after_output is not None:
await result.after_output()
if result.continue_response:
await self._send_event({"type": "response.create"})
def clear(self) -> None:
self._handled_call_ids.clear()
@staticmethod
def _parse_arguments(
arguments: str | dict[str, Any] | None,
) -> dict[str, Any]:
if isinstance(arguments, dict):
return dict(arguments)
if not arguments:
return {}
try:
parsed = json.loads(arguments)
except (TypeError, json.JSONDecodeError):
return {}
return dict(parsed) if isinstance(parsed, dict) else {}