diff --git a/backend/services/brains/workflow_brain.py b/backend/services/brains/workflow_brain.py index 52b7970..c1333c6 100644 --- a/backend/services/brains/workflow_brain.py +++ b/backend/services/brains/workflow_brain.py @@ -562,16 +562,24 @@ class WorkflowBrain(BaseBrain): return await self._emit_node_active(node_id) - data = self._engine.data(node_id) - entry_mode = str(data.get("entryMode") or "wait_user") - should_run = entry_mode == "generate" or bool(triggering_user_text) - if should_run: + if self._agent_runs_on_entry(node_id): self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT) await self._require_runtime().queue_frame(LLMRunFrame()) return + # The turn that selected this node belongs to the previous stage. + # A strict wait must not leave it pending until the next user message. + if triggering_user_text: + self._state.consume_user_turn() self._state.enter(node_id, WorkflowStatus.WAITING_USER) + def _agent_runs_on_entry(self, node_id: str) -> bool: + """Only explicit generate mode may start a reply on node entry.""" + entry_mode = str( + self._engine.data(node_id).get("entryMode") or "wait_user" + ) + return entry_mode == "generate" + async def _activate_node_config( self, node_config: NodeConfig, @@ -708,10 +716,7 @@ class WorkflowBrain(BaseBrain): return configured if node_type != "agent": return node_config - entry_mode = str( - self._engine.data(node_id).get("entryMode") or "wait_user" - ) - should_run = entry_mode == "generate" or bool(triggering_user_text) + should_run = self._agent_runs_on_entry(node_id) configured = dict(node_config) configured["respond_immediately"] = should_run configured["pre_actions"] = [ @@ -719,6 +724,8 @@ class WorkflowBrain(BaseBrain): "type": ConfiguredFlowManager.ENTRY_ACTION_TYPE, "node_id": node_id, "should_run": should_run, + "consume_triggering_turn": bool(triggering_user_text) + and not should_run, "handler": self._activate_from_flow_transition, } ] @@ -735,6 +742,8 @@ class WorkflowBrain(BaseBrain): if action.get("should_run"): self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT) else: + if action.get("consume_triggering_turn"): + self._state.consume_user_turn() self._state.enter(node_id, WorkflowStatus.WAITING_USER) def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None: diff --git a/backend/tests/test_brains.py b/backend/tests/test_brains.py index f8a3943..4f77e25 100644 --- a/backend/tests/test_brains.py +++ b/backend/tests/test_brains.py @@ -867,6 +867,66 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(brain._flow_tool(timeout_tool, "start").timeout_secs, 7.0) self.assertIsNone(brain._flow_tool(session_tool, "start").timeout_secs) + async def test_flow_transition_honors_strict_agent_entry_mode(self): + brain = WorkflowBrain( + { + "specVersion": 3, + "settings": {}, + "nodes": [ + {"id": "start", "type": "start", "data": {}}, + { + "id": "waiting", + "type": "agent", + "data": {"entryMode": "wait_user"}, + }, + { + "id": "immediate", + "type": "agent", + "data": {"entryMode": "generate"}, + }, + ], + "edges": [], + } + ) + + async def queue_frame(_frame): + pass + + brain._runtime = BrainRuntime( + context=LLMContext(messages=[]), + llm=FakeLLM(), + queue_frame=queue_frame, + set_system_prompt=lambda _prompt: None, + set_tools=lambda _tools: None, + call_end=FakeCallEnd(), + ) + + brain._state.begin_user_turn("完成上一阶段") + waiting_config = brain._flow_managed_transition_config( + {"name": "waiting"}, + triggering_user_text="完成上一阶段", + ) + waiting_action = waiting_config["pre_actions"][0] + + self.assertFalse(waiting_config["respond_immediately"]) + self.assertTrue(waiting_action["consume_triggering_turn"]) + await waiting_action["handler"](waiting_action, SimpleNamespace()) + self.assertEqual(brain._state.status, WorkflowStatus.WAITING_USER) + self.assertIsNone(brain._state.pending_user_turn) + + brain._state.begin_user_turn("请立即处理") + immediate_config = brain._flow_managed_transition_config( + {"name": "immediate"}, + triggering_user_text="请立即处理", + ) + immediate_action = immediate_config["pre_actions"][0] + + self.assertTrue(immediate_config["respond_immediately"]) + self.assertFalse(immediate_action["consume_triggering_turn"]) + await immediate_action["handler"](immediate_action, SimpleNamespace()) + self.assertEqual(brain._state.status, WorkflowStatus.RUNNING_AGENT) + self.assertIsNotNone(brain._state.pending_user_turn) + async def test_session_update_refreshes_current_agent_without_routing(self): cfg = prepare_dynamic_config( AssistantConfig( @@ -1614,6 +1674,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): "data": { "prompt": "处理用户输入", "contextPolicy": "fresh", + "entryMode": "generate", }, }, ], @@ -1874,6 +1935,9 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): for message in manager.configs[-1]["task_messages"] ) ) + runs_before_second_transition = sum( + isinstance(frame, LLMRunFrame) for frame in queued + ) # The user-turn processor must return while the second Message is # still waiting for its transport playback boundary. @@ -1892,7 +1956,12 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): if manager.current_node == "agent2": break self.assertEqual(manager.current_node, "agent2") - self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued)) + self.assertEqual( + sum(isinstance(frame, LLMRunFrame) for frame in queued), + runs_before_second_transition, + ) + self.assertEqual(brain._state.status, WorkflowStatus.WAITING_USER) + self.assertIsNone(brain._state.pending_user_turn) self.assertEqual( manager.configs[-1]["task_messages"], [ @@ -1906,7 +1975,6 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): ], ) - await brain.on_assistant_text_end("agent2-turn", "信息确认完成", False) await brain.on_user_turn_end("结束通话") self.assertEqual(manager.current_node, "end") self.assertTrue(call_end.finished) @@ -2031,7 +2099,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): ) ) - async def test_start_llm_conditions_wait_for_and_route_first_user_turn(self): + async def test_waiting_agent_does_not_reply_to_transitioning_user_turn(self): brain = WorkflowBrain( { "specVersion": 3, @@ -2161,9 +2229,16 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase): image_message, manager.config["task_messages"], ) - self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued)) + self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued)) + self.assertEqual(brain._state.status, WorkflowStatus.WAITING_USER) + self.assertIsNone(brain._state.pending_user_turn) self.assertIn("我想吃饭", brain._store.values["system__conversation_history"]) + await brain.on_user_turn_end("我要一份米饭") + + self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued)) + self.assertEqual(brain._state.status, WorkflowStatus.RUNNING_AGENT) + async def test_start_expression_condition_also_waits_for_user_turn(self): brain = WorkflowBrain( { diff --git a/frontend/src/components/workflow/GenericNode.tsx b/frontend/src/components/workflow/GenericNode.tsx index e3f1782..efa9d06 100644 --- a/frontend/src/components/workflow/GenericNode.tsx +++ b/frontend/src/components/workflow/GenericNode.tsx @@ -42,7 +42,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) { .toString() .trim(); const entryModeLabel = { - wait_user: "等待用户", + wait_user: "等待下一轮", generate: "立即回复", }[nodeData.entryMode ?? "wait_user"]; const inheritsGlobal = nodeData.inheritGlobalConfig !== false; diff --git a/frontend/src/components/workflow/panels/AgentNodePanel.tsx b/frontend/src/components/workflow/panels/AgentNodePanel.tsx index 52905ba..45f4675 100644 --- a/frontend/src/components/workflow/panels/AgentNodePanel.tsx +++ b/frontend/src/components/workflow/panels/AgentNodePanel.tsx @@ -151,14 +151,14 @@ export function AgentNodePanel({ label="进入节点时" value={(draft.entryMode as string) || "wait_user"} options={[ - { value: "wait_user", label: "等待用户说话(默认)" }, - { value: "generate", label: "立即让 LLM 回复" }, + { value: "wait_user", label: "等待下一轮用户输入(默认)" }, + { value: "generate", label: "进入后立即回复" }, ]} onChange={(value) => set("entryMode", value || "wait_user")} allowNone={false} />
- 固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。 + 等待模式不会回复触发跳转的当前输入;立即回复会处理该输入。固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
diff --git a/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx b/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx index 4d50d2b..20daea5 100644 --- a/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx +++ b/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx @@ -169,14 +169,14 @@ export function NodeSettingsPanel({ label="进入节点时" value={(draft.entryMode as string) || "wait_user"} options={[ - { value: "wait_user", label: "等待用户说话(默认)" }, - { value: "generate", label: "立即让 LLM 回复" }, + { value: "wait_user", label: "等待下一轮用户输入(默认)" }, + { value: "generate", label: "进入后立即回复" }, ]} onChange={(value) => set("entryMode", value || "wait_user")} allowNone={false} />- 固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。 + 等待模式不会回复触发跳转的当前输入;立即回复会处理该输入。固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。