feat: add deterministic message interaction stages
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user