Files
ai-video-fullstack/backend/services/brains/prompt_brain.py
2026-08-10 13:49:24 +08:00

793 lines
30 KiB
Python

"""Local prompt assistant, including prompt-only reusable tools."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from time import monotonic
from typing import Any
from uuid import uuid4
from loguru import logger
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import (
LLMRunFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.llm_service import (
FunctionCallParams,
FunctionCallResultProperties,
)
from pipecat.utils.time import time_now_iso8601
from services.brains.base import (
BaseBrain,
BrainRuntime,
BrainSpec,
SessionVariableUpdate,
)
from services.action_runtime import (
ActionOutcome,
ActionInvocationCancelled,
ActionRunner,
ActionStatus,
)
from services.action_stage import ActionStageRunner, ActionStageSpec, StageAction
from services.fixed_speech import FixedSpeechOutput
from services.message_policy import MESSAGE_CONFIRMATION
from services.message_stage import (
MessageDisplaySpec,
MessageStageRunner,
MessageStageSpec,
)
from services.runtime_variables import DynamicVariableError, DynamicVariableStore
from services.system_tools import state_update_properties, system_tool_kind
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool
PREFLIGHT_TIMEOUT_SECONDS = 30
class PromptBrain(BaseBrain):
spec = BrainSpec(
type="prompt",
supported_runtime_modes=frozenset({"pipeline", "realtime"}),
owns_context=True,
)
def __init__(self, cfg: AssistantConfig):
self._cfg = cfg
self._dynamic_enabled = True
self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store)
self._actions = ActionRunner(self._tools)
self._action_stages = ActionStageRunner(self._actions)
self._message_stages = MessageStageRunner()
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._runtime: BrainRuntime | None = None
self._output: FixedSpeechOutput | None = None
self._waiting_for_generated_end_speech = False
self._preflight_finished = False
self._opening_started = False
self._opening_finished = False
self._opening_input_blocked = False
self._startup_failed = False
self._greeting_pending = False
self._entry_dispatched = False
async def greeting(self, cfg: AssistantConfig) -> str:
# The built-in opening Message owns the greeting so speech and the
# client dialog can start as one atomic stage.
if self._opening_mode() == "confirmation":
return ""
return self._render_greeting(cfg)
def _render_greeting(self, cfg: AssistantConfig) -> str:
return (
self._store.render(cfg.greeting)
if self._dynamic_enabled
else cfg.greeting
)
def system_prompt(self, cfg: AssistantConfig) -> str:
return self._store.render(cfg.prompt) if self._dynamic_enabled else cfg.prompt
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
from services.pipecat.service_factory import create_llm
return create_llm(cfg)
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
self._runtime = runtime
if runtime.tool_executor_factory is not None:
self._tools = runtime.tool_executor_factory(self._store)
self._tools.set_client_tools(runtime.client_tools)
self._actions = ActionRunner(
self._tools,
is_session_ending=lambda: runtime.call_end.ending,
)
self._action_stages = ActionStageRunner(self._actions)
self._message_stages = MessageStageRunner(runtime.client_tools)
self._output = FixedSpeechOutput(self._store, runtime)
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._waiting_for_generated_end_speech = False
self._preflight_finished = False
self._opening_started = False
self._opening_finished = not self._has_opening_stage()
self._opening_input_blocked = False
self._startup_failed = False
self._greeting_pending = False
self._entry_dispatched = False
llm_tool_ids = (
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None
)
schemas: list[FunctionSchema] = []
registered_names: set[str] = set()
for tool in cfg.tools:
if llm_tool_ids is not None and tool.id not in llm_tool_ids:
continue
if tool.type == "system":
schema, handler = self._make_system_tool(tool, runtime)
elif tool.type in {"http", "mcp", "client"}:
schema, handler = self._make_remote_tool(tool, runtime)
else:
continue
if schema.name in registered_names:
logger.warning(
f"跳过工具 {tool.id}: 函数名 {schema.name} 已被占用"
)
continue
schemas.append(schema)
registered_names.add(schema.name)
policy = policy_for_tool(tool)
runtime.llm.register_function(
tool.function_name,
handler,
cancel_on_interruption=policy.cancel_on_interruption,
)
runtime.set_tools(schemas)
async def run_preflight(self) -> None:
if self._preflight_finished:
return
try:
async with asyncio.timeout(PREFLIGHT_TIMEOUT_SECONDS):
succeeded = await self._run_startup_actions("preflight")
except TimeoutError as exc:
raise RuntimeError("Prompt preflight 超过 30 秒安全上限") from exc
if not succeeded:
raise RuntimeError("必需的 Prompt preflight Action 执行失败")
self._preflight_finished = True
async def on_connected(self, *, greeting_pending: bool = False) -> None:
self._greeting_pending = greeting_pending
should_block_input = (
self._has_opening_stage()
or (
greeting_pending
and self._opening_mode() == "playback"
)
or (
not greeting_pending
and self._entry_mode() == "generate"
)
)
if (
should_block_input
and self._runtime is not None
and self._runtime.set_input_enabled is not None
):
self._runtime.set_input_enabled(False)
self._opening_input_blocked = True
await self._enter_prompt_if_ready()
async def on_greeting_finished(self) -> None:
self._greeting_pending = False
await self._enter_prompt_if_ready()
async def on_interruption_processed(self) -> None:
if (
self._greeting_pending
and self._opening_mode() == "interruptible"
):
# The user's input now owns the first Agent turn. Do not also queue
# an empty-context entry reply when the interrupted greeting stops.
self._entry_dispatched = True
logger.debug("Prompt 可打断开场白已被用户输入接管")
async def on_client_ready(self) -> None:
if self._output is not None:
await self._output.mark_client_ready()
if self._startup_failed:
return
if self._opening_started or self._opening_finished:
await self._enter_prompt_if_ready()
return
self._opening_started = True
runtime = self._runtime
if runtime is None:
raise RuntimeError("PromptBrain 尚未初始化")
opening_message = (
self._opening_message()
if self._opening_mode() == "confirmation"
else None
)
opening_actions = self._startup_actions("opening")
speech = (
self._render_greeting(self._cfg).strip()
if opening_message is not None
else ""
)
if speech:
self.prepare_greeting_context(speech, runtime.context)
try:
if opening_message is not None:
message_result = await self._message_stages.run(
self._opening_message_stage_spec(speech, opening_message),
speak=self._speak_opening,
set_input_enabled=runtime.set_input_enabled,
input_already_blocked=self._opening_input_blocked,
# Prompt owns the gate until its explicit entry behavior
# has been dispatched.
release_input_on_success=False,
release_input_on_failure=False,
)
if not message_result.succeeded:
await self._fail_opening(
message_result.error or "开场消息显示失败"
)
return
if opening_actions:
result = await self._action_stages.run(
self._opening_actions_stage_spec(),
set_input_enabled=None,
input_already_blocked=self._opening_input_blocked,
release_input_on_failure=False,
on_outcome=self._publish_opening_outcome,
)
if not result.succeeded:
await self._fail_opening("必需的开场 Action 执行失败")
return
except ActionInvocationCancelled:
self._startup_failed = True
raise
self._opening_finished = True
await self._enter_prompt_if_ready()
async def _speak_opening(self, content: str) -> Awaitable[None] | None:
if self._output is None:
raise RuntimeError("Prompt 固定播报输出尚未初始化")
return await self._output.speak(
content,
source="prompt-opening-speech",
record_history=False,
)
def _opening_message(self) -> dict[str, Any] | None:
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
value = startup.get("opening_message", startup.get("openingMessage"))
return value if isinstance(value, dict) else None
def _opening_mode(self) -> str:
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
value = str(startup.get("opening_mode", startup.get("openingMode")) or "")
if value in {"interruptible", "playback", "confirmation"}:
return value
# Before openingMode existed, an opening message meant confirmation;
# ordinary Prompt greetings were interruptible.
return (
"confirmation"
if self._opening_message() is not None
else "interruptible"
)
def _entry_mode(self) -> str:
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
value = str(startup.get("entry_mode", startup.get("entryMode")) or "")
if value in {"wait_user", "generate"}:
return value
# Raw runtime configs saved before entryMode existed generated a first
# reply after their opening confirmation. Keep that legacy behavior.
return "generate" if self._opening_mode() == "confirmation" else "wait_user"
async def _enter_prompt_if_ready(self) -> None:
if (
self._entry_dispatched
or self._startup_failed
or not self._opening_finished
or self._greeting_pending
):
return
runtime = self._runtime
if runtime is None or runtime.call_end.ending:
return
self._entry_dispatched = True
entry_mode = self._entry_mode()
if entry_mode == "generate":
logger.debug("Prompt 开场阶段完成,按进入行为触发自动首句")
await runtime.queue_frame(LLMRunFrame())
if self._opening_input_blocked and runtime.set_input_enabled is not None:
runtime.set_input_enabled(True)
self._opening_input_blocked = False
def _has_opening_stage(self) -> bool:
return self._opening_mode() == "confirmation" or bool(
self._startup_actions("opening")
)
def _startup_actions(self, phase: str) -> list[dict[str, Any]]:
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
return [
action
for action in startup.get("actions") or []
if isinstance(action, dict) and action.get("phase", "opening") == phase
]
def _opening_actions_stage_spec(self) -> ActionStageSpec:
actions = tuple(
StageAction(
id=str(action.get("id") or "startup_action"),
tool=self._tool_by_id.get(
str(action.get("tool_id") or action.get("toolId") or "")
),
arguments=action.get("arguments") or {},
required=bool(action.get("required", True)),
invocation_id=self._actions.new_invocation_id(),
)
for action in self._startup_actions("opening")
)
return ActionStageSpec(
actions=actions,
input_policy="block",
)
def _opening_message_stage_spec(
self,
speech: str,
config: dict[str, Any],
) -> MessageStageSpec:
return MessageStageSpec(
speech=speech,
display=MessageDisplaySpec(
title=self._store.render(
str(config.get("title") or "重要提示")
).strip(),
message=self._store.render(
str(config.get("message") or "")
).strip(),
confirm_label=self._store.render(
str(
config.get("confirm_label")
or config.get("confirmLabel")
or "确认"
)
).strip(),
),
completion_policy=MESSAGE_CONFIRMATION,
)
async def _publish_opening_outcome(
self,
action: StageAction,
outcome: ActionOutcome,
) -> None:
if outcome.updated_variables:
self._refresh_prompt()
if self._runtime is not None:
await self._runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "startup-action-result",
"actionId": action.id,
"phase": "opening",
"outcome": outcome.trace_payload(),
}
)
)
if outcome.status == ActionStatus.FAILURE and action.required:
logger.warning(
f"必需的 Prompt opening Action 失败: "
f"action={action.id} error={outcome.error}"
)
elif outcome.status == ActionStatus.FAILURE:
logger.warning(
f"忽略可选 Prompt opening Action 失败: "
f"action={action.id} error={outcome.error}"
)
async def _run_startup_actions(self, phase: str) -> bool:
for action in self._startup_actions(phase):
action_id = str(action.get("id") or "startup_action")
tool_id = str(action.get("tool_id") or action.get("toolId") or "")
tool = self._tool_by_id.get(tool_id)
invocation_id = self._actions.new_invocation_id()
logger.info(
f"执行 Prompt {phase} Action: action={action_id} tool={tool_id}"
)
outcome = await self._actions.execute(
tool,
action.get("arguments") or {},
invocation_id=invocation_id,
)
if outcome.updated_variables:
self._refresh_prompt()
if outcome.status == ActionStatus.SUCCESS:
continue
if outcome.status == ActionStatus.CANCELLED:
return False
if bool(action.get("required", True)):
logger.warning(
f"必需的 Prompt {phase} Action 失败: "
f"action={action_id} error={outcome.error}"
)
return False
logger.warning(
f"忽略可选 Prompt {phase} Action 失败: "
f"action={action_id} error={outcome.error}"
)
return True
async def _fail_opening(self, message: str) -> None:
self._startup_failed = True
runtime = self._runtime
if runtime is None or runtime.call_end.ending:
return
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={"type": "startup-action-error", "message": message}
)
)
runtime.call_end.begin("startup_action_failed")
await runtime.call_end.finish()
def record_user_message(self, content: str) -> None:
if not self._dynamic_enabled:
return
self._store.record("user", content)
self._refresh_prompt()
async def on_session_update(
self,
dynamic_variables: dict[str, Any],
) -> SessionVariableUpdate:
changed = self._store.assign_declared_many(dynamic_variables)
if changed:
self._refresh_prompt()
return SessionVariableUpdate(
changed=changed,
dynamic_variables=self._store.public_values(),
)
async def on_assistant_text_start(self, _turn_id: str) -> None:
if self._runtime is not None:
self._runtime.call_end.begin_response()
async def on_assistant_text_end(
self,
_turn_id: str,
content: str,
interrupted: bool,
) -> None:
if content and not interrupted:
self._store.record("agent", content, completed_agent_turn=True)
self._refresh_prompt()
if (
self._waiting_for_generated_end_speech
and self._runtime is not None
and self._runtime.call_end.ending
):
self._waiting_for_generated_end_speech = False
await self._runtime.call_end.finish_after_current_speech(
has_text=bool(content.strip()) and not interrupted
)
def _refresh_prompt(self) -> None:
if self._dynamic_enabled and self._runtime is not None:
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
def _make_remote_tool(self, tool, runtime: BrainRuntime):
properties, required = self._tools.schema_parts(tool)
self._tools.register_secrets(tool)
policy = policy_for_tool(tool)
async def return_result(params: FunctionCallParams, result: dict) -> None:
if not policy.runs_llm_after_result:
await params.result_callback(
result,
properties=FunctionCallResultProperties(run_llm=False),
)
else:
await params.result_callback(result)
async def call_tool(params: FunctionCallParams) -> None:
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,
)
try:
result = await self._tools.execute(tool, dict(params.arguments or {}))
if result["updated_variables"]:
self._refresh_prompt()
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=list(result.get("updated_variables") or []),
resultKeys=sorted(str(name) for name in result if name != "data"),
)
await return_result(params, result)
except (ToolExecutionError, ValueError) as 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=str(exc)[:2048],
)
await return_result(
params,
{"status": "error", "message": f"工具调用失败: {exc}"},
)
schema = FunctionSchema(
name=tool.function_name,
description=tool.description or f"调用 {tool.name}",
properties=properties,
required=required,
)
return schema, call_tool
async def _emit_trace(self, event: str, **details: Any) -> None:
runtime = self._runtime
if runtime is None:
return
try:
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "workflow-event",
"eventId": f"wfe_{uuid4().hex[:20]}",
"event": event,
"timestamp": time_now_iso8601(),
"sessionId": runtime.session_id,
**details,
}
)
)
except Exception as exc: # noqa: BLE001 - trace must not alter execution
logger.warning(f"发送 Prompt 工具轨迹失败,不影响当前调用: {exc}")
def _make_end_call_tool(self, tool, runtime: BrainRuntime):
config = (tool.definition or {}).get("config") or {}
message_type = str(config.get("message_type") or "none")
custom_message = str(config.get("custom_message") or "").strip()
capture_reason = bool(config.get("capture_reason", True))
async def end_call(params: FunctionCallParams) -> None:
reason = str(params.arguments.get("reason") or "end_call_tool").strip()
uses_custom_message = message_type == "custom" and bool(custom_message)
self._waiting_for_generated_end_speech = not uses_custom_message
runtime.call_end.begin(reason)
await params.result_callback(
{"status": "success", "action": "ending_call"},
properties=FunctionCallResultProperties(run_llm=False),
)
if not uses_custom_message:
# The model may have already streamed a spoken goodbye before
# invoking this tool. Decide at assistant-text-end whether to
# wait for that TTS audio or finish immediately for tool-only calls.
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": custom_message,
},
{
"type": "assistant-text-end",
"turn_id": turn_id,
"content": custom_message,
"interrupted": False,
},
):
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(message=message)
)
runtime.call_end.arm_after_speech()
await runtime.queue_frame(
TTSSpeakFrame(custom_message, append_to_context=False)
)
properties = (
{
"reason": {
"type": "string",
"description": "结束本次通话的简短原因。",
}
}
if capture_reason
else {}
)
schema = FunctionSchema(
name=tool.function_name,
description=tool.description or "结束当前通话。",
properties=properties,
required=["reason"] if capture_reason else [],
)
return schema, end_call
# ---------- System 工具资源 ----------
def _make_system_tool(self, tool, runtime: BrainRuntime):
kind = system_tool_kind(tool.definition or {})
if not kind:
raise ValueError(f"系统工具 {tool.id} 缺少有效 kind")
if kind == "end_conversation":
schema, handler = self._make_end_call_tool(tool, runtime)
elif kind == "update_state":
schema, handler = self._make_update_state_tool(tool, runtime)
elif kind == "skip_turn":
schema, handler = self._make_skip_turn_tool(tool)
elif kind == "request_human_handoff":
schema, handler = self._make_handoff_tool(tool, runtime)
else:
raise ValueError(f"未知系统工具: {kind}")
if runtime.tool_executor_factory is None:
return schema, handler
async def mock_system_tool(params: FunctionCallParams) -> None:
result = await self._tools.execute(
tool,
dict(params.arguments or {}),
)
await params.result_callback(result)
return schema, mock_system_tool
def _make_update_state_tool(self, tool, runtime: BrainRuntime):
"""更新已声明的动态变量(会话状态),并让模型继续当前回答。"""
writable = state_update_properties(self._cfg.dynamic_variable_definitions)
async def update_state(params: FunctionCallParams) -> None:
state = dict(params.arguments or {})
try:
changed = self._store.assign_declared_many(state)
except DynamicVariableError as exc:
await params.result_callback(
{"status": "error", "message": f"状态更新失败: {exc}"}
)
return
if changed:
self._refresh_prompt()
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "session-variables",
"reason": "update_state",
"variables": self._store.public_values(),
"changed": changed,
}
)
)
await params.result_callback(
{
"status": "success",
"changed": changed,
"variables": self._store.public_values(),
}
)
schema = FunctionSchema(
name=tool.function_name,
description=tool.description or (
"静默更新本次对话中已经声明并明确列出的动态变量。"
"只提交本轮获得或确认的信息,更新后继续当前回答。"
),
properties=writable,
required=[],
)
return schema, update_state
def _make_skip_turn_tool(self, tool):
"""跳过当前轮次,不生成任何语音回复。"""
async def skip_turn(params: FunctionCallParams) -> None:
reason = str(params.arguments.get("reason") or "").strip()
result = {"status": "success", "action": "skip_turn"}
if reason:
result["reason"] = reason
await params.result_callback(
result,
properties=FunctionCallResultProperties(run_llm=False),
)
schema = FunctionSchema(
name=tool.function_name,
description=tool.description or (
"跳过当前轮次,不生成任何语音回复。"
"仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。"
),
properties={
"reason": {
"type": "string",
"description": "跳过本轮的原因(可选)。",
}
},
required=[],
)
return schema, skip_turn
def _make_handoff_tool(self, tool, runtime: BrainRuntime):
"""提交人工接管请求;请求完成前保持当前 AI 会话可用。"""
async def request_human_handoff(params: FunctionCallParams) -> None:
reason = str(
params.arguments.get("reason") or "human_handoff"
).strip()
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "handoff-requested",
"source": "prompt-system-tool",
"reason": reason,
"message": "用户请求转接人工服务。",
}
)
)
await params.result_callback(
{
"status": "requested",
"action": "human_handoff_requested",
"message": "人工接管请求已提交,请告知用户正在等待人工响应。",
}
)
schema = FunctionSchema(
name=tool.function_name,
description=tool.description or (
"提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法"
"解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续"
"回复用户并说明正在等待人工响应。"
),
properties={
"reason": {
"type": "string",
"description": "转接人工的原因。",
}
},
required=[],
)
return schema, request_human_handoff