fix: honor strict workflow agent entry mode
This commit is contained in:
@@ -562,16 +562,24 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
data = self._engine.data(node_id)
|
if self._agent_runs_on_entry(node_id):
|
||||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
|
||||||
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
|
||||||
if should_run:
|
|
||||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||||
await self._require_runtime().queue_frame(LLMRunFrame())
|
await self._require_runtime().queue_frame(LLMRunFrame())
|
||||||
return
|
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)
|
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(
|
async def _activate_node_config(
|
||||||
self,
|
self,
|
||||||
node_config: NodeConfig,
|
node_config: NodeConfig,
|
||||||
@@ -708,10 +716,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
return configured
|
return configured
|
||||||
if node_type != "agent":
|
if node_type != "agent":
|
||||||
return node_config
|
return node_config
|
||||||
entry_mode = str(
|
should_run = self._agent_runs_on_entry(node_id)
|
||||||
self._engine.data(node_id).get("entryMode") or "wait_user"
|
|
||||||
)
|
|
||||||
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
|
||||||
configured = dict(node_config)
|
configured = dict(node_config)
|
||||||
configured["respond_immediately"] = should_run
|
configured["respond_immediately"] = should_run
|
||||||
configured["pre_actions"] = [
|
configured["pre_actions"] = [
|
||||||
@@ -719,6 +724,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
"type": ConfiguredFlowManager.ENTRY_ACTION_TYPE,
|
"type": ConfiguredFlowManager.ENTRY_ACTION_TYPE,
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
"should_run": should_run,
|
"should_run": should_run,
|
||||||
|
"consume_triggering_turn": bool(triggering_user_text)
|
||||||
|
and not should_run,
|
||||||
"handler": self._activate_from_flow_transition,
|
"handler": self._activate_from_flow_transition,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -735,6 +742,8 @@ class WorkflowBrain(BaseBrain):
|
|||||||
if action.get("should_run"):
|
if action.get("should_run"):
|
||||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||||
else:
|
else:
|
||||||
|
if action.get("consume_triggering_turn"):
|
||||||
|
self._state.consume_user_turn()
|
||||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||||
|
|
||||||
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
def _knowledge_function(self, node_id: str) -> FlowsFunctionSchema | None:
|
||||||
|
|||||||
@@ -867,6 +867,66 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(brain._flow_tool(timeout_tool, "start").timeout_secs, 7.0)
|
self.assertEqual(brain._flow_tool(timeout_tool, "start").timeout_secs, 7.0)
|
||||||
self.assertIsNone(brain._flow_tool(session_tool, "start").timeout_secs)
|
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):
|
async def test_session_update_refreshes_current_agent_without_routing(self):
|
||||||
cfg = prepare_dynamic_config(
|
cfg = prepare_dynamic_config(
|
||||||
AssistantConfig(
|
AssistantConfig(
|
||||||
@@ -1614,6 +1674,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"data": {
|
"data": {
|
||||||
"prompt": "处理用户输入",
|
"prompt": "处理用户输入",
|
||||||
"contextPolicy": "fresh",
|
"contextPolicy": "fresh",
|
||||||
|
"entryMode": "generate",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1874,6 +1935,9 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
for message in manager.configs[-1]["task_messages"]
|
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
|
# 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.
|
||||||
@@ -1892,7 +1956,12 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
if manager.current_node == "agent2":
|
if manager.current_node == "agent2":
|
||||||
break
|
break
|
||||||
self.assertEqual(manager.current_node, "agent2")
|
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(
|
self.assertEqual(
|
||||||
manager.configs[-1]["task_messages"],
|
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("结束通话")
|
await brain.on_user_turn_end("结束通话")
|
||||||
self.assertEqual(manager.current_node, "end")
|
self.assertEqual(manager.current_node, "end")
|
||||||
self.assertTrue(call_end.finished)
|
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(
|
brain = WorkflowBrain(
|
||||||
{
|
{
|
||||||
"specVersion": 3,
|
"specVersion": 3,
|
||||||
@@ -2161,9 +2229,16 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
image_message,
|
image_message,
|
||||||
manager.config["task_messages"],
|
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"])
|
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):
|
async def test_start_expression_condition_also_waits_for_user_turn(self):
|
||||||
brain = WorkflowBrain(
|
brain = WorkflowBrain(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
|
|||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
const entryModeLabel = {
|
const entryModeLabel = {
|
||||||
wait_user: "等待用户",
|
wait_user: "等待下一轮",
|
||||||
generate: "立即回复",
|
generate: "立即回复",
|
||||||
}[nodeData.entryMode ?? "wait_user"];
|
}[nodeData.entryMode ?? "wait_user"];
|
||||||
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
const inheritsGlobal = nodeData.inheritGlobalConfig !== false;
|
||||||
|
|||||||
@@ -151,14 +151,14 @@ export function AgentNodePanel({
|
|||||||
label="进入节点时"
|
label="进入节点时"
|
||||||
value={(draft.entryMode as string) || "wait_user"}
|
value={(draft.entryMode as string) || "wait_user"}
|
||||||
options={[
|
options={[
|
||||||
{ value: "wait_user", label: "等待用户说话(默认)" },
|
{ value: "wait_user", label: "等待下一轮用户输入(默认)" },
|
||||||
{ value: "generate", label: "立即让 LLM 回复" },
|
{ value: "generate", label: "进入后立即回复" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => set("entryMode", value || "wait_user")}
|
onChange={(value) => set("entryMode", value || "wait_user")}
|
||||||
allowNone={false}
|
allowNone={false}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
|
等待模式不会回复触发跳转的当前输入;立即回复会处理该输入。固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
|
||||||
</p>
|
</p>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@@ -169,14 +169,14 @@ export function NodeSettingsPanel({
|
|||||||
label="进入节点时"
|
label="进入节点时"
|
||||||
value={(draft.entryMode as string) || "wait_user"}
|
value={(draft.entryMode as string) || "wait_user"}
|
||||||
options={[
|
options={[
|
||||||
{ value: "wait_user", label: "等待用户说话(默认)" },
|
{ value: "wait_user", label: "等待下一轮用户输入(默认)" },
|
||||||
{ value: "generate", label: "立即让 LLM 回复" },
|
{ value: "generate", label: "进入后立即回复" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => set("entryMode", value || "wait_user")}
|
onChange={(value) => set("entryMode", value || "wait_user")}
|
||||||
allowNone={false}
|
allowNone={false}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs leading-5 text-muted-soft">
|
<p className="text-xs leading-5 text-muted-soft">
|
||||||
固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
|
等待模式不会回复触发跳转的当前输入;立即回复会处理该输入。固定播报、客户端弹窗和确认门禁请使用独立的 Message 节点。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium text-foreground">可用工具</label>
|
<label className="text-sm font-medium text-foreground">可用工具</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user