feat: add prompt opening behavior modes

This commit is contained in:
Xin Wang
2026-08-04 13:29:21 +08:00
parent 78bba090a2
commit 5bf5987fe4
11 changed files with 235 additions and 97 deletions

View File

@@ -132,6 +132,9 @@ class BaseBrain:
async def on_greeting_finished(self) -> None:
"""Continue startup after the shared greeting has actually played."""
async def on_interruption_processed(self) -> None:
"""Observe that an output interruption reached assistant aggregation."""
def prepare_greeting_context(
self,
greeting: str,
@@ -214,6 +217,8 @@ class Brain(Protocol):
async def on_greeting_finished(self) -> None: ...
async def on_interruption_processed(self) -> None: ...
def prepare_greeting_context(
self,
greeting: str,

View File

@@ -81,7 +81,7 @@ class PromptBrain(BaseBrain):
async def greeting(self, cfg: AssistantConfig) -> str:
# 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:
if self._opening_mode() == "confirmation":
return ""
return self._render_greeting(cfg)
@@ -155,8 +155,19 @@ class PromptBrain(BaseBrain):
async def on_connected(self, *, greeting_pending: bool = False) -> None:
self._greeting_pending = greeting_pending
should_block_input = (
self._has_opening_stage()
or (
greeting_pending
and self._opening_mode() == "playback"
)
or (
not greeting_pending
and self._entry_mode() == "generate"
)
)
if (
(self._has_opening_stage() or self._entry_mode() == "generate")
should_block_input
and self._runtime is not None
and self._runtime.set_input_enabled is not None
):
@@ -168,6 +179,16 @@ class PromptBrain(BaseBrain):
self._greeting_pending = False
await self._enter_prompt_if_ready()
async def on_interruption_processed(self) -> None:
if (
self._greeting_pending
and self._opening_mode() == "interruptible"
):
# The user's input now owns the first Agent turn. Do not also queue
# an empty-context entry reply when the interrupted greeting stops.
self._entry_dispatched = True
logger.debug("Prompt 可打断开场白已被用户输入接管")
async def on_client_ready(self) -> None:
if self._output is not None:
await self._output.mark_client_ready()
@@ -180,7 +201,11 @@ class PromptBrain(BaseBrain):
runtime = self._runtime
if runtime is None:
raise RuntimeError("PromptBrain 尚未初始化")
opening_message = self._opening_message()
opening_message = (
self._opening_message()
if self._opening_mode() == "confirmation"
else None
)
opening_actions = self._startup_actions("opening")
speech = (
self._render_greeting(self._cfg).strip()
@@ -238,6 +263,19 @@ class PromptBrain(BaseBrain):
value = startup.get("opening_message", startup.get("openingMessage"))
return value if isinstance(value, dict) else None
def _opening_mode(self) -> str:
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
value = str(startup.get("opening_mode", startup.get("openingMode")) or "")
if value in {"interruptible", "playback", "confirmation"}:
return value
# Before openingMode existed, an opening message meant confirmation;
# ordinary Prompt greetings were interruptible.
return (
"confirmation"
if self._opening_message() is not None
else "interruptible"
)
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 "")
@@ -245,7 +283,7 @@ class PromptBrain(BaseBrain):
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"
return "generate" if self._opening_mode() == "confirmation" else "wait_user"
async def _enter_prompt_if_ready(self) -> None:
if (
@@ -269,7 +307,7 @@ class PromptBrain(BaseBrain):
self._opening_input_blocked = False
def _has_opening_stage(self) -> bool:
return self._opening_message() is not None or bool(
return self._opening_mode() == "confirmation" or bool(
self._startup_actions("opening")
)
@@ -322,12 +360,6 @@ 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(

View File

@@ -38,7 +38,6 @@ class MessageStageSpec:
speech: str = ""
display: MessageDisplaySpec | None = None
completion_policy: MessageCompletionPolicy = MESSAGE_PLAYBACK
skip_speech_on_confirmation: bool = True
@dataclass(frozen=True)
@@ -55,7 +54,7 @@ class MessageStageRunner:
"""Run one atomic user-visible message stage.
Speech is queued before the client message is dispatched. A confirmation
may interrupt the remaining audio or keep playback as the final gate. A
stage completes when the user confirms, even if audio is still playing. A
speech-only stage completes at the real transport playback boundary.
"""
@@ -118,14 +117,11 @@ class MessageStageRunner:
return result
action = result.action
# A confirmation may either interrupt the remaining speech or
# acknowledge the dialog while keeping playback as the final gate.
# Confirmation is the gate. It deliberately does not wait for the
# audio completion future, so the user can continue immediately.
if (
playback_completion is not None
and (
not require_confirmation
or not spec.skip_speech_on_confirmation
)
and not require_confirmation
):
await playback_completion
@@ -191,10 +187,7 @@ class MessageStageRunner:
response_wait_mode=(
"session" if require_confirmation else "timeout"
),
interrupt_on_result=(
require_confirmation
and spec.skip_speech_on_confirmation
),
interrupt_on_result=require_confirmation,
)
except ClientToolError as exc:
return MessageStageResult(

View File

@@ -161,6 +161,9 @@ def bind_cascade_pipeline_events(
@assistant_aggregator.event_handler("on_interruption_processed")
async def on_interruption_processed(_aggregator):
brain_handler = getattr(brain, "on_interruption_processed", None)
if callable(brain_handler):
await brain_handler()
if client_tools is not None:
client_tools.on_interruption_processed()
if not pending_user_inputs: