feat: add prompt startup actions and shared vision config
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user