feat(backend): 完善 Workflow 与 DeepResearch 的运行链路与持久化能力
功能说明:\n- 重构并增强工作流网关执行链路,支持更完整的 proposal 与 run 调度参数透传\n- 增加 DeepResearch/workflow 运行事件持久化能力并补齐建表 SQL 与查询/保存接口适配\n- 优化 lead agent 与 deep research runner 的中间态处理,增强线程收集与执行链并发控制\n- 调整工作流执行器与任务路由,提升兼容性并完善错误与回放行为\n- 补充回归测试:deep_research 持久化、workflow 并发、定义加载、引擎执行、run 流程等覆盖
This commit is contained in:
1 parent
f5bf7897b9
commit
6d326a21cd
28 files changed
+836
-135
No files matched your search
@@ -1093,12 +1093,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
)
|
||||
|
||||
ensure_template_builder_agent()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Template-builder agent seeding failed at startup (non-fatal)"
|
||||
)
|
||||
|
||||
# 深度研究「收集员」内置 agent(对话式信息收集)。同样必须在
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Template-builder agent seeding failed at startup (non-fatal)"
|
||||
)
|
||||
|
||||
# 实验性「页面研报设计师」:在 _sync_legacy_agents 前补齐 agent 目录与
|
||||
# 公共页面生成技能,首次启动即可在智能体管理页和普通对话中复用。
|
||||
try:
|
||||
from app.gateway.routers._page_report_designer_seed import (
|
||||
ensure_page_report_designer_agent,
|
||||
)
|
||||
|
||||
ensure_page_report_designer_agent()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Page-report designer agent seeding failed at startup (non-fatal)"
|
||||
)
|
||||
|
||||
# 深度研究「收集员」内置 agent(对话式信息收集)。同样必须在
|
||||
# _sync_legacy_agents 之前补建目录,由后者 upsert 进 agents 表,
|
||||
# 深度研究页的收集对话与智能体管理页即可直接使用。
|
||||
try:
|
||||
|
||||
@@ -1501,27 +1501,42 @@ class DeepResearchJobExecutor:
|
||||
runtime_thread_id=runtime_thread_id,
|
||||
query=query,
|
||||
)
|
||||
await events.emit(
|
||||
"collection_completed",
|
||||
phase="collecting",
|
||||
payload={
|
||||
"source": "chat_thread",
|
||||
"materialCount": len(harvested),
|
||||
"agentSummaryChars": len(agent_summary),
|
||||
},
|
||||
)
|
||||
for material in harvested:
|
||||
# Freeze the harvested list before publishing any progress. Source
|
||||
# event persistence is intentionally best-effort: a bad payload or a
|
||||
# temporarily unavailable event table must never prevent the writer
|
||||
# from receiving this already-collected evidence.
|
||||
material_provider = StaticHarvestMaterialProvider(harvested)
|
||||
try:
|
||||
await events.emit(
|
||||
"source_added",
|
||||
"collection_completed",
|
||||
phase="collecting",
|
||||
payload={
|
||||
"sourceId": material.id,
|
||||
"title": material.title,
|
||||
"url": material.url,
|
||||
"sourceType": material.source_type,
|
||||
"source": material.to_persistence_dict(material.query),
|
||||
"source": "chat_thread",
|
||||
"materialCount": material_provider.material_count,
|
||||
"agentSummaryChars": len(agent_summary),
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - progress telemetry must not lose evidence
|
||||
logger.warning("chat deep-research collection event failed; retaining harvested materials", exc_info=True)
|
||||
for material in harvested:
|
||||
try:
|
||||
await events.emit(
|
||||
"source_added",
|
||||
phase="collecting",
|
||||
payload={
|
||||
"sourceId": material.id,
|
||||
"title": material.title,
|
||||
"url": material.url,
|
||||
"sourceType": material.source_type,
|
||||
"source": material.to_persistence_dict(material.query),
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - source rows are a projection, not the input
|
||||
logger.warning(
|
||||
"chat deep-research source event failed for %s; retaining harvested materials",
|
||||
material.id,
|
||||
exc_info=True,
|
||||
)
|
||||
if not harvested:
|
||||
await events.emit(
|
||||
"warning",
|
||||
@@ -1532,7 +1547,7 @@ class DeepResearchJobExecutor:
|
||||
"recoverable": True,
|
||||
},
|
||||
)
|
||||
return StaticHarvestMaterialProvider(harvested)
|
||||
return material_provider
|
||||
|
||||
async def _generate_report_summary(
|
||||
self,
|
||||
|
||||
@@ -47,6 +47,9 @@ _SSE_HEARTBEAT_SECONDS = 1.0
|
||||
|
||||
class CreatePlanningSessionBody(BaseModel):
|
||||
query: str = Field(min_length=1, max_length=12_000)
|
||||
model_name: str | None = Field(default=None, alias="modelName", max_length=191)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class UpdateProposalGraphBody(BaseModel):
|
||||
@@ -66,6 +69,18 @@ def _require_enabled() -> None:
|
||||
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
|
||||
|
||||
|
||||
def _selected_model_name(value: str | None) -> str | None:
|
||||
model_name = str(value or "").strip()
|
||||
if not model_name:
|
||||
return None
|
||||
if get_app_config().get_model_config(model_name) is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "WORKFLOW_MODEL_INVALID", "message": "所选模型不存在或已不可用"},
|
||||
)
|
||||
return model_name
|
||||
|
||||
|
||||
def _store(request: Request) -> WorkflowPlanningStore:
|
||||
store = getattr(request.app.state, "workflow_planning_store", None)
|
||||
if store is None:
|
||||
@@ -225,11 +240,13 @@ async def _create_planning_session(
|
||||
},
|
||||
) from exc
|
||||
try:
|
||||
model_name = _selected_model_name(body.model_name)
|
||||
proposals = await build_workflow_proposals(
|
||||
request.app,
|
||||
source_graph=source_graph,
|
||||
query=body.query.strip(),
|
||||
owner_id=user_id,
|
||||
model_name=model_name,
|
||||
source_canvas_schema=str(definition.get("draft_canvas_schema") or "{}"),
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ class StartRunBody(BaseModel):
|
||||
# 对话面板的显式定向任务:不是打断既有 run,而是从当前图中选取一个已
|
||||
# 绑定的 Agent,投影成可审计的 start → agent → output 新运行。
|
||||
target_agent_id: str | None = Field(default=None, alias="targetAgentId", max_length=191)
|
||||
model_name: str | None = Field(default=None, alias="modelName", max_length=191)
|
||||
# 规划会话中的已选候选图。二者必须同时给出;正式执行只读取服务端持久化
|
||||
# 的 proposal 快照,永不接受浏览器直接塞入的临时 graph。
|
||||
planning_session_id: str | None = Field(default=None, alias="planningSessionId", max_length=64)
|
||||
@@ -105,6 +106,18 @@ def _require_enabled() -> None:
|
||||
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
|
||||
|
||||
|
||||
def _selected_model_name(value: str | None) -> str | None:
|
||||
model_name = str(value or "").strip()
|
||||
if not model_name:
|
||||
return None
|
||||
if get_app_config().get_model_config(model_name) is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": "WORKFLOW_MODEL_INVALID", "message": "所选模型不存在或已不可用"},
|
||||
)
|
||||
return model_name
|
||||
|
||||
|
||||
def _runs(request: Request) -> WorkflowRunStore:
|
||||
store = getattr(request.app.state, "workflow_run_store", None)
|
||||
if store is None:
|
||||
@@ -216,7 +229,11 @@ async def _run_validation_issues(
|
||||
)
|
||||
|
||||
|
||||
def _agent_task_graph(graph: WorkflowGraph, target_agent_id: str) -> WorkflowGraph:
|
||||
def _agent_task_graph(
|
||||
graph: WorkflowGraph,
|
||||
target_agent_id: str,
|
||||
model_name: str | None = None,
|
||||
) -> WorkflowGraph:
|
||||
"""Build an explicit, durable one-agent task graph from a canvas graph.
|
||||
|
||||
A chat composer cannot safely "message" a running node: that loses DAG
|
||||
@@ -260,6 +277,8 @@ def _agent_task_graph(graph: WorkflowGraph, target_agent_id: str) -> WorkflowGra
|
||||
bindings["workflowInput"] = "{{ inputs }}"
|
||||
config["inputBindings"] = bindings
|
||||
config["agentName"] = str(config.get("agentName") or agent.name or target_agent_id)
|
||||
if model_name:
|
||||
config["modelName"] = model_name
|
||||
|
||||
return WorkflowGraph.model_validate(
|
||||
{
|
||||
@@ -434,8 +453,9 @@ async def start_run(request: Request, workflow_id: str, body: StartRunBody) -> d
|
||||
) from exc
|
||||
|
||||
target_agent_id = str(body.target_agent_id or "").strip()
|
||||
model_name = _selected_model_name(body.model_name)
|
||||
if target_agent_id:
|
||||
graph = _agent_task_graph(graph, target_agent_id)
|
||||
graph = _agent_task_graph(graph, target_agent_id, model_name)
|
||||
|
||||
issues = await _run_validation_issues(
|
||||
request,
|
||||
|
||||
@@ -181,7 +181,11 @@ def _to_coze_canvas(definition: dict[str, Any], *, is_published: bool = False) -
|
||||
"schema_json": _canvas_schema_text(definition),
|
||||
"status": 1,
|
||||
"collaborator_mode": "Open",
|
||||
"creator": {"user_id": owner, "nickname": owner},
|
||||
# Playground derives editability from this flag even when the
|
||||
# VCS `can_edit` value is true. Omitting it puts an owned
|
||||
# standalone workflow into preview/read-only mode, which also
|
||||
# disables dragging candidate nodes on the canvas.
|
||||
"creator": {"user_id": owner, "nickname": owner, "self": True},
|
||||
"is_published": is_published,
|
||||
},
|
||||
"vcs_data": {"can_edit": True},
|
||||
|
||||
@@ -413,6 +413,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"subagent_enabled",
|
||||
"max_concurrent_subagents",
|
||||
"agent_name",
|
||||
# 页面研报设计师由前端随消息带入当前界面明暗模式;需要进入 agent
|
||||
# 提示词及 ToolRuntime.context,才能选择同色系模板。
|
||||
"presentation_theme",
|
||||
"is_bootstrap",
|
||||
"is_writing_setup",
|
||||
"is_report_structure_setup",
|
||||
|
||||
@@ -12,6 +12,7 @@ asks for ``shared_run_thread``, which keeps the whole run in one conversation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
@@ -37,12 +38,99 @@ def _extract_text(content: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_reasoning(message: AIMessage) -> str:
|
||||
"""Read provider reasoning that LangChain keeps outside ``content``.
|
||||
|
||||
Patched OpenAI/vLLM providers preserve the streaming field under
|
||||
``additional_kwargs.reasoning_content``. Some compatible providers use
|
||||
the shorter ``reasoning`` spelling, so accept both without making either
|
||||
field part of the workflow's executable output.
|
||||
"""
|
||||
|
||||
additional_kwargs = getattr(message, "additional_kwargs", None)
|
||||
if not isinstance(additional_kwargs, dict):
|
||||
return ""
|
||||
for key in ("reasoning_content", "reasoning"):
|
||||
value = additional_kwargs.get(key)
|
||||
text = _extract_text(value)
|
||||
if text:
|
||||
return text
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _thread_id(run_id: str, node_id: str, thread_mode: str) -> str:
|
||||
if thread_mode == "shared_run_thread":
|
||||
return f"wf-{run_id}"
|
||||
return f"wf-{run_id}-{node_id}"
|
||||
|
||||
|
||||
def _native_message_frame(chunk: Any) -> list[Any]:
|
||||
"""Keep the DeerFlow/LangGraph ``messages`` tuple intact for the client.
|
||||
|
||||
The workflow event envelope adds run/node identity, but the nested value is
|
||||
deliberately the same two-item shape consumed by DeerFlow's chat UI:
|
||||
``[message, metadata]``. Do not project tool args, result content, or
|
||||
``additional_kwargs`` here; those fields are what power rich tool and user
|
||||
assistance cards after a live stream reconnects.
|
||||
"""
|
||||
|
||||
def encode(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
return encode(value.model_dump(mode="json"))
|
||||
if isinstance(value, dict):
|
||||
return {str(key): encode(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [encode(item) for item in value]
|
||||
# Force a JSON-compatible copy without discarding or truncating any
|
||||
# field. LangGraph metadata is normally already JSON-native; ``str``
|
||||
# is only a serialization fallback for provider-specific objects.
|
||||
try:
|
||||
json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
return encode(chunk if isinstance(chunk, tuple) else (chunk, {}))
|
||||
|
||||
|
||||
def _resume_message(payload: dict[str, Any]) -> str:
|
||||
"""Represent a workflow resume as the next user turn in the same thread."""
|
||||
return (
|
||||
"用户已回复你刚才的协助请求。请基于已有对话继续当前任务,不要重复询问已经回答的信息。\n"
|
||||
"<workflow-user-assistance-response>\n"
|
||||
f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'), default=str)}\n"
|
||||
"</workflow-user-assistance-response>"
|
||||
)
|
||||
|
||||
|
||||
def _clarification_pending(message: ToolMessage) -> dict[str, Any] | None:
|
||||
"""Identify DeerFlow's native ``ask_clarification`` tool result.
|
||||
|
||||
The complete card payload continues to live in the untouched ToolMessage
|
||||
sent through ``node.message``. The pause descriptor only carries the
|
||||
stable tool-call identity required to reconnect it to this node run.
|
||||
"""
|
||||
if str(getattr(message, "name", "") or "") != "ask_clarification":
|
||||
return None
|
||||
additional_kwargs = getattr(message, "additional_kwargs", None)
|
||||
clarification = (
|
||||
additional_kwargs.get("clarification")
|
||||
if isinstance(additional_kwargs, dict)
|
||||
else None
|
||||
)
|
||||
if not isinstance(clarification, dict):
|
||||
return None
|
||||
call_id = str(getattr(message, "tool_call_id", "") or getattr(message, "id", "") or "")
|
||||
question = str(clarification.get("question") or "")
|
||||
return {
|
||||
"toolCallId": call_id,
|
||||
"prompt": question or "智能体需要你补充信息后才能继续。",
|
||||
"actions": ["submit"],
|
||||
}
|
||||
|
||||
|
||||
class WorkflowAgentRunner:
|
||||
def __init__(self, app: Any) -> None:
|
||||
self._app = app
|
||||
@@ -60,6 +148,8 @@ class WorkflowAgentRunner:
|
||||
cancel: Any = None,
|
||||
on_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
on_message: Callable[[list[Any]], Awaitable[None]] | None = None,
|
||||
resume_payload: dict[str, Any] | None = None,
|
||||
model_name: str | None = None,
|
||||
disable_tools: bool = False,
|
||||
thinking_enabled: bool | None = None,
|
||||
@@ -119,9 +209,33 @@ class WorkflowAgentRunner:
|
||||
final_state: dict[str, Any] | None = None
|
||||
streamed: dict[str, list[str]] = {}
|
||||
last_message_id = ""
|
||||
open_reasoning_message_ids: set[str] = set()
|
||||
|
||||
async def emit_display_delta(text: str) -> None:
|
||||
if on_delta is not None and text:
|
||||
await on_delta(text)
|
||||
|
||||
async def close_reasoning(*, except_message_id: str | None = None) -> None:
|
||||
"""Keep thinking blocks well-formed across tool/model turns."""
|
||||
|
||||
for message_id in tuple(open_reasoning_message_ids):
|
||||
if message_id == except_message_id:
|
||||
continue
|
||||
open_reasoning_message_ids.remove(message_id)
|
||||
await emit_display_delta("</think>")
|
||||
|
||||
initial_messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": _resume_message(resume_payload)
|
||||
if resume_payload is not None
|
||||
else prompt,
|
||||
}
|
||||
]
|
||||
awaiting_input: dict[str, Any] | None = None
|
||||
|
||||
async for mode, chunk in agent.astream(
|
||||
{"messages": [{"role": "user", "content": prompt}]},
|
||||
{"messages": initial_messages},
|
||||
config=config,
|
||||
stream_mode=["values", "messages"],
|
||||
):
|
||||
@@ -133,35 +247,44 @@ class WorkflowAgentRunner:
|
||||
continue
|
||||
if mode != "messages":
|
||||
continue
|
||||
if on_message is not None:
|
||||
await on_message(_native_message_frame(chunk))
|
||||
message = chunk[0] if isinstance(chunk, tuple) and chunk else chunk
|
||||
if isinstance(message, ToolMessage):
|
||||
# Tool result for an earlier call: close its step in the event log.
|
||||
await close_reasoning()
|
||||
call_id = str(getattr(message, "tool_call_id", "") or "")
|
||||
if call_id and call_id not in finished_tool_calls:
|
||||
finished_tool_calls.add(call_id)
|
||||
if on_tool is not None:
|
||||
content = getattr(message, "content", "")
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
await on_tool(
|
||||
{
|
||||
"phase": "finished",
|
||||
"id": call_id,
|
||||
"name": tool_names.get(call_id) or getattr(message, "name", "") or "",
|
||||
"ok": getattr(message, "status", None) != "error",
|
||||
"summary": content[:200],
|
||||
"result": getattr(message, "content", ""),
|
||||
}
|
||||
)
|
||||
awaiting_input = _clarification_pending(message)
|
||||
if awaiting_input is not None:
|
||||
break
|
||||
continue
|
||||
if not isinstance(message, AIMessage):
|
||||
continue
|
||||
message_id = str(getattr(message, "id", "") or "__workflow_reasoning__")
|
||||
reasoning = _extract_reasoning(message)
|
||||
if reasoning:
|
||||
if message_id not in open_reasoning_message_ids:
|
||||
open_reasoning_message_ids.add(message_id)
|
||||
await emit_display_delta("<think>")
|
||||
await emit_display_delta(reasoning)
|
||||
text = _extract_text(getattr(message, "content", ""))
|
||||
if text:
|
||||
message_id = str(getattr(message, "id", "") or "")
|
||||
await close_reasoning()
|
||||
streamed.setdefault(message_id, []).append(text)
|
||||
last_message_id = message_id
|
||||
if on_delta is not None:
|
||||
await on_delta(text)
|
||||
await emit_display_delta(text)
|
||||
for call in getattr(message, "tool_calls", None) or []:
|
||||
call_id = str(call.get("id") or "")
|
||||
if not call_id or call_id in seen_tool_calls:
|
||||
@@ -178,6 +301,23 @@ class WorkflowAgentRunner:
|
||||
}
|
||||
)
|
||||
|
||||
# A reasoning-only turn that delegates to a tool must end before
|
||||
# the tool result and next model turn arrive. Otherwise a later
|
||||
# answer could be swallowed by one long unclosed `<think>` block.
|
||||
if getattr(message, "tool_calls", None):
|
||||
await close_reasoning()
|
||||
|
||||
await close_reasoning()
|
||||
|
||||
if awaiting_input is not None:
|
||||
return {
|
||||
"awaiting_input": awaiting_input,
|
||||
"thread_id": thread_id,
|
||||
"messages": [],
|
||||
"artifacts": self._artifacts(final_state),
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
final_text = "".join(streamed.get(last_message_id, ())) or self._final_text(final_state)
|
||||
if not final_text.strip():
|
||||
raise WorkflowError(
|
||||
|
||||
@@ -273,8 +273,10 @@ class WorkflowRunExecutor:
|
||||
"prompt": paused.pending_input.get("prompt") or "",
|
||||
"formSchema": paused.pending_input.get("formSchema") or {},
|
||||
"actions": paused.pending_input.get("actions") or ["submit"],
|
||||
"toolCallId": paused.pending_input.get("toolCallId") or "",
|
||||
},
|
||||
node_id=paused.node_id,
|
||||
node_run_id=ctx.node_run_ids.get(paused.node_id),
|
||||
)
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,6 +23,8 @@ from deerflow.workflows.schemas import WorkflowGraph
|
||||
_JSON_BLOCK = re.compile(r"```(?:json)?\s*(.+?)\s*```", re.DOTALL | re.IGNORECASE)
|
||||
_QUERY_PREVIEW = 64
|
||||
_STRATEGIES = ("collaborative", "parallel_research", "quick_answer")
|
||||
_TASK_CONTRACT_FIELDS = ("mission", "deliverable", "scope", "handoff")
|
||||
_TASK_CONTRACT_FIELD_LIMIT = 600
|
||||
PlanningProgressEmitter = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
@@ -106,6 +108,103 @@ def _agent_skills(node: Any) -> list[str]:
|
||||
return [str(item) for item in raw] if isinstance(raw, list) else []
|
||||
|
||||
|
||||
def _default_task_contract(
|
||||
node: Any,
|
||||
*,
|
||||
strategy: str,
|
||||
index: int,
|
||||
total: int,
|
||||
) -> dict[str, str]:
|
||||
"""Describe one worker's bounded responsibility when the controller omits it.
|
||||
|
||||
The controller may specialize these fields, but an omitted model field must
|
||||
never send a worker back to a vague "help with the task" instruction.
|
||||
"""
|
||||
role = _agent_name(node)
|
||||
capability = _agent_description(node) or "其已配置的专业能力"
|
||||
if strategy == "parallel_research" and index == total - 1:
|
||||
return {
|
||||
"mission": f"作为{role}整合各并行调研分支,不重新发起无关调研。",
|
||||
"deliverable": "输出证据对照、冲突与不确定性、结论和可执行的下一步建议。",
|
||||
"scope": "只使用 workflowInput 和 evidencePack;将来源事实、推断和待验证假设明确区分。",
|
||||
"handoff": "交给深度研究报告写作节点;用清晰的结论结构和证据缺口帮助其成文。",
|
||||
}
|
||||
if strategy == "parallel_research":
|
||||
return {
|
||||
"mission": f"作为{role}从一个独立视角调查用户问题,发挥:{capability}。",
|
||||
"deliverable": "交付结构化证据笔记:关键事实、来源或验证方法、判断、置信度与信息缺口。",
|
||||
"scope": "只完成本调研分支;不替代综合角色写最终报告,不把未验证内容表述为事实。",
|
||||
"handoff": "结果会进入 Evidence Pack,供综合角色比对并交给报告写作节点。",
|
||||
}
|
||||
if strategy == "collaborative" and index == 0:
|
||||
return {
|
||||
"mission": f"作为{role}完成第一阶段任务拆解和基础分析,发挥:{capability}。",
|
||||
"deliverable": "交付问题分解、关键判断、证据或验证路径、明确的假设与待补信息。",
|
||||
"scope": "只建立可靠的上游材料,不假装已经完成后续角色的审阅或最终交付。",
|
||||
"handoff": "将可复核的结构化结果交给下游角色继续审阅和补强。",
|
||||
}
|
||||
if strategy == "collaborative":
|
||||
return {
|
||||
"mission": f"作为{role}审阅并补强上游成果,发挥:{capability}。",
|
||||
"deliverable": "交付经过校正的结论、补充证据、冲突说明、剩余风险与下一步建议。",
|
||||
"scope": "围绕 workflowInput 和 upstreamResult 工作;保留有效材料,明确标注不能确认的部分。",
|
||||
"handoff": "将可直接被下一阶段使用的完整结果交接给下游角色。",
|
||||
}
|
||||
return {
|
||||
"mission": f"作为{role}快速分析用户问题,发挥:{capability}。",
|
||||
"deliverable": "交付直接结论、可验证依据、关键假设和仍需用户补充的信息。",
|
||||
"scope": "只处理当前用户问题;信息不足时先说明限制,不能臆造事实或虚构执行结果。",
|
||||
"handoff": "这是该策略的最终用户可读输出,无需自行编排其他智能体。",
|
||||
}
|
||||
|
||||
|
||||
def _contract_text(value: Any, fallback: str) -> str:
|
||||
"""Accept short controller-owned text while keeping prompt assembly bounded."""
|
||||
text = _text(value).replace("\x00", " ")
|
||||
return (text or fallback)[:_TASK_CONTRACT_FIELD_LIMIT]
|
||||
|
||||
|
||||
def _task_contracts(
|
||||
model_result: dict[str, Any],
|
||||
agents: list[Any],
|
||||
*,
|
||||
strategy: str,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""Map controller task contracts to selected, visible worker ids only."""
|
||||
raw_contracts = model_result.get("taskContracts") or model_result.get("task_contracts")
|
||||
strategy_contracts = raw_contracts.get(strategy) if isinstance(raw_contracts, dict) else {}
|
||||
contracts: dict[str, dict[str, str]] = {}
|
||||
for index, agent in enumerate(agents):
|
||||
agent_id = _agent_id(agent)
|
||||
fallback = _default_task_contract(
|
||||
agent,
|
||||
strategy=strategy,
|
||||
index=index,
|
||||
total=len(agents),
|
||||
)
|
||||
raw = strategy_contracts.get(agent_id) if isinstance(strategy_contracts, dict) else None
|
||||
contracts[agent_id] = {
|
||||
field: _contract_text(raw.get(field) if isinstance(raw, dict) else None, fallback[field])
|
||||
for field in _TASK_CONTRACT_FIELDS
|
||||
}
|
||||
return contracts
|
||||
|
||||
|
||||
def _task_contract_suffix(contract: dict[str, str] | None) -> str:
|
||||
if not contract:
|
||||
return ""
|
||||
return (
|
||||
"<workflow-task-contract>\n"
|
||||
"这是工作流总控为你分配的固定岗位。只完成这个岗位,不自行改派角色或重复总控职责。\n"
|
||||
f"专属任务:{contract['mission']}\n"
|
||||
f"交付产物:{contract['deliverable']}\n"
|
||||
f"工作范围与质量要求:{contract['scope']}\n"
|
||||
f"交接方式:{contract['handoff']}\n"
|
||||
"如果缺少完成本岗位必需的信息,说明缺口并通过用户协助工具提问;不要用虚构内容填补。\n"
|
||||
"</workflow-task-contract>"
|
||||
)
|
||||
|
||||
|
||||
def _catalog_payload(agents: list[Any]) -> list[dict[str, Any]]:
|
||||
"""Return metadata only; agent SOUL/configuration must never enter a prompt."""
|
||||
unique: dict[str, dict[str, Any]] = {}
|
||||
@@ -219,16 +318,26 @@ def _chat_input_schema(graph: WorkflowGraph) -> dict[str, Any]:
|
||||
return schema
|
||||
|
||||
|
||||
def _agent_config(node: Any, *, prompt_suffix: str = "", bindings: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
def _agent_config(
|
||||
node: Any,
|
||||
*,
|
||||
prompt_suffix: str = "",
|
||||
task_contract: dict[str, str] | None = None,
|
||||
bindings: dict[str, str] | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = copy.deepcopy(node.config)
|
||||
template = _text(config.get("promptTemplate") or config.get("prompt_template"), "请完成用户的请求。")
|
||||
config["promptTemplate"] = f"{template}\n\n{prompt_suffix}".strip()
|
||||
prompt_parts = [template, prompt_suffix, _task_contract_suffix(task_contract)]
|
||||
config["promptTemplate"] = "\n\n".join(part for part in prompt_parts if part).strip()
|
||||
original = config.get("inputBindings") or config.get("input_bindings")
|
||||
resolved = dict(original) if isinstance(original, dict) else {}
|
||||
resolved["workflowInput"] = "{{ inputs }}"
|
||||
if bindings:
|
||||
resolved.update(bindings)
|
||||
config["inputBindings"] = resolved
|
||||
if model_name:
|
||||
config["modelName"] = model_name
|
||||
return config
|
||||
|
||||
|
||||
@@ -241,7 +350,15 @@ def _output_node(node_id: str, source_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _single_agent_graph(source: WorkflowGraph, node: Any, *, name: str, strategy: str) -> dict[str, Any]:
|
||||
def _single_agent_graph(
|
||||
source: WorkflowGraph,
|
||||
node: Any,
|
||||
*,
|
||||
name: str,
|
||||
strategy: str,
|
||||
task_contract: dict[str, str] | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
source_id = str(node.id)
|
||||
start_id = f"__{strategy}_start__"
|
||||
output_id = f"__{strategy}_output__"
|
||||
@@ -260,6 +377,8 @@ def _single_agent_graph(source: WorkflowGraph, node: Any, *, name: str, strategy
|
||||
"config": _agent_config(
|
||||
node,
|
||||
prompt_suffix="你负责快速澄清用户问题、给出可验证的结论;信息不足时明确列出缺口。",
|
||||
task_contract=task_contract,
|
||||
model_name=model_name,
|
||||
),
|
||||
},
|
||||
_output_node(output_id, source_id),
|
||||
@@ -271,10 +390,23 @@ def _single_agent_graph(source: WorkflowGraph, node: Any, *, name: str, strategy
|
||||
}
|
||||
|
||||
|
||||
def _parallel_research_graph(source: WorkflowGraph, agents: list[Any]) -> dict[str, Any]:
|
||||
def _parallel_research_graph(
|
||||
source: WorkflowGraph,
|
||||
agents: list[Any],
|
||||
*,
|
||||
task_contracts: dict[str, dict[str, str]] | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fan out collection roles and use the last configured agent as synthesizer."""
|
||||
if len(agents) < 2:
|
||||
return _single_agent_graph(source, agents[0], name="快速验证方案", strategy="quick_answer")
|
||||
return _single_agent_graph(
|
||||
source,
|
||||
agents[0],
|
||||
name="快速验证方案",
|
||||
strategy="quick_answer",
|
||||
task_contract=(task_contracts or {}).get(_agent_id(agents[0])),
|
||||
model_name=model_name,
|
||||
)
|
||||
start_id = "__parallel_start__"
|
||||
evidence_id = "__parallel_evidence__"
|
||||
report_id = "__parallel_report__"
|
||||
@@ -294,6 +426,8 @@ def _parallel_research_graph(source: WorkflowGraph, agents: list[Any]) -> dict[s
|
||||
"config": _agent_config(
|
||||
researcher,
|
||||
prompt_suffix="你是并行调研角色。围绕用户任务收集事实、假设和可引用依据,输出结构化要点给汇总角色。",
|
||||
task_contract=(task_contracts or {}).get(_agent_id(researcher)),
|
||||
model_name=model_name,
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -319,7 +453,9 @@ def _parallel_research_graph(source: WorkflowGraph, agents: list[Any]) -> dict[s
|
||||
"config": _agent_config(
|
||||
synthesis,
|
||||
prompt_suffix="你是综合分析角色。基于 Evidence Pack 比较各调研结果,标注冲突、假设和证据不足,形成面向用户的结论与下一步建议。",
|
||||
task_contract=(task_contracts or {}).get(_agent_id(synthesis)),
|
||||
bindings=synthesis_bindings,
|
||||
model_name=model_name,
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -375,7 +511,13 @@ def _parallel_research_graph(source: WorkflowGraph, agents: list[Any]) -> dict[s
|
||||
}
|
||||
|
||||
|
||||
def _collaborative_graph(source: WorkflowGraph, agents: list[Any]) -> dict[str, Any]:
|
||||
def _collaborative_graph(
|
||||
source: WorkflowGraph,
|
||||
agents: list[Any],
|
||||
*,
|
||||
task_contracts: dict[str, dict[str, str]] | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a sequential expert hand-off from the controller's selection."""
|
||||
if not agents:
|
||||
raise WorkflowPlannerUnavailableError("总控没有选出可执行的协作智能体")
|
||||
@@ -399,7 +541,13 @@ def _collaborative_graph(source: WorkflowGraph, agents: list[Any]) -> dict[str,
|
||||
"id": node_id,
|
||||
"type": agent.type,
|
||||
"name": _agent_name(agent),
|
||||
"config": _agent_config(agent, prompt_suffix=role_hint, bindings=bindings),
|
||||
"config": _agent_config(
|
||||
agent,
|
||||
prompt_suffix=role_hint,
|
||||
task_contract=(task_contracts or {}).get(_agent_id(agent)),
|
||||
bindings=bindings,
|
||||
model_name=model_name,
|
||||
),
|
||||
}
|
||||
)
|
||||
edges.append({"id": f"{previous_id}_to_{node_id}", "source": previous_id, "target": node_id})
|
||||
@@ -435,6 +583,7 @@ async def _ask_workflow_controller(
|
||||
query: str,
|
||||
owner_id: str,
|
||||
agents: list[Any],
|
||||
model_name: str | None = None,
|
||||
on_progress: PlanningProgressEmitter | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Ask the dedicated controller to understand intent and select agents.
|
||||
@@ -479,9 +628,16 @@ async def _ask_workflow_controller(
|
||||
'"recommendedStrategy":"collaborative|parallel_research|quick_answer",'
|
||||
'"strategyOrder":["..."],'
|
||||
'"agentSelections":{"collaborative":["agentId"],"parallel_research":["agentId"],"quick_answer":["agentId"]},'
|
||||
'"taskContracts":{"策略":{"agentId":{"mission":"不超过180字的专属任务",'
|
||||
'"deliverable":"不超过180字的交付物",'
|
||||
'"scope":"不超过180字的边界和证据要求",'
|
||||
'"handoff":"不超过180字的交接说明"}}},'
|
||||
'"titles":{"策略":"不超过20字标题"},'
|
||||
'"summaries":{"策略":"不超过120字方案说明"},'
|
||||
'"rationales":{"策略":"不超过120字选择理由"}}。\n\n'
|
||||
'"rationales":{"策略":"不超过120字选择理由"}}。'
|
||||
"taskContracts 只能为已选中的逐字匹配 agentId 提供岗位说明;"
|
||||
"并行策略中每个调研角色必须分配互补视角,最后的综合角色必须明确 Evidence Pack 的使用方式;"
|
||||
"顺序协作策略必须明确上一角色交接给下一角色的内容。\n\n"
|
||||
f"用户需求:{query}\n可用业务智能体目录:{json.dumps(catalog, ensure_ascii=False)}"
|
||||
)
|
||||
received_first_delta = False
|
||||
@@ -506,6 +662,7 @@ async def _ask_workflow_controller(
|
||||
disable_tools=True,
|
||||
thinking_enabled=False,
|
||||
force_disable_thinking=True,
|
||||
model_name=model_name,
|
||||
on_delta=on_delta,
|
||||
)
|
||||
parsed = _decode_planner_json(_text(result.get("text")))
|
||||
@@ -564,6 +721,7 @@ async def build_workflow_proposals(
|
||||
source_graph: WorkflowGraph,
|
||||
query: str,
|
||||
owner_id: str,
|
||||
model_name: str | None = None,
|
||||
source_canvas_schema: str = "{}",
|
||||
on_progress: PlanningProgressEmitter | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -589,6 +747,7 @@ async def build_workflow_proposals(
|
||||
query=query,
|
||||
owner_id=owner_id,
|
||||
agents=agents,
|
||||
model_name=model_name,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
await _emit_progress(
|
||||
@@ -604,6 +763,21 @@ async def build_workflow_proposals(
|
||||
parallel_agents = _strategy_selection(
|
||||
model_result, agents, "parallel_research", fallback_count=min(4, len(agents))
|
||||
)
|
||||
collaborative_contracts = _task_contracts(
|
||||
model_result,
|
||||
collaborative_agents,
|
||||
strategy="collaborative",
|
||||
)
|
||||
quick_contracts = _task_contracts(
|
||||
model_result,
|
||||
quick_agents,
|
||||
strategy="quick_answer",
|
||||
)
|
||||
parallel_contracts = _task_contracts(
|
||||
model_result,
|
||||
parallel_agents,
|
||||
strategy="parallel_research",
|
||||
)
|
||||
await _emit_progress(
|
||||
on_progress,
|
||||
phase="selection",
|
||||
@@ -625,7 +799,12 @@ async def build_workflow_proposals(
|
||||
"summary": f"由选定角色依次复核“{preview}”,逐步补足证据与结论。",
|
||||
"rationale": "适合需要角色接力、审阅和逐步收敛的复杂任务。",
|
||||
"estimated_duration_seconds": 180,
|
||||
"graph": _collaborative_graph(source_graph, collaborative_agents),
|
||||
"graph": _collaborative_graph(
|
||||
source_graph,
|
||||
collaborative_agents,
|
||||
task_contracts=collaborative_contracts,
|
||||
model_name=model_name,
|
||||
),
|
||||
"canvas_schema": "{}",
|
||||
},
|
||||
}
|
||||
@@ -635,7 +814,14 @@ async def build_workflow_proposals(
|
||||
"summary": f"由 {_agent_name(quick_agents[0])} 先处理“{preview}”,快速给出结论与信息缺口。",
|
||||
"rationale": "适合先校验方向、范围明确或需要控制成本的任务。",
|
||||
"estimated_duration_seconds": 45,
|
||||
"graph": _single_agent_graph(source_graph, quick_agents[0], name="快速验证方案", strategy="quick_answer"),
|
||||
"graph": _single_agent_graph(
|
||||
source_graph,
|
||||
quick_agents[0],
|
||||
name="快速验证方案",
|
||||
strategy="quick_answer",
|
||||
task_contract=quick_contracts.get(_agent_id(quick_agents[0])),
|
||||
model_name=model_name,
|
||||
),
|
||||
"canvas_schema": "{}",
|
||||
}
|
||||
if len(parallel_agents) >= 2:
|
||||
@@ -645,7 +831,12 @@ async def build_workflow_proposals(
|
||||
"summary": f"多个角色并行收集“{preview}”相关依据,再由综合角色输出统一结论和报告。",
|
||||
"rationale": "适合研究、诊断、方案和报告类任务,可减少单一视角遗漏。",
|
||||
"estimated_duration_seconds": 600,
|
||||
"graph": _parallel_research_graph(source_graph, parallel_agents),
|
||||
"graph": _parallel_research_graph(
|
||||
source_graph,
|
||||
parallel_agents,
|
||||
task_contracts=parallel_contracts,
|
||||
model_name=model_name,
|
||||
),
|
||||
"canvas_schema": "{}",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user