feat: make workflow messages resumable
This commit is contained in:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from copy import deepcopy
|
||||
from dataclasses import replace
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -63,9 +63,29 @@ from services.workflow_router import WorkflowLLMRouter
|
||||
MAX_AUTOMATIC_HOPS = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MessageContinuation:
|
||||
"""Resume one Workflow path after its visible Message gate completes."""
|
||||
|
||||
token: int
|
||||
node_id: str
|
||||
context_messages: list[dict[str, str]]
|
||||
triggering_user_text: str
|
||||
task: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
class ConfiguredFlowManager(FlowManager):
|
||||
"""Preserve Flow transitions while suppressing late async-tool replies."""
|
||||
|
||||
ENTRY_ACTION_TYPE = "workflow_function_transition_entry"
|
||||
|
||||
async def _set_node(self, node_id: str, node_config: NodeConfig) -> None:
|
||||
"""Notify Workflow only after FlowManager committed the active node."""
|
||||
await super()._set_node(node_id, node_config)
|
||||
after_activation = node_config.get("workflow_after_activation")
|
||||
if callable(after_activation):
|
||||
await after_activation(node_id)
|
||||
|
||||
async def _create_transition_func(self, name, handler):
|
||||
transition = await super()._create_transition_func(name, handler)
|
||||
if not getattr(handler, "_suppress_followup_llm", False):
|
||||
@@ -136,6 +156,8 @@ class WorkflowBrain(BaseBrain):
|
||||
self._output: WorkflowOutput | None = None
|
||||
self._agent_stage: WorkflowAgentStage | None = None
|
||||
self._ended = False
|
||||
self._next_message_token = 1
|
||||
self._pending_message: _MessageContinuation | None = None
|
||||
|
||||
async def greeting(self, _cfg: AssistantConfig) -> str:
|
||||
"""Workflow opening speech belongs to an explicit Message or Agent."""
|
||||
@@ -179,6 +201,8 @@ class WorkflowBrain(BaseBrain):
|
||||
runtime=runtime,
|
||||
)
|
||||
self._ended = False
|
||||
self._next_message_token = 1
|
||||
self._pending_message = None
|
||||
self._manager = ConfiguredFlowManager(
|
||||
worker=runtime.worker,
|
||||
llm=runtime.llm,
|
||||
@@ -199,8 +223,7 @@ class WorkflowBrain(BaseBrain):
|
||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||
|
||||
node_config = await self._initial_node_config()
|
||||
await self._manager.initialize(node_config)
|
||||
await self._after_node_activated(node_config)
|
||||
await self._activate_node_config(node_config, initialize=True)
|
||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||
|
||||
async def _initial_node_config(self) -> NodeConfig:
|
||||
@@ -298,8 +321,7 @@ class WorkflowBrain(BaseBrain):
|
||||
decision.edge,
|
||||
triggering_user_text=content,
|
||||
)
|
||||
await manager.set_node_from_config(next_config)
|
||||
await self._after_node_activated(
|
||||
await self._activate_node_config(
|
||||
next_config,
|
||||
triggering_user_text=content,
|
||||
)
|
||||
@@ -390,9 +412,12 @@ class WorkflowBrain(BaseBrain):
|
||||
*,
|
||||
triggering_user_text: str = "",
|
||||
) -> None:
|
||||
"""Publish activation and perform exactly one Agent entry behavior."""
|
||||
"""Run the entry behavior owned by the newly active node."""
|
||||
node_id = str(node_config.get("name") or "")
|
||||
node_type = self._engine.node_type(node_id)
|
||||
if node_type == "message":
|
||||
await self._activate_message_continuation(node_id)
|
||||
return
|
||||
if node_type != "agent":
|
||||
if node_type == "start":
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
@@ -401,17 +426,6 @@ class WorkflowBrain(BaseBrain):
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
if entry_mode == "fixed_speech":
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
await self._queue_visible_speech(
|
||||
entry_speech,
|
||||
source="workflow-fixed-reply",
|
||||
node_id=node_id,
|
||||
)
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
self._state.consume_user_turn()
|
||||
return
|
||||
|
||||
should_run = entry_mode == "generate" or bool(triggering_user_text)
|
||||
if should_run:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||
@@ -420,6 +434,24 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
|
||||
async def _activate_node_config(
|
||||
self,
|
||||
node_config: NodeConfig,
|
||||
*,
|
||||
triggering_user_text: str = "",
|
||||
initialize: bool = False,
|
||||
) -> None:
|
||||
"""Install one node and dispatch its entry behavior exactly once."""
|
||||
manager = self._require_manager()
|
||||
if initialize:
|
||||
await manager.initialize(node_config)
|
||||
else:
|
||||
await manager.set_node_from_config(node_config)
|
||||
await self._after_node_activated(
|
||||
node_config,
|
||||
triggering_user_text=triggering_user_text,
|
||||
)
|
||||
|
||||
async def _queue_visible_speech(
|
||||
self,
|
||||
text: str,
|
||||
@@ -529,21 +561,25 @@ class WorkflowBrain(BaseBrain):
|
||||
state; it never performs a second LLM run.
|
||||
"""
|
||||
node_id = str(node_config.get("name") or "")
|
||||
if self._engine.node_type(node_id) != "agent":
|
||||
node_type = self._engine.node_type(node_id)
|
||||
if node_type == "message":
|
||||
configured = dict(node_config)
|
||||
configured["workflow_after_activation"] = (
|
||||
self._activate_message_continuation
|
||||
)
|
||||
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)
|
||||
configured = dict(node_config)
|
||||
configured["respond_immediately"] = (
|
||||
should_run and entry_mode != "fixed_speech"
|
||||
)
|
||||
configured["respond_immediately"] = should_run
|
||||
configured["pre_actions"] = [
|
||||
{
|
||||
"type": "workflow_function_transition_entry",
|
||||
"type": ConfiguredFlowManager.ENTRY_ACTION_TYPE,
|
||||
"node_id": node_id,
|
||||
"entry_mode": entry_mode,
|
||||
"should_run": should_run,
|
||||
"handler": self._activate_from_flow_transition,
|
||||
}
|
||||
@@ -558,19 +594,7 @@ class WorkflowBrain(BaseBrain):
|
||||
"""Apply visible entry state without manually queueing an LLM run."""
|
||||
node_id = str(action.get("node_id") or "")
|
||||
await self._emit_node_active(node_id)
|
||||
entry_mode = str(action.get("entry_mode") or "wait_user")
|
||||
if entry_mode == "fixed_speech":
|
||||
entry_speech = self._store.render(
|
||||
str(self._engine.data(node_id).get("entrySpeech") or "")
|
||||
)
|
||||
await self._queue_visible_speech(
|
||||
entry_speech,
|
||||
source="workflow-fixed-reply",
|
||||
node_id=node_id,
|
||||
)
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
self._state.consume_user_turn()
|
||||
elif action.get("should_run"):
|
||||
if action.get("should_run"):
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_AGENT)
|
||||
else:
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
@@ -619,10 +643,11 @@ class WorkflowBrain(BaseBrain):
|
||||
self,
|
||||
edge: dict,
|
||||
*,
|
||||
leading_messages: list[dict[str, str]] | None = None,
|
||||
triggering_user_text: str = "",
|
||||
) -> NodeConfig:
|
||||
await self._begin_edge_transition(edge)
|
||||
leading_messages: list[dict[str, str]] = []
|
||||
context_messages = list(leading_messages or [])
|
||||
speech = self._engine.edge_transition_speech(edge)
|
||||
if speech:
|
||||
content = self._store.render(speech).strip()
|
||||
@@ -632,12 +657,12 @@ class WorkflowBrain(BaseBrain):
|
||||
source="workflow-edge-transition",
|
||||
node_id=str(edge.get("target") or "") or None,
|
||||
)
|
||||
leading_messages.append(
|
||||
context_messages.append(
|
||||
{"role": "assistant", "content": content}
|
||||
)
|
||||
return await self._resolve_path(
|
||||
str(edge.get("target") or ""),
|
||||
leading_messages=leading_messages,
|
||||
leading_messages=context_messages,
|
||||
triggering_user_text=triggering_user_text,
|
||||
)
|
||||
|
||||
@@ -672,13 +697,12 @@ class WorkflowBrain(BaseBrain):
|
||||
if not outcome.should_route:
|
||||
return self._passive_node_config(node_id, context_messages)
|
||||
elif node_type == "message":
|
||||
message_result = await self._enter_message(node_id)
|
||||
if not message_result.succeeded:
|
||||
return self._passive_node_config(node_id, context_messages)
|
||||
if message_result.speech:
|
||||
context_messages.append(
|
||||
{"role": "assistant", "content": message_result.speech}
|
||||
)
|
||||
self._prepare_message_continuation(
|
||||
node_id,
|
||||
context_messages=context_messages,
|
||||
triggering_user_text=triggering_user_text,
|
||||
)
|
||||
return self._passive_node_config(node_id, context_messages)
|
||||
elif node_type == "handoff":
|
||||
await self._enter_handoff(node_id)
|
||||
elif node_type == "start":
|
||||
@@ -708,6 +732,127 @@ class WorkflowBrain(BaseBrain):
|
||||
node_id = str(edge.get("target") or "")
|
||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||
|
||||
def _prepare_message_continuation(
|
||||
self,
|
||||
node_id: str,
|
||||
*,
|
||||
context_messages: list[dict[str, str]],
|
||||
triggering_user_text: str,
|
||||
) -> None:
|
||||
"""Save the path state without waiting inside the pipeline call stack."""
|
||||
token = self._next_message_token
|
||||
self._next_message_token += 1
|
||||
self._pending_message = _MessageContinuation(
|
||||
token=token,
|
||||
node_id=node_id,
|
||||
context_messages=[dict(message) for message in context_messages],
|
||||
triggering_user_text=triggering_user_text,
|
||||
)
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||
|
||||
async def _activate_message_continuation(self, node_id: str) -> None:
|
||||
"""Start a prepared Message only after FlowManager made it active."""
|
||||
continuation = self._pending_message
|
||||
if (
|
||||
continuation is None
|
||||
or continuation.node_id != node_id
|
||||
or continuation.task is not None
|
||||
):
|
||||
return
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||
await self._emit_node_active(node_id)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.set_input_enabled is not None:
|
||||
runtime.set_input_enabled(False)
|
||||
continuation.task = asyncio.create_task(
|
||||
self._complete_message_continuation(continuation),
|
||||
name=f"workflow-message-{node_id}-{continuation.token}",
|
||||
)
|
||||
|
||||
async def _complete_message_continuation(
|
||||
self,
|
||||
continuation: _MessageContinuation,
|
||||
) -> None:
|
||||
"""Wait for playback/confirmation outside routing, then resume once."""
|
||||
try:
|
||||
result = await self._enter_message(
|
||||
continuation.node_id,
|
||||
already_active=True,
|
||||
input_already_blocked=True,
|
||||
)
|
||||
if not result.succeeded:
|
||||
if self._pending_message is continuation:
|
||||
self._pending_message = None
|
||||
return
|
||||
await self._resume_message_continuation(continuation, result)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - keep callback failures visible
|
||||
logger.exception(f"Message 节点续跑失败:{exc}")
|
||||
if self._pending_message is continuation:
|
||||
self._pending_message = None
|
||||
self._state.enter(
|
||||
continuation.node_id,
|
||||
WorkflowStatus.WAITING_USER,
|
||||
)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.set_input_enabled is not None:
|
||||
runtime.set_input_enabled(True)
|
||||
await self._require_output().emit_error(
|
||||
"Message 节点完成后无法继续工作流",
|
||||
node_id=continuation.node_id,
|
||||
code="workflow_message_resume_error",
|
||||
)
|
||||
|
||||
async def _resume_message_continuation(
|
||||
self,
|
||||
continuation: _MessageContinuation,
|
||||
result: MessageStageResult,
|
||||
) -> None:
|
||||
"""Advance from the completed Message without blocking media frames."""
|
||||
async with self._turn_lock:
|
||||
manager = self._require_manager()
|
||||
if (
|
||||
self._ended
|
||||
or self._pending_message is not continuation
|
||||
or self._state.current_node_id != continuation.node_id
|
||||
or str(manager.current_node or "") != continuation.node_id
|
||||
):
|
||||
return
|
||||
|
||||
self._pending_message = None
|
||||
context_messages = [
|
||||
dict(message) for message in continuation.context_messages
|
||||
]
|
||||
if result.speech:
|
||||
context_messages.append(
|
||||
{"role": "assistant", "content": result.speech}
|
||||
)
|
||||
|
||||
if not self._engine.has_outgoing(continuation.node_id):
|
||||
self._state.enter(
|
||||
continuation.node_id,
|
||||
WorkflowStatus.WAITING_USER,
|
||||
)
|
||||
return
|
||||
edge = await self._select_edge(continuation.node_id)
|
||||
if not edge:
|
||||
self._state.enter(
|
||||
continuation.node_id,
|
||||
WorkflowStatus.WAITING_USER,
|
||||
)
|
||||
return
|
||||
|
||||
next_config = await self._follow_edge(
|
||||
edge,
|
||||
leading_messages=context_messages,
|
||||
triggering_user_text=continuation.triggering_user_text,
|
||||
)
|
||||
await self._activate_node_config(
|
||||
next_config,
|
||||
triggering_user_text=continuation.triggering_user_text,
|
||||
)
|
||||
|
||||
async def _enter_action(self, node_id: str) -> ActionOutcome:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||
await self._emit_node_active(node_id)
|
||||
@@ -760,9 +905,16 @@ class WorkflowBrain(BaseBrain):
|
||||
await self._emit_action_outcome(node_id, outcome)
|
||||
return outcome
|
||||
|
||||
async def _enter_message(self, node_id: str) -> MessageStageResult:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||
await self._emit_node_active(node_id)
|
||||
async def _enter_message(
|
||||
self,
|
||||
node_id: str,
|
||||
*,
|
||||
already_active: bool = False,
|
||||
input_already_blocked: bool = False,
|
||||
) -> MessageStageResult:
|
||||
if not already_active:
|
||||
self._state.enter(node_id, WorkflowStatus.RUNNING_MESSAGE)
|
||||
await self._emit_node_active(node_id)
|
||||
data = self._engine.data(node_id)
|
||||
runtime = self._require_runtime()
|
||||
speech = self._store.render(str(data.get("speech") or "")).strip()
|
||||
@@ -795,6 +947,7 @@ class WorkflowBrain(BaseBrain):
|
||||
node_id=node_id,
|
||||
),
|
||||
set_input_enabled=runtime.set_input_enabled,
|
||||
input_already_blocked=input_already_blocked,
|
||||
on_started=lambda: self._emit_trace(
|
||||
"message_started",
|
||||
nodeId=node_id,
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any
|
||||
SPEC_VERSION = "3"
|
||||
NODE_TYPES = {"start", "agent", "message", "action", "handoff", "end"}
|
||||
EDGE_MODES = {"llm", "expression", "always"}
|
||||
AGENT_ENTRY_MODES = {"wait_user", "generate", "fixed_speech"}
|
||||
AGENT_ENTRY_MODES = {"wait_user", "generate"}
|
||||
ACTION_RESULT_ASSIGNMENT_MODES = {"inherit", "override", "none"}
|
||||
ACTION_USER_INPUT_POLICIES = {"queue", "block"}
|
||||
AUTOMATIC_NODE_TYPES = {"start", "message", "action", "handoff"}
|
||||
@@ -149,10 +149,11 @@ def _edge_data_v3(edge: dict) -> dict:
|
||||
|
||||
|
||||
def _normalize_agent_data(data: dict[str, Any]) -> None:
|
||||
"""Add v3 Agent defaults without changing existing node-level behavior."""
|
||||
"""Keep Agent entry focused on conversation, not fixed interaction."""
|
||||
data.setdefault("contextPolicy", "inherit")
|
||||
data.setdefault("entryMode", "wait_user")
|
||||
data.setdefault("entrySpeech", "")
|
||||
if data.get("entryMode") not in AGENT_ENTRY_MODES:
|
||||
data["entryMode"] = "wait_user"
|
||||
data.pop("entrySpeech", None)
|
||||
if "inheritGlobalConfig" not in data:
|
||||
has_node_overrides = any(
|
||||
(
|
||||
@@ -300,7 +301,6 @@ def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"contextPolicy": "inherit",
|
||||
"inheritGlobalConfig": True,
|
||||
"entryMode": "wait_user",
|
||||
"entrySpeech": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -376,10 +376,6 @@ def validate_graph(graph: dict[str, Any]) -> list[str]:
|
||||
entry_mode = data.get("entryMode", "wait_user")
|
||||
if entry_mode not in AGENT_ENTRY_MODES:
|
||||
errors.append(f"Agent 节点 {node_id} 的进入模式无效:{entry_mode}")
|
||||
elif entry_mode == "fixed_speech" and not str(
|
||||
data.get("entrySpeech") or ""
|
||||
).strip():
|
||||
errors.append(f"Agent 节点 {node_id} 的固定进入语不能为空")
|
||||
elif node_type == "message":
|
||||
data = node.get("data") or {}
|
||||
speech = data.get("speech")
|
||||
|
||||
@@ -109,25 +109,15 @@ class WorkflowAgentStage:
|
||||
leading_messages: list[dict[str, str]] | None = None,
|
||||
) -> NodeConfig:
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
strategy = (
|
||||
ContextStrategy.RESET
|
||||
if data.get("contextPolicy") == "fresh"
|
||||
else ContextStrategy.APPEND
|
||||
)
|
||||
fixed_reply_messages = (
|
||||
[{"role": "assistant", "content": entry_speech}]
|
||||
if entry_mode == "fixed_speech" and entry_speech
|
||||
else []
|
||||
)
|
||||
return {
|
||||
"name": node_id,
|
||||
"role_message": self.role_message(node_id),
|
||||
"task_messages": [
|
||||
*(leading_messages or []),
|
||||
*fixed_reply_messages,
|
||||
],
|
||||
"task_messages": list(leading_messages or []),
|
||||
"functions": functions,
|
||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||
# Direct node activations let WorkflowRuntime decide whether to run
|
||||
|
||||
Reference in New Issue
Block a user