fix(workflow): prevent fixed speech continuation

This commit is contained in:
Xin Wang
2026-08-03 13:02:31 +08:00
parent 4c43e167db
commit ef434c80b2
3 changed files with 63 additions and 16 deletions

View File

@@ -42,6 +42,7 @@ from services.action_runtime import (
ActionStatus, ActionStatus,
) )
from services.action_stage import ActionStageRunner, ActionStageSpec, StageAction from services.action_stage import ActionStageRunner, ActionStageSpec, StageAction
from services.fixed_speech import fixed_speech_context_message
from services.knowledge import search as search_knowledge from services.knowledge import search as search_knowledge
from services.message_policy import ( from services.message_policy import (
MESSAGE_COMPLETION_POLICIES, MESSAGE_COMPLETION_POLICIES,
@@ -794,9 +795,9 @@ class WorkflowBrain(BaseBrain):
source="workflow-edge-transition", source="workflow-edge-transition",
node_id=str(edge.get("target") or "") or None, node_id=str(edge.get("target") or "") or None,
) )
context_messages.append( context_message = fixed_speech_context_message(content)
{"role": "assistant", "content": content} if context_message is not None:
) context_messages.append(context_message)
return await self._resolve_path( return await self._resolve_path(
str(edge.get("target") or ""), str(edge.get("target") or ""),
leading_messages=context_messages, leading_messages=context_messages,
@@ -828,10 +829,11 @@ class WorkflowBrain(BaseBrain):
if triggering_user_message if triggering_user_message
else {"role": "user", "content": triggering_user_text} else {"role": "user", "content": triggering_user_text}
) )
agent_messages = [ # Fixed speech is represented by system facts. Put those
current_user_message, # facts before the triggering user turn so RESET contexts
*context_messages, # still end with the real user input rather than a control
] # message.
agent_messages = [*context_messages, current_user_message]
return self._agent_config(node_id, agent_messages) return self._agent_config(node_id, agent_messages)
if node_type == "end": if node_type == "end":
await self._enter_end(node_id) await self._enter_end(node_id)
@@ -871,9 +873,9 @@ class WorkflowBrain(BaseBrain):
source="workflow-edge-transition", source="workflow-edge-transition",
node_id=target_id or None, node_id=target_id or None,
) )
context_messages.append( context_message = fixed_speech_context_message(content)
{"role": "assistant", "content": content} if context_message is not None:
) context_messages.append(context_message)
node_id = str(edge.get("target") or "") node_id = str(edge.get("target") or "")
raise RuntimeError("工作流连续自动跳转超过安全上限") raise RuntimeError("工作流连续自动跳转超过安全上限")
@@ -974,9 +976,9 @@ class WorkflowBrain(BaseBrain):
dict(message) for message in continuation.context_messages dict(message) for message in continuation.context_messages
] ]
if result.speech: if result.speech:
context_messages.append( context_message = fixed_speech_context_message(result.speech)
{"role": "assistant", "content": result.speech} if context_message is not None:
) context_messages.append(context_message)
if not self._engine.has_outgoing(continuation.node_id): if not self._engine.has_outgoing(continuation.node_id):
self._state.enter( self._state.enter(

View File

@@ -12,6 +12,20 @@ from services.brains.base import BrainRuntime
from services.runtime_variables import DynamicVariableStore from services.runtime_variables import DynamicVariableStore
FIXED_SPEECH_CONTEXT_MARKER = "[会话事实:以下固定消息已向用户播报]"
def fixed_speech_context_message(content: str) -> dict[str, str] | None:
"""Keep deterministic speech in context without inviting assistant continuation."""
text = content.strip()
if not text:
return None
return {
"role": "system",
"content": f"{FIXED_SPEECH_CONTEXT_MARKER}\n{text}",
}
class FixedSpeechOutput: class FixedSpeechOutput:
"""Display and synthesize fixed speech without waiting for playback.""" """Display and synthesize fixed speech without waiting for playback."""

View File

@@ -30,6 +30,7 @@ from services.brains.dify_llm import (
normalize_api_base, normalize_api_base,
) )
from services.brains.workflow_brain import ConfiguredFlowManager, WorkflowBrain from services.brains.workflow_brain import ConfiguredFlowManager, WorkflowBrain
from services.fixed_speech import FIXED_SPEECH_CONTEXT_MARKER
from services.runtime_variables import prepare_dynamic_config from services.runtime_variables import prepare_dynamic_config
from services.action_runtime import ActionOutcome, ActionStatus from services.action_runtime import ActionOutcome, ActionStatus
from services.workflow.models import ( from services.workflow.models import (
@@ -1723,7 +1724,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
{ {
"id": "agent1", "id": "agent1",
"type": "agent", "type": "agent",
"data": {"prompt": "收集基本信息"}, "data": {
"prompt": "收集基本信息",
"entryMode": "generate",
},
}, },
{ {
"id": "middle", "id": "middle",
@@ -1846,6 +1850,21 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
if manager.current_node == "agent1": if manager.current_node == "agent1":
break break
self.assertEqual(manager.current_node, "agent1") self.assertEqual(manager.current_node, "agent1")
self.assertEqual(
manager.configs[-1]["task_messages"],
[
{
"role": "system",
"content": f"{FIXED_SPEECH_CONTEXT_MARKER}\n欢迎使用。",
}
],
)
self.assertFalse(
any(
message["role"] == "assistant"
for message in manager.configs[-1]["task_messages"]
)
)
# The user-turn processor must return while the second Message is # The user-turn processor must return while the second Message is
# still waiting for its transport playback boundary. # still waiting for its transport playback boundary.
@@ -1868,8 +1887,13 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual( self.assertEqual(
manager.configs[-1]["task_messages"], manager.configs[-1]["task_messages"],
[ [
{
"role": "system",
"content": (
f"{FIXED_SPEECH_CONTEXT_MARKER}\n现在进入信息确认。"
),
},
{"role": "user", "content": "基本信息已经收集完成"}, {"role": "user", "content": "基本信息已经收集完成"},
{"role": "assistant", "content": "现在进入信息确认。"},
], ],
) )
@@ -2550,7 +2574,14 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
for frame in worker.frames for frame in worker.frames
if isinstance(frame, LLMMessagesAppendFrame) if isinstance(frame, LLMMessagesAppendFrame)
and frame.messages and frame.messages
== [{"role": "assistant", "content": "正在为你结束流程"}] == [
{
"role": "system",
"content": (
f"{FIXED_SPEECH_CONTEXT_MARKER}\n正在为你结束流程"
),
}
]
] ]
self.assertTrue(transition_context_frames) self.assertTrue(transition_context_frames)
transition_events = [ transition_events = [