feat: add deterministic message interaction stages

This commit is contained in:
Xin Wang
2026-08-03 07:10:41 +08:00
parent 479a516546
commit 9daa46ed4d
26 changed files with 1356 additions and 414 deletions

View File

@@ -64,7 +64,7 @@ class CallEndPort(Protocol):
def arm_after_speech(self) -> None: ...
def track_speech(self) -> None: ...
def track_speech(self) -> Awaitable[None] | None: ...
async def arm_after_tracked_speech(self) -> None: ...

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from typing import Any
from uuid import uuid4
@@ -25,10 +26,18 @@ from services.brains.base import (
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_stage import (
MessageDisplaySpec,
MessageStageRunner,
MessageStageSpec,
)
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool
@@ -50,17 +59,31 @@ class PromptBrain(BaseBrain):
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._greeting_finished = True
self._preflight_finished = False
self._opening_started = False
self._opening_finished = False
self._opening_input_blocked = 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
# The built-in opening Message owns the greeting so speech and the
# client dialog can start as one atomic stage.
if self._opening_message() is not None:
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
@@ -77,12 +100,15 @@ class PromptBrain(BaseBrain):
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._greeting_finished = True
self._preflight_finished = False
self._opening_started = False
self._opening_finished = not bool(self._startup_actions("opening"))
self._opening_finished = not self._has_opening_stage()
self._opening_input_blocked = False
self._startup_failed = False
llm_tool_ids = (
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None
@@ -119,32 +145,83 @@ class PromptBrain(BaseBrain):
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")
self._has_opening_stage()
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
async def on_client_ready(self) -> None:
if self._output is not None:
await self._output.mark_client_ready()
if self._opening_started or self._opening_finished or self._startup_failed:
return
self._opening_started = True
runtime = self._runtime
if runtime is None:
raise RuntimeError("PromptBrain 尚未初始化")
opening_message = self._opening_message()
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:
succeeded = await self._run_startup_actions("opening")
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,
release_input_on_success=not bool(opening_actions),
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=runtime.set_input_enabled,
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
if not succeeded:
await self._fail_opening("必需的开场 Action 执行失败")
return
self._opening_finished = True
self._release_startup_gate_if_ready()
self._opening_input_blocked = False
async def on_greeting_finished(self) -> None:
self._greeting_finished = True
self._release_startup_gate_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 _has_opening_stage(self) -> bool:
return self._opening_message() is not None 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 {}
@@ -154,6 +231,78 @@ class PromptBrain(BaseBrain):
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(),
),
require_confirmation=True,
)
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")
@@ -170,17 +319,6 @@ class PromptBrain(BaseBrain):
)
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:
@@ -197,18 +335,6 @@ class PromptBrain(BaseBrain):
)
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

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from copy import deepcopy
from dataclasses import replace
from typing import Any
@@ -40,7 +41,14 @@ from services.action_runtime import (
ActionRunner,
ActionStatus,
)
from services.action_stage import ActionStageRunner, ActionStageSpec, StageAction
from services.knowledge import search as search_knowledge
from services.message_stage import (
MessageDisplaySpec,
MessageStageResult,
MessageStageRunner,
MessageStageSpec,
)
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool
@@ -110,6 +118,8 @@ class WorkflowBrain(BaseBrain):
self._store = DynamicVariableStore.from_config(cfg or AssistantConfig(type="workflow"))
self._tools = ToolExecutor(self._store)
self._actions = ActionRunner(self._tools)
self._action_stages = ActionStageRunner(self._actions)
self._message_stages = MessageStageRunner()
self._tool_by_id: dict[str, RuntimeTool] = {
tool.id: tool for tool in (cfg.tools if cfg else [])
}
@@ -126,11 +136,10 @@ class WorkflowBrain(BaseBrain):
self._output: WorkflowOutput | None = None
self._agent_stage: WorkflowAgentStage | None = None
self._ended = False
self._greeting_context_message: dict[str, str] | None = None
self._startup_waiting_for_greeting = False
async def greeting(self, cfg: AssistantConfig) -> str:
return self._engine.greeting(self._store) or cfg.greeting
async def greeting(self, _cfg: AssistantConfig) -> str:
"""Workflow opening speech belongs to an explicit Message or Agent."""
return ""
def system_prompt(self, cfg: AssistantConfig) -> str:
return self._store.render(self._engine.global_prompt())
@@ -151,6 +160,8 @@ class WorkflowBrain(BaseBrain):
self._tools,
is_session_ending=lambda: runtime.call_end.ending,
)
self._action_stages = ActionStageRunner(self._actions)
self._message_stages = MessageStageRunner(runtime.client_tools)
self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._router = WorkflowLLMRouter(cfg)
self._edge_evaluator = WorkflowEdgeEvaluator(
@@ -168,8 +179,6 @@ class WorkflowBrain(BaseBrain):
runtime=runtime,
)
self._ended = False
self._greeting_context_message = None
self._startup_waiting_for_greeting = False
self._manager = ConfiguredFlowManager(
worker=runtime.worker,
llm=runtime.llm,
@@ -179,15 +188,6 @@ class WorkflowBrain(BaseBrain):
)
self._manager.state["variables"] = self._store.values
def prepare_greeting_context(
self,
greeting: str,
context: LLMContext,
) -> dict[str, str] | None:
message = super().prepare_greeting_context(greeting, context)
self._greeting_context_message = deepcopy(message) if message else None
return message
async def on_connected(self, *, greeting_pending: bool = False) -> None:
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
await self._emit_node_active(self._engine.start_id)
@@ -198,39 +198,11 @@ class WorkflowBrain(BaseBrain):
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
self._startup_waiting_for_greeting = greeting_pending
if greeting_pending:
# Keep the Workflow on Start until the transport confirms that the
# shared greeting has finished. This prevents an initial Agent's
# fixed speech (or generated reply) from racing the greeting.
await self._manager.initialize(
self._passive_node_config(self._engine.start_id)
)
logger.info("工作流等待 Start 开场白播放完毕")
return
node_config = await self._initial_node_config()
await self._manager.initialize(node_config)
await self._after_node_activated(node_config)
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
async def on_greeting_finished(self) -> None:
"""Enter the first node only after Start's greeting reaches playback end."""
if not self._startup_waiting_for_greeting or self._ended:
return
self._startup_waiting_for_greeting = False
manager = self._require_manager()
if manager.current_node != self._engine.start_id:
return
node_config = await self._initial_node_config()
if node_config.get("name") == self._engine.start_id:
self._state.enter(self._engine.start_id, WorkflowStatus.WAITING_USER)
return
await manager.set_node_from_config(node_config)
await self._after_node_activated(node_config)
logger.info(f"Start 开场白结束,进入节点: {manager.current_node}")
async def _initial_node_config(self) -> NodeConfig:
"""Only a default-only Start advances before the first user turn."""
outgoing = self._engine.outgoing(self._engine.start_id)
@@ -409,7 +381,6 @@ class WorkflowBrain(BaseBrain):
return self._require_agent_stage().node_config(
node_id,
functions=functions,
greeting_context_message=self._greeting_context_message,
leading_messages=leading_messages,
)
@@ -455,8 +426,8 @@ class WorkflowBrain(BaseBrain):
*,
source: str = "workflow-speech",
node_id: str | None = None,
) -> None:
await self._require_output().speak(
) -> Awaitable[None] | None:
return await self._require_output().speak(
text,
source=source,
node_id=node_id,
@@ -700,6 +671,14 @@ class WorkflowBrain(BaseBrain):
outcome = await self._enter_action(node_id)
if not outcome.should_route:
return self._passive_node_config(node_id, context_messages)
elif node_type == "message":
message_result = await self._enter_message(node_id)
if not message_result.succeeded:
return self._passive_node_config(node_id, context_messages)
if message_result.speech:
context_messages.append(
{"role": "assistant", "content": message_result.speech}
)
elif node_type == "handoff":
await self._enter_handoff(node_id)
elif node_type == "start":
@@ -735,29 +714,36 @@ class WorkflowBrain(BaseBrain):
data = self._engine.data(node_id)
runtime = self._require_runtime()
invocation_id = self._actions.new_invocation_id()
block_user_input = data.get("userInputPolicy") == "block"
if block_user_input and runtime.set_input_enabled:
# Blocking only suppresses new audio/text input while the Action
# runs. It deliberately does not cancel the tool. The default
# queue policy leaves input enabled; the turn lock serializes any
# completed user turn until this automatic path has finished.
runtime.set_input_enabled(False)
tool_id = str(data.get("toolId") or "")
tool = self._tool_by_id.get(tool_id)
try:
await self._emit_trace(
"action_started",
nodeId=node_id,
invocationId=invocation_id,
toolId=tool_id,
toolType=tool.type if tool else None,
)
outcome = await self._actions.execute(
tool,
data.get("arguments") or {},
result_assignments=self._action_result_assignments(data),
invocation_id=invocation_id,
stage_result = await self._action_stages.run(
ActionStageSpec(
actions=(
StageAction(
id=node_id,
tool=tool,
arguments=data.get("arguments") or {},
result_assignments=self._action_result_assignments(data),
invocation_id=invocation_id,
),
),
input_policy=(
"block"
if data.get("userInputPolicy") == "block"
else "queue"
),
),
set_input_enabled=runtime.set_input_enabled,
on_started=lambda: self._emit_trace(
"action_started",
nodeId=node_id,
invocationId=invocation_id,
toolId=tool_id,
toolType=tool.type if tool else None,
),
)
outcome = stage_result.outcomes[0]
updated_variables = list(outcome.updated_variables)
if updated_variables:
await self._emit_variables(
@@ -770,13 +756,74 @@ class WorkflowBrain(BaseBrain):
self._set_last_action(outcome)
await self._emit_action_outcome(node_id, outcome)
raise
finally:
if block_user_input and runtime.set_input_enabled:
runtime.set_input_enabled(True)
self._set_last_action(outcome)
await self._emit_action_outcome(node_id, outcome)
return outcome
async def _enter_message(self, node_id: str) -> MessageStageResult:
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
await self._emit_node_active(node_id)
data = self._engine.data(node_id)
runtime = self._require_runtime()
speech = self._store.render(str(data.get("speech") or "")).strip()
show_message = bool(data.get("showMessage", False))
require_confirmation = bool(data.get("requireConfirmation", False))
display = (
MessageDisplaySpec(
title=self._store.render(
str(data.get("title") or "重要提示")
).strip(),
message=self._store.render(
str(data.get("message") or "")
).strip(),
confirm_label=self._store.render(
str(data.get("confirmLabel") or "确认")
).strip(),
)
if show_message
else None
)
result = await self._message_stages.run(
MessageStageSpec(
speech=speech,
display=display,
require_confirmation=require_confirmation,
),
speak=lambda content: self._queue_visible_speech(
content,
source="workflow-message-speech",
node_id=node_id,
),
set_input_enabled=runtime.set_input_enabled,
on_started=lambda: self._emit_trace(
"message_started",
nodeId=node_id,
hasSpeech=bool(speech),
showsMessage=show_message,
requiresConfirmation=require_confirmation,
),
)
if result.succeeded:
await self._emit_trace(
"message_completed",
nodeId=node_id,
action=result.action,
)
return result
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
await self._emit_trace(
"message_failed",
nodeId=node_id,
error=result.error or "Message 节点执行失败",
)
await self._require_output().emit_error(
result.error or "Message 节点执行失败",
node_id=node_id,
code="workflow_message_error",
)
return result
def _set_last_action(self, outcome: ActionOutcome) -> None:
legacy_status = {
ActionStatus.SUCCESS: "ok",