feat: add prompt startup actions and shared vision config

This commit is contained in:
Xin Wang
2026-08-01 23:31:14 +08:00
parent b747144ff1
commit 0331f8cd07
22 changed files with 1238 additions and 344 deletions

View File

@@ -2,9 +2,11 @@
from __future__ import annotations
import asyncio
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 OutputTransportMessageUrgentFrame, TTSSpeakFrame
@@ -22,11 +24,19 @@ from services.brains.base import (
BrainSpec,
SessionVariableUpdate,
)
from services.action_runtime import (
ActionInvocationCancelled,
ActionRunner,
ActionStatus,
)
from services.runtime_variables import DynamicVariableStore
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",
@@ -39,8 +49,15 @@ class PromptBrain(BaseBrain):
self._dynamic_enabled = True
self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store)
self._actions = ActionRunner(self._tools)
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._runtime: BrainRuntime | None = None
self._waiting_for_generated_end_speech = False
self._greeting_finished = True
self._preflight_finished = False
self._opening_started = False
self._opening_finished = False
self._startup_failed = False
async def greeting(self, cfg: AssistantConfig) -> str:
return self._store.render(cfg.greeting) if self._dynamic_enabled else cfg.greeting
@@ -56,7 +73,17 @@ class PromptBrain(BaseBrain):
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
self._runtime = runtime
self._tools.set_client_tools(runtime.client_tools)
self._actions = ActionRunner(
self._tools,
is_session_ending=lambda: runtime.call_end.ending,
)
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._waiting_for_generated_end_speech = False
self._greeting_finished = True
self._preflight_finished = False
self._opening_started = False
self._opening_finished = not bool(self._startup_actions("opening"))
self._startup_failed = False
schemas: list[FunctionSchema] = []
for tool in cfg.tools:
if tool.type == "end_call":
@@ -74,6 +101,122 @@ class PromptBrain(BaseBrain):
)
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_finished = not greeting_pending
if (
self._startup_actions("opening")
and self._runtime is not None
and self._runtime.set_input_enabled is not None
):
self._runtime.set_input_enabled(False)
async def on_client_ready(self) -> None:
if self._opening_started or self._opening_finished or self._startup_failed:
return
self._opening_started = True
try:
succeeded = await self._run_startup_actions("opening")
except ActionInvocationCancelled:
self._startup_failed = True
raise
if not succeeded:
await self._fail_opening("必需的开场 Action 执行失败")
return
self._opening_finished = True
self._release_startup_gate_if_ready()
async def on_greeting_finished(self) -> None:
self._greeting_finished = True
self._release_startup_gate_if_ready()
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
]
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 phase == "opening" and self._runtime is not None:
await self._runtime.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "startup-action-result",
"actionId": action_id,
"phase": phase,
"outcome": outcome.trace_payload(),
}
)
)
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
def _release_startup_gate_if_ready(self) -> None:
runtime = self._runtime
if (
runtime is not None
and runtime.set_input_enabled is not None
and self._greeting_finished
and self._opening_finished
and not self._startup_failed
and not runtime.call_end.ending
):
runtime.set_input_enabled(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