Skip to content

Commit 10f9fb9

Browse files
authored
feat: New workflow engine is used for tool workflow (#6992)
1 parent c479b5b commit 10f9fb9

1 file changed

Lines changed: 170 additions & 30 deletions

File tree

apps/tools/serializers/tool_workflow.py

Lines changed: 170 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,44 @@
1313

1414
# coding=utf-8
1515
import pickle
16+
import queue
1617
import tempfile
18+
import time
1719
import zipfile
1820
from functools import reduce
1921
from typing import Dict, List
2022

2123
import requests
2224
import uuid_utils.compat as uuid
23-
from application.flow.common import Workflow, WorkflowMode
24-
from application.flow.i_step_node import ToolWorkflowPostHandler
25-
from application.flow.tool_workflow_manage import ToolWorkflowManage
26-
from application.models import ChatRecord
25+
from application.flow.tools import to_stream_response_simple
26+
from application.workflow.common import WorkflowType, new_instance
27+
from application.workflow.message.aggregator import AggregationManager
28+
from application.workflow.nodes import get_node_class
29+
from application.workflow.status import Status
30+
from application.workflow.workflow_manage import CallBack, WorkflowManage
2731
from application.serializers.application import (
2832
McpServersSerializer,
2933
get_mcp_tools,
3034
validate_bound_tool_permissions,
3135
)
32-
from application.serializers.common import ToolExecute
36+
from common.constants.cache_version import Cache_Version
3337
from common.database_model_manage.database_model_manage import DatabaseModelManage
38+
from common.handle.impl.response.system_to_response import SystemToResponse
3439
from common.exception.app_exception import AppApiException
3540
from common.field.common import UploadedFileField
3641
from common.result import result
3742
from common.utils.common import bytes_to_uploaded_file, generate_uuid, restricted_loads
3843
from common.utils.logger import maxkb_logger
3944
from common.utils.tool_code import ToolExecutor
4045
from common.utils.url_validator import ALLOWED_CALLBACK_HOSTS, ALLOWED_DOWNLOAD_HOSTS, validate_trusted_url
46+
from django.core.cache import cache
4147
from django.db import transaction
4248
from django.db.models import Q, QuerySet
4349
from django.http import HttpResponse
4450
from django.utils import timezone
4551
from django.utils.translation import gettext_lazy as _, gettext
4652
from knowledge.models import Knowledge, KnowledgeScope, KnowledgeWorkflow
53+
from knowledge.models.knowledge_action import State
4754
from knowledge.serializers.knowledge import KnowledgeModelSerializer, KnowledgeSerializer
4855
from maxkb.const import CONFIG
4956
from rest_framework import serializers, status
@@ -164,42 +171,175 @@ def debug(self, instance: Dict, user, with_valid=True):
164171
tool_workflow = QuerySet(ToolWorkflow).filter(tool_id=self.data.get("tool_id")).first()
165172
workspace_id = tool_workflow.workspace_id
166173
tool_record_id = instance.get("chat_record_id") or str(uuid.uuid7())
167-
took_execute = ToolExecute(self.data.get("tool_id"), tool_record_id, workspace_id, None, None, True)
168-
record = took_execute.get_record()
174+
# 表单节点等断点续跑:position 指向要从其恢复执行的节点,机制与 chat 一致
175+
position = instance.get("position")
169176
# 运行身份取自认证上下文(DB 工作空间 + 登录用户),请求体不得覆盖,
170-
# 防止低权限用户伪造 workspace_id/user_id 绕过工具引用授权
171-
identity_keys = {"workspace_id", "user_id", "chat_user_id", "chat_user_type"}
172-
run_params = {
173-
"chat_record_id": tool_record_id,
177+
# 防止低权限用户伪造 workspace_id/user_id 绕过工具引用授权;
178+
# chat_record_id 仅用于沿用同一条执行记录,工具工作流本身不作为运行参数
179+
identity_keys = {"workspace_id", "user_id", "chat_user_id", "chat_user_type", "chat_record_id"}
180+
# 对齐旧引擎 get_body():输入字段值 + 执行身份,不含对话语义字段(question/chat_record_id)
181+
parameters = {
174182
"tool_id": self.data.get("tool_id"),
175183
"stream": True,
184+
"debug": True,
176185
"workspace_id": workspace_id,
177186
"user_id": self.data.get("user_id"),
178187
**{k: v for k, v in instance.items() if k not in identity_keys},
179188
}
180-
work_flow_manage = ToolWorkflowManage(
181-
Workflow.new_instance(tool_workflow.work_flow, WorkflowMode.TOOL),
182-
run_params,
183-
ToolWorkflowPostHandler(took_execute, self.data.get("tool_id")),
184-
is_the_task_interrupted=lambda: False,
185-
child_node=instance.get("child_node"),
186-
start_node_id=instance.get("runtime_node_id"),
187-
start_node_data=instance.get("node_data"),
188-
chat_record=self.to_chat_record(record),
189-
)
190189

191-
r = work_flow_manage.run()
192-
return r
190+
workflow = new_instance(tool_workflow.work_flow, WorkflowType.TOOL)
191+
aggregation = AggregationManager()
192+
result_queue = queue.Queue()
193+
base_to_response = SystemToResponse()
194+
start_time = time.time()
195+
196+
def on_next(wf_manage, content):
197+
aggregation.aggregate(content)
198+
result_queue.put(("chunk", content.to_dict()))
199+
200+
def on_complete(wf_manage, error):
201+
try:
202+
self.save_tool_record(
203+
tool_record_id,
204+
self.data.get("tool_id"),
205+
workspace_id,
206+
wf_manage,
207+
aggregation,
208+
parameters,
209+
start_time,
210+
error,
211+
position,
212+
)
213+
finally:
214+
result_queue.put(("error", error) if error else ("done", None))
215+
216+
call_back = CallBack(on_next, on_complete)
217+
218+
def get_node_parameters(node):
219+
return node.properties.get("node_data", {})
220+
221+
def get_start_node_fn(wf, wm):
222+
# 有 position:从指定节点续跑(表单节点等),与 chat 的 position 机制一致
223+
if position and position.get("id"):
224+
node = wf.get_node(position.get("id"))
225+
if node:
226+
node_class = get_node_class(node.type, WorkflowType.TOOL)
227+
return node_class(node, wm, get_node_parameters)
228+
# 默认从工具起始节点开始
229+
start_node = wf.get_node("tool-start-node")
230+
if start_node is None:
231+
raise AppApiException(500, gettext("The start node does not exist"))
232+
node_class = get_node_class(start_node.type, WorkflowType.TOOL)
233+
return node_class(start_node, wm, get_node_parameters)
234+
235+
# 有 position 且有记录 id:从历史 context 恢复(position 机制与 chat 一致);恢复失败回退为全新执行。
236+
# 工具无 ChatRecord,context 来源是 debug 专用缓存——通过 get_context 回调提供,from_context 只负责重建
237+
if position and instance.get("chat_record_id"):
238+
239+
def get_tool_context():
240+
return cache.get(Cache_Version.DEBUG_WORKFLOW_CONTEXT.get_key(chat_record_id=str(tool_record_id)))
241+
242+
work_flow_manage = WorkflowManage.from_context(
243+
get_context=get_tool_context,
244+
workflow=workflow,
245+
parameters=parameters,
246+
workflow_type=WorkflowType.TOOL,
247+
call_back=call_back,
248+
get_start_node=get_start_node_fn,
249+
)
250+
if work_flow_manage is None:
251+
work_flow_manage = WorkflowManage(
252+
workflow, parameters, WorkflowType.TOOL, call_back, get_start_node_fn
253+
)
254+
else:
255+
work_flow_manage = WorkflowManage(workflow, parameters, WorkflowType.TOOL, call_back, get_start_node_fn)
256+
work_flow_manage.start_node.workflow_manage = work_flow_manage
257+
258+
def generate():
259+
work_flow_manage.run()
260+
while True:
261+
msg_type, data = result_queue.get()
262+
if msg_type == "done":
263+
yield "data: [DONE]\n\n"
264+
break
265+
if msg_type == "error":
266+
error_block = {"id": str(uuid.uuid7()), "type": "FAILURE", "content": str(data)}
267+
frame = base_to_response.to_stream(tool_record_id, tool_record_id, error_block)
268+
if frame is not None:
269+
yield "data: " + frame + "\n\n"
270+
yield "data: [DONE]\n\n"
271+
break
272+
if msg_type == "chunk":
273+
frame = base_to_response.to_stream(tool_record_id, tool_record_id, data)
274+
if frame is not None:
275+
yield "data: " + frame + "\n\n"
276+
277+
return to_stream_response_simple(generate())
193278

194279
@staticmethod
195-
def to_chat_record(record):
196-
if record is None:
197-
return None
198-
return ChatRecord(
199-
answer_text_list=record.meta.get("answer_text_list"),
200-
details=record.meta.get("details"),
201-
answer_text="",
280+
def save_tool_record(
281+
tool_record_id, tool_id, workspace_id, wf_manage, aggregation, parameters, start_time, error, position=None
282+
):
283+
"""
284+
工具调试执行结束后写执行记录缓存(替代旧引擎 ToolWorkflowPostHandler)。
285+
debug 只写 30 分钟 Redis 缓存、不落库,前端据此拉取 meta.output/details 展示;
286+
缓存 shape 与 tool 记录查询端点(ToolSerializer...one)读取的字段保持一致。
287+
同时把运行 context 写入 DEBUG_WORKFLOW_CONTEXT,供下次 position 续跑时 from_context 恢复。
288+
"""
289+
workflow = wf_manage.workflow
290+
base_node = workflow.get_node("tool-base-node")
291+
input_field_list = base_node.properties.get("user_input_field_list", []) if base_node else []
292+
output_field_list = base_node.properties.get("user_output_field_list", []) if base_node else []
293+
input_data = {f.get("field"): parameters.get(f.get("field")) for f in input_field_list}
294+
# 新引擎工具输出收口于全局 output 上下文(tool-start-node 初始化、变量赋值节点写入)
295+
output = wf_manage.context.get("output", {})
296+
# 续跑(有 position):合并上一次的节点详情,与 chat 的 get_details 用法一致
297+
old_details = None
298+
if position:
299+
prev_record = cache.get(
300+
Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=tool_record_id),
301+
version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version(),
302+
)
303+
if prev_record:
304+
old_details = (prev_record.get("meta") or {}).get("details")
305+
details = wf_manage.get_details(position=position, old_details=old_details)
306+
tool_record = {
307+
"id": tool_record_id,
308+
"tool_id": tool_id,
309+
"workspace_id": workspace_id,
310+
"source_type": None,
311+
"source_id": None,
312+
"state": ToolWorkflowSerializer.Operate.compute_tool_state(details, error),
313+
"run_time": time.time() - start_time,
314+
"meta": {
315+
"input_field_list": input_field_list,
316+
"output_field_list": output_field_list,
317+
"input": input_data,
318+
"output": output,
319+
"details": details,
320+
"answer_text_list": aggregation.get_contents(),
321+
},
322+
}
323+
cache.set(
324+
Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=tool_record_id),
325+
tool_record,
326+
version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version(),
327+
timeout=60 * 30,
202328
)
329+
# 持久化运行 context,供 position 续跑时恢复(工具无 ChatRecord,故写 debug 专用缓存)。
330+
# 读写都用 cache.get/set(key) 不带 version,两侧须一致,否则 Django version 命名空间对不上会命中不到。
331+
cache.set(
332+
Cache_Version.DEBUG_WORKFLOW_CONTEXT.get_key(chat_record_id=str(tool_record_id)),
333+
wf_manage.context,
334+
timeout=60 * 30,
335+
)
336+
337+
@staticmethod
338+
def compute_tool_state(details, error):
339+
if error:
340+
return State.FAILURE
341+
has_fail = any((d or {}).get("status") == Status.FAIL.value for d in (details or []))
342+
return State.FAILURE if has_fail else State.SUCCESS
203343

204344
def publish(self, with_valid=True):
205345
if with_valid:

0 commit comments

Comments
 (0)