feat: separate prompt opening and entry behavior
This commit is contained in:
@@ -75,6 +75,8 @@ class PromptBrain(BaseBrain):
|
||||
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
|
||||
@@ -115,6 +117,8 @@ class PromptBrain(BaseBrain):
|
||||
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
|
||||
)
|
||||
@@ -150,18 +154,27 @@ class PromptBrain(BaseBrain):
|
||||
self._preflight_finished = True
|
||||
|
||||
async def on_connected(self, *, greeting_pending: bool = False) -> None:
|
||||
self._greeting_pending = greeting_pending
|
||||
if (
|
||||
self._has_opening_stage()
|
||||
(self._has_opening_stage() or self._entry_mode() == "generate")
|
||||
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_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:
|
||||
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
|
||||
@@ -169,7 +182,6 @@ class PromptBrain(BaseBrain):
|
||||
raise RuntimeError("PromptBrain 尚未初始化")
|
||||
opening_message = self._opening_message()
|
||||
opening_actions = self._startup_actions("opening")
|
||||
generate_after_confirmation = opening_message is not None
|
||||
speech = (
|
||||
self._render_greeting(self._cfg).strip()
|
||||
if opening_message is not None
|
||||
@@ -184,7 +196,8 @@ class PromptBrain(BaseBrain):
|
||||
speak=self._speak_opening,
|
||||
set_input_enabled=runtime.set_input_enabled,
|
||||
input_already_blocked=self._opening_input_blocked,
|
||||
# Keep the gate until the automatic first reply is queued.
|
||||
# Prompt owns the gate until its explicit entry behavior
|
||||
# has been dispatched.
|
||||
release_input_on_success=False,
|
||||
release_input_on_failure=False,
|
||||
)
|
||||
@@ -197,13 +210,7 @@ class PromptBrain(BaseBrain):
|
||||
if opening_actions:
|
||||
result = await self._action_stages.run(
|
||||
self._opening_actions_stage_spec(),
|
||||
# The Prompt opening lifecycle owns the input gate when a
|
||||
# confirmation message will trigger an automatic reply.
|
||||
set_input_enabled=(
|
||||
None
|
||||
if generate_after_confirmation
|
||||
else runtime.set_input_enabled
|
||||
),
|
||||
set_input_enabled=None,
|
||||
input_already_blocked=self._opening_input_blocked,
|
||||
release_input_on_failure=False,
|
||||
on_outcome=self._publish_opening_outcome,
|
||||
@@ -215,12 +222,7 @@ class PromptBrain(BaseBrain):
|
||||
self._startup_failed = True
|
||||
raise
|
||||
self._opening_finished = True
|
||||
self._opening_input_blocked = False
|
||||
if generate_after_confirmation and not runtime.call_end.ending:
|
||||
logger.debug("Prompt 开场确认完成,触发自动首句")
|
||||
await runtime.queue_frame(LLMRunFrame())
|
||||
if runtime.set_input_enabled is not None:
|
||||
runtime.set_input_enabled(True)
|
||||
await self._enter_prompt_if_ready()
|
||||
|
||||
async def _speak_opening(self, content: str) -> Awaitable[None] | None:
|
||||
if self._output is None:
|
||||
@@ -236,6 +238,36 @@ class PromptBrain(BaseBrain):
|
||||
value = startup.get("opening_message", startup.get("openingMessage"))
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
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_message() is not None 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_message() is not None or bool(
|
||||
self._startup_actions("opening")
|
||||
@@ -290,6 +322,12 @@ class PromptBrain(BaseBrain):
|
||||
).strip(),
|
||||
),
|
||||
completion_policy=MESSAGE_CONFIRMATION,
|
||||
skip_speech_on_confirmation=bool(
|
||||
config.get(
|
||||
"skip_speech_on_confirm",
|
||||
config.get("skipSpeechOnConfirm", True),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
async def _publish_opening_outcome(
|
||||
|
||||
@@ -38,6 +38,7 @@ class MessageStageSpec:
|
||||
speech: str = ""
|
||||
display: MessageDisplaySpec | None = None
|
||||
completion_policy: MessageCompletionPolicy = MESSAGE_PLAYBACK
|
||||
skip_speech_on_confirmation: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -54,7 +55,7 @@ class MessageStageRunner:
|
||||
"""Run one atomic user-visible message stage.
|
||||
|
||||
Speech is queued before the client message is dispatched. A confirmation
|
||||
stage completes when the user confirms, even if audio is still playing. A
|
||||
may interrupt the remaining audio or keep playback as the final gate. A
|
||||
speech-only stage completes at the real transport playback boundary.
|
||||
"""
|
||||
|
||||
@@ -117,11 +118,14 @@ class MessageStageRunner:
|
||||
return result
|
||||
action = result.action
|
||||
|
||||
# Confirmation is the gate. It deliberately does not wait for the
|
||||
# audio completion future, so the user can continue immediately.
|
||||
# A confirmation may either interrupt the remaining speech or
|
||||
# acknowledge the dialog while keeping playback as the final gate.
|
||||
if (
|
||||
playback_completion is not None
|
||||
and not require_confirmation
|
||||
and (
|
||||
not require_confirmation
|
||||
or not spec.skip_speech_on_confirmation
|
||||
)
|
||||
):
|
||||
await playback_completion
|
||||
|
||||
@@ -187,7 +191,10 @@ class MessageStageRunner:
|
||||
response_wait_mode=(
|
||||
"session" if require_confirmation else "timeout"
|
||||
),
|
||||
interrupt_on_result=require_confirmation,
|
||||
interrupt_on_result=(
|
||||
require_confirmation
|
||||
and spec.skip_speech_on_confirmation
|
||||
),
|
||||
)
|
||||
except ClientToolError as exc:
|
||||
return MessageStageResult(
|
||||
|
||||
Reference in New Issue
Block a user