Files
ai-video-fullstack/backend/services/brains/prompt_realtime_tools.py
T

531 lines
20 KiB
Python
Raw Normal View History

"""Prompt-owned tools for Qwen speech-to-speech sessions."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from time import monotonic
from typing import Any
from uuid import uuid4
from loguru import logger
from models import AssistantConfig, RuntimeTool, RuntimeToolBinding
from pipecat.frames.frames import OutputTransportMessageUrgentFrame
from pipecat.utils.time import time_now_iso8601
from services.brains.base import RealtimeBrainRuntime
from services.pipecat.call_lifecycle import playback_marker_for
from services.pipecat.realtime_tools import RealtimeTool, RealtimeToolResult
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.state_expressions import evaluate_update_rules
from services.system_tools import (
RESERVED_TOOL_NAMES,
runtime_system_tools,
state_update_properties,
system_tool_kind,
)
from services.tool_executor import ToolExecutor
from services.tool_policy import policy_for_tool
ToolHandler = Callable[[dict[str, Any]], Awaitable[RealtimeToolResult]]
SUPPORTED_BUILT_INS = frozenset(
{"end_conversation", "update_state", "request_human_handoff"}
)
MAX_PUBLIC_TOOL_ERROR = 512
class PromptRealtimeToolController:
"""Install and execute Prompt tools without modifying Pipecat itself."""
def __init__(
self,
*,
cfg: AssistantConfig,
store: DynamicVariableStore,
runtime: RealtimeBrainRuntime,
) -> None:
self._cfg = cfg
self._store = store
self._runtime = runtime
self._executor = ToolExecutor(store, client_tools=runtime.client_tools)
self._tools_by_id = {tool.id: tool for tool in cfg.tools}
self._handlers: dict[str, ToolHandler] = {}
self._input_locks = 0
self._handoff_requested = False
self._waiting_generated_end = False
async def start(self) -> None:
if self._cfg.realtime_interface_type != "qwen-audio-realtime":
if self._cfg.tool_bindings or runtime_system_tools(
self._cfg.built_in_tools
):
raise ValueError(
"当前 Realtime Provider 不支持 Prompt 工具调用"
)
return
tools = await self._build_tools()
self._runtime.realtime.set_tool_dispatcher(self.dispatch_tool)
await self._runtime.realtime.update_session(
self._store.render(self._cfg.prompt),
tools,
)
async def dispatch_tool(
self,
name: str,
arguments: dict[str, Any],
_call_id: str,
) -> RealtimeToolResult:
handler = self._handlers.get(name)
if handler is None:
return RealtimeToolResult(
{"status": "error", "message": f"当前会话没有工具:{name}"}
)
return await handler(arguments)
async def on_user_speech_started(self) -> None:
"""Cancel only an explicitly interruptible pending ending."""
if self._runtime.call_end.cancel_pending_end():
self._waiting_generated_end = False
self._set_input_enabled(True, force=True)
def on_assistant_text_start(self) -> None:
"""Reset transport speech tracking for the response that just started."""
self._runtime.call_end.begin_response()
async def on_assistant_text_end(
self,
content: str,
interrupted: bool,
) -> None:
if not self._waiting_generated_end:
return
self._waiting_generated_end = False
if self._runtime.call_end.ending:
await self._runtime.call_end.finish_after_current_speech(
has_text=bool(content.strip()) and not interrupted
)
async def on_response_done(self, content: str, interrupted: bool) -> None:
"""Finish a generated ending even when the response has no transcript."""
if self._waiting_generated_end:
await self.on_assistant_text_end(content, interrupted)
async def _build_tools(self) -> list[RealtimeTool]:
result: list[RealtimeTool] = []
binding_by_id = {
binding.tool_id: binding for binding in self._cfg.tool_bindings
}
async def add(
tool: RealtimeTool,
handler: ToolHandler,
*,
source_id: str,
) -> None:
if tool.name in self._handlers:
await self._emit_trace(
"tool_skipped",
toolId=source_id,
functionName=tool.name,
reason="duplicate_function_name",
)
return
self._handlers[tool.name] = handler
result.append(tool)
for binding in self._cfg.tool_bindings:
runtime_tool = self._tools_by_id.get(binding.tool_id)
if runtime_tool is None or runtime_tool.type not in {
"http",
"mcp",
"client",
}:
continue
if runtime_tool.function_name in RESERVED_TOOL_NAMES:
raise ValueError(
f"外部工具使用了平台保留函数名:{runtime_tool.function_name}"
)
if (
runtime_tool.type == "client"
and (
self._runtime.client_tools is None
or getattr(
self._runtime.client_tools,
"supports",
lambda _name: True,
)(runtime_tool.function_name)
is False
)
):
logger.warning(
f"跳过客户端未上报的 Realtime 工具: "
f"{runtime_tool.function_name}"
)
await self._emit_trace(
"tool_skipped",
toolId=runtime_tool.id,
functionName=runtime_tool.function_name,
toolType=runtime_tool.type,
reason="client_capability_missing",
)
continue
built = self._external_tool(runtime_tool, binding_by_id[binding.tool_id])
await add(*built, source_id=runtime_tool.id)
for runtime_tool in runtime_system_tools(self._cfg.built_in_tools):
kind = system_tool_kind(runtime_tool.definition or {})
if kind == "skip_turn":
raise ValueError("Prompt Realtime 暂不支持 skip_turn")
if kind not in SUPPORTED_BUILT_INS:
continue
built = self._system_tool(runtime_tool)
if built is not None:
await add(*built, source_id=runtime_tool.id)
return result
def _external_tool(
self,
tool: RuntimeTool,
binding: RuntimeToolBinding,
) -> tuple[RealtimeTool, ToolHandler]:
properties, required = self._executor.schema_parts(tool, binding)
policy = policy_for_tool(tool)
async def handler(arguments: dict[str, Any]) -> RealtimeToolResult:
invocation_id = f"tool_{uuid4().hex[:20]}"
started_at = monotonic()
await self._emit_trace(
"tool_started",
invocationId=invocation_id,
toolId=tool.id,
toolName=tool.name,
functionName=tool.function_name,
toolType=tool.type,
)
if not policy.allow_interruptions:
self._lock_input()
try:
result = await self._executor.execute(
tool,
arguments,
binding=binding,
)
changed = list(result.get("updated_variables") or [])
if changed:
await self._publish_variables("tool", changed)
await self._refresh_instructions()
await self._emit_trace(
"tool_completed"
if result.get("status") == "ok"
else "tool_failed",
invocationId=invocation_id,
toolId=tool.id,
toolName=tool.name,
functionName=tool.function_name,
toolType=tool.type,
status=str(result.get("status") or "unknown"),
durationMs=max(0, round((monotonic() - started_at) * 1000)),
updatedVariables=changed,
resultKeys=sorted(str(name) for name in result if name != "data"),
)
follow_up = (
"continue" if policy.runs_llm_after_result else "none"
)
return RealtimeToolResult(
result,
follow_up=follow_up,
)
except Exception as exc: # noqa: BLE001 - sanitize provider errors
message = self._public_tool_error(exc)
await self._emit_trace(
"tool_failed",
invocationId=invocation_id,
toolId=tool.id,
toolName=tool.name,
functionName=tool.function_name,
toolType=tool.type,
status="error",
durationMs=max(0, round((monotonic() - started_at) * 1000)),
error=message,
)
follow_up = (
"continue" if policy.runs_llm_after_result else "none"
)
return RealtimeToolResult(
{"status": "error", "message": message},
follow_up=follow_up,
)
finally:
if not policy.allow_interruptions:
self._unlock_input()
return (
RealtimeTool(
name=tool.function_name,
description=tool.description or f"调用 {tool.name}",
properties=properties,
required=tuple(required),
),
handler,
)
def _system_tool(
self,
tool: RuntimeTool,
) -> tuple[RealtimeTool, ToolHandler] | None:
kind = system_tool_kind(tool.definition or {})
config = (tool.definition or {}).get("config") or {}
if kind == "update_state":
rules = list(config.get("updates") or [])
async def update_state(arguments: dict[str, Any]) -> RealtimeToolResult:
try:
values = evaluate_update_rules(
rules,
dict(self._store.values),
arguments,
)
changed = self._store.assign_declared_many(values)
except (DynamicVariableError, ValueError) as exc:
return RealtimeToolResult(
{
"status": "error",
"message": f"状态更新失败:{self._public_error(exc)}",
}
)
if changed:
await self._publish_variables("update_state", changed)
await self._refresh_instructions()
return RealtimeToolResult(
{
"status": "success",
"changed": changed,
"variables": self._store.public_values(),
}
)
return (
RealtimeTool(
name=tool.function_name,
description=tool.description or "更新已授权的动态变量。",
properties=state_update_properties(
self._cfg.dynamic_variable_definitions,
rules=rules,
),
),
update_state,
)
if kind == "request_human_handoff":
async def request_handoff(
arguments: dict[str, Any],
) -> RealtimeToolResult:
reason = str(arguments.get("reason") or "human_handoff")
if not self._handoff_requested:
self._handoff_requested = True
self._store.values["system__handoff_status"] = "requested"
await self._runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "handoff-requested",
"source": "prompt-system-tool",
"reason": reason,
"message": "用户请求转接人工服务。",
}
)
)
await self._publish_variables(
"human_handoff",
["system__handoff_status"],
)
await self._refresh_instructions()
return RealtimeToolResult(
{
"status": "requested",
"action": "human_handoff_requested",
"message": "人工接管请求已提交,请告知用户正在等待人工响应。",
}
)
return (
RealtimeTool(
name=tool.function_name,
description=tool.description or "提交人工接管请求。",
properties={
"reason": {"type": "string", "description": "转接原因"}
},
),
request_handoff,
)
if kind == "end_conversation":
capture_reason = bool(config.get("capture_reason", True))
allow_end_interruption = bool(
config.get("allow_end_interruption", False)
)
message_type = str(config.get("message_type") or "none")
custom_message = str(config.get("custom_message") or "").strip()
async def end_conversation(
arguments: dict[str, Any],
) -> RealtimeToolResult:
if self._runtime.call_end.ending:
return RealtimeToolResult(
{"status": "success", "action": "ending_call"},
follow_up="suppress",
)
reason = str(
arguments.get("reason") or "end_conversation"
).strip()
self._runtime.call_end.begin(
reason,
interruptible=allow_end_interruption,
)
if not allow_end_interruption:
self._set_input_enabled(False, force=True)
uses_custom_message = (
message_type == "custom" and bool(custom_message)
)
if uses_custom_message:
return RealtimeToolResult(
{"status": "success", "action": "ending_call"},
follow_up="suppress",
after_output=lambda: self._play_end_message(custom_message),
)
self._waiting_generated_end = True
return RealtimeToolResult(
{
"status": "success",
"action": "ending_call",
"message": "请用一句简短、礼貌的结束语结束对话。",
}
)
properties = (
{
"reason": {
"type": "string",
"description": "结束本次通话的简短原因。",
}
}
if capture_reason
else {}
)
return (
RealtimeTool(
name=tool.function_name,
description=tool.description or "礼貌结束本次对话。",
properties=properties,
required=("reason",) if capture_reason else (),
),
end_conversation,
)
return None
async def _play_end_message(self, content: str) -> None:
if not self._runtime.call_end.ending:
return
turn_id = uuid4().hex
timestamp = time_now_iso8601()
for message in (
{"type": "assistant-text-start", "turn_id": turn_id, "timestamp": timestamp},
{"type": "assistant-text-delta", "turn_id": turn_id, "delta": content},
{
"type": "assistant-text-end",
"turn_id": turn_id,
"content": content,
"interrupted": False,
},
):
await self._runtime.queue_frame(
OutputTransportMessageUrgentFrame(message=message)
)
track_speech = getattr(self._runtime.call_end, "track_speech", None)
playback_completion = track_speech() if callable(track_speech) else None
provider_completion = await self._runtime.realtime.speak_fixed(
content,
suppress_transcript=True,
)
marker = playback_marker_for(playback_completion)
if marker is not None:
if provider_completion is not None:
await provider_completion
await self._runtime.queue_frame(marker)
marker.completion.mark_queued()
await self._runtime.call_end.arm_after_tracked_speech()
await playback_completion
return
self._runtime.call_end.arm_after_speech()
async def _refresh_instructions(self) -> None:
await self._runtime.realtime.update_instructions(
self._store.render(self._cfg.prompt)
)
async def _publish_variables(self, reason: str, changed: list[str]) -> None:
await self._runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "session-variables",
"reason": reason,
"variables": self._store.public_values(),
"changed": changed,
}
)
)
async def _emit_trace(self, event: str, **details: Any) -> None:
try:
await self._runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "workflow-event",
"eventId": f"wfe_{uuid4().hex[:20]}",
"event": event,
"timestamp": time_now_iso8601(),
"sessionId": self._runtime.session_id,
"source": "prompt-realtime",
**details,
}
)
)
except Exception as exc: # noqa: BLE001 - trace must not break tools
logger.warning(f"发送 Prompt Realtime 工具轨迹失败:{exc}")
def _lock_input(self) -> None:
self._input_locks += 1
self._set_input_enabled(False)
def _unlock_input(self) -> None:
self._input_locks = max(0, self._input_locks - 1)
if self._input_locks == 0 and not self._runtime.call_end.ending:
self._set_input_enabled(True)
def _set_input_enabled(self, enabled: bool, *, force: bool = False) -> None:
if self._runtime.set_input_enabled is None:
return
if force or enabled is False or self._input_locks == 0:
self._runtime.set_input_enabled(enabled)
@staticmethod
def _public_error(exc: Exception) -> str:
message = str(exc).strip() or type(exc).__name__
return message[:MAX_PUBLIC_TOOL_ERROR]
@staticmethod
def _public_tool_error(exc: Exception) -> str:
"""Return actionable errors without exposing URLs, payloads, or secrets."""
message = str(exc).strip()
if "超时" in message:
return "工具调用超时"
if message.startswith("客户端未注册工具"):
return message[:MAX_PUBLIC_TOOL_ERROR]
if any(token in message for token in ("参数", "变量", "绑定")):
return message[:MAX_PUBLIC_TOOL_ERROR]
return "工具调用失败,请稍后重试"