from __future__ import annotations import asyncio import json import time from typing import Any from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langgraph.errors import GraphRecursionError from langgraph.func import task from app.analysis.contract import CallError from app.core.config import settings from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import Workflow from app.engine.context import ( LABEL_PREFIX_KEY, PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer, ) from app.engine.lineage import whole_ref from app.engine.state import GraphState, message_text, template_context from app.engine.toolcalls import ( TOOL_MARKUP_NUDGE, ToolTimeout, leaked_markup, limit_fields, limit_of, markup_warning, run_bounded, ) from app.providers import catalog from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model from app.engine.sql_problems import event_checks from app.data import catalog_view from app.tools.analysis import ( REFUSED_CODE as ANALYSIS_REFUSED, SCOPE_KEY as ANALYSIS_SCOPE_KEY, AnalysisScope, call_error_text, event_view, input_snapshot, is_analysis_tool, payload_of, reply_text, run_snapshot, ) from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions from app.tools.registry import ( ToolArgsError, ToolContext, args_model_of, build_tools, guides_text, prepare_args, ) from app.tools.trust import ask_gate, call_policy _MAX_DEPTH = 3 #: 调度者在 llm.end 里的 agent 名。成员用各自的名字 COORDINATOR = "调度者" #: 成员把工具调用写成文字、纠正之后还是这样时,交给调度者的那句话 MEMBER_MARKUP_FAILED = ("模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据" "(常见原因:该成员未绑定工具,或模型、服务不支持工具调用)") # -------------------------------------------------------------------------- # Supervisor:一个调度者 + 若干专家 # -------------------------------------------------------------------------- async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: """多 agent 协作。 调度者每轮只做一件事:看当前进展,决定交给哪个专家、并给它一句具体指令, 或者宣布收工。专家各自带自己的 system prompt 和工具集。 把整个协作塞进一个节点,是为了让画布保持可读 —— 展开细节看运行时间线就够了。 """ agents = ctx.cfg("agents", []) or [] if not agents: raise NodeError(ctx.node.id, "「多 Agent 协作」节点未配置团队成员,至少需要一个") names = [a.get("name") or f"agent{i}" for i, a in enumerate(agents)] agent_map = {n: a for n, a in zip(names, agents)} max_rounds = min(int(ctx.cfg("max_rounds", 6) or 6), settings.max_agent_steps) #: 轮数用完、调度者始终没判定完成时怎么办。fail:判失败;degrade:照样交付但标明降档。 #: 缺省 fail——成员的原话不是结论,把它当成功交出去比失败更糟 on_exhausted = "degrade" if ctx.cfg("on_exhausted") == "degrade" else "fail" goal = ctx.render_str(ctx.cfg("goal", "{{ last_message }}"), state) if not goal.strip(): goal = json.dumps(template_context(state).get("input"), ensure_ascii=False) async with SessionLocal() as session: supervisor_model, sup_model_id = await get_chat_model( session, ModelSpec( provider=ctx.cfg("provider"), model=ctx.cfg("model"), max_tokens=int(ctx.cfg("max_tokens", 2048) or 2048), ), ) roster = "\n".join( f"- {n}:{agent_map[n].get('description') or agent_map[n].get('system', '')[:100]}" for n in names ) # 一轮可以派给多个专家。协议用 assignments 数组而不是单个 next: # 后者把"一次只能派一个"编进了协议本身,模型再想并行也表达不出来。 # # 但并行只在任务**互不依赖**时才成立——并发的几个专家看到的是同一份进展 # 快照,谁也看不见谁。派错了不会报错,只会让两个人基于同样的旧信息 # 重复劳动,而这件事在结果里很难看出来。所以判断责任明确压在调度者身上, # 提示词里把反例写出来。 route_schema = { "title": "route", # langchain 靠 title 识别 JSON Schema dict "type": "object", "properties": { "assignments": { "type": "array", "description": "这一轮要派出去的任务。互不依赖时可以给多个,它们会同时执行", "items": { "type": "object", "properties": { "agent": {"type": "string", "enum": names}, "instruction": {"type": "string"}, }, "required": ["agent", "instruction"], }, }, "done": {"type": "boolean", "description": "目标已达成,结束协作"}, "reason": {"type": "string"}, }, "required": ["assignments"], } #: 一轮最多同时派几个。再多的话调度者多半只是在把任务拆碎, #: 而每多一路就多一份"基于陈旧进展"的风险 max_parallel = max(1, min(int(ctx.cfg("max_parallel", 3) or 3), len(names))) #: dangerous:需要人工确认的调用不执行(成员停不下来等人);never:全部放行 approval = ctx.approval_mode() transcript: list[dict[str, Any]] = [] usage_total = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0} def _usage(msg: Any, model_id: str) -> dict[str, Any]: meta = getattr(msg, "usage_metadata", None) or {} i, o = int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0) return {"input_tokens": i, "output_tokens": o, "cost_usd": round(catalog.estimate_cost(model_id, i, o), 6)} def _accumulate(one: dict[str, Any], into: dict[str, Any] | None = None) -> None: into = usage_total if into is None else into into["input_tokens"] += one["input_tokens"] into["output_tokens"] += one["output_tokens"] into["calls"] += one.get("calls", 1) into["cost_usd"] = round(into["cost_usd"] + one["cost_usd"], 6) def _report(who: str, model_id: str, one: dict[str, Any], t0: float) -> None: # 每次模型调用各发一条 llm.end,界面上的用量才能实时涨,而且分得清是谁花的。 # 它只是把 usage_total 这笔账逐次摊开:run.finished 的 usage 仍以累计为准 ctx.emit(EventType.LLM_END, agent=who, model=model_id, duration_ms=int((time.perf_counter() - t0) * 1000), **one) async def _decide(prompt: str, *, closing: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: """问调度者一次。返回 (决定, 这次调用的用量)。""" spent = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0} try: # include_raw:结构化结果之外把原始消息也要回来,用量在那条消息上。 # 以前只拿解析结果,调度者的 token 一笔都没记进 usage out = await supervisor_model.with_structured_output( route_schema, include_raw=True).ainvoke(prompt) if isinstance(out, dict) and "raw" in out and "parsed" in out: spent = _usage(out["raw"], sup_model_id) value = out["parsed"] if value is None: value = json.loads(message_text(out["raw"])) else: value = out if not isinstance(value, dict): value = json.loads(message_text(value)) except Exception: # noqa: BLE001 # 不支持结构化输出的模型退回文本:从回复里认出一个成员名,按单人派。 # 收尾判定时同理:回复里还点着哪个成员,就是还没完 reply = await supervisor_model.ainvoke(prompt) spent = _usage(reply, sup_model_id) raw = message_text(reply) picked = next((n for n in names if n in raw), None) if closing: value = {"assignments": [], "done": picked is None, "reason": raw[:200]} else: value = ({"assignments": [{"agent": picked, "instruction": raw[:500]}]} if picked else {"assignments": [], "done": True, "reason": raw[:200]}) return (value if isinstance(value, dict) else {}), spent def _batch_of(decision: dict[str, Any]) -> list[tuple[str, str]]: # 只留认得出的成员,并按上限截断。模型偶尔会重复派同一个人或者编一个 # 不存在的名字——这两种在并发里都是纯浪费 batch: list[tuple[str, str]] = [] seen: set[str] = set() for item in decision.get("assignments") or []: name = str((item or {}).get("agent") or "") if name in agent_map and name not in seen: seen.add(name) batch.append((name, str(item.get("instruction") or goal))) if len(batch) >= max_parallel: break return batch def _finished(round_no: int, reason: str, **extra: Any) -> None: # agents / done 是结构化字段,界面照着它渲染。以前只有一句中文 # 消息串,解码层得拿正则去拆「调度 → X(理由)」——文案一改就散架 ctx.emit(EventType.LOG, level="info", round=round_no, agents=[], done=True, message="调度 → 结束协作" + (f"({reason})" if reason else ""), **extra) # 调度者决策前后各一条事件(它常常占掉团队节点大半的时间,以前是黑盒),和派活的 # 那条日志一起都在 task 里发:「接着跑」一个失败的团队节点时整个节点重放,前几轮 # 的决定取自 checkpoint,这些事件不该在时间线上再出现一遍 @task async def route(round_no: int, progress: str) -> dict[str, Any]: ctx.emit(EventType.AGENT_ROUTE_START, round=round_no) prompt = ( f"你是团队调度者。目标:\n{goal}\n\n" f"可用成员:\n{roster}\n\n" f"当前进展:\n{progress or '(还没有任何进展)'}\n\n" "决定这一轮把什么任务交给谁,每个任务给一句明确的指令。\n\n" f"**互不依赖的任务可以放在同一轮**(最多 {max_parallel} 个),它们会同时执行," "总耗时按最慢的那个算。判断依据只有一条:**后一个任务需不需要看到前一个的结果**。\n" " 可以同时派:「查 A 产品的资料」和「查 B 产品的资料」——两边互不相干\n" " 不能同时派:「查资料」和「根据资料写报告」——后者要等前者\n" " 不能同时派:「写初稿」和「校对初稿」——同上\n\n" "同时派出去的成员看到的是**同一份进展快照**,谁也看不见谁这一轮干了什么。" "拿不准是否独立时就分两轮派,代价只是慢一点;派错了则是两个人基于同样的" "旧信息重复劳动,而这在结果里很难看出来。\n\n" "目标已经达成时,done 填 true、assignments 给空数组。" ) t0 = time.perf_counter() value, spent = await _decide(prompt) _report(COORDINATOR, sup_model_id, spent, t0) batch = _batch_of(value) finishing = bool(value.get("done")) or not batch reason = str(value.get("reason", "")) ctx.emit(EventType.AGENT_ROUTE_END, round=round_no, duration_ms=int((time.perf_counter() - t0) * 1000), agents=[] if finishing else [n for n, _ in batch], parallel=0 if finishing else len(batch), done=finishing, reason=reason) if finishing: _finished(round_no, reason) else: who = "、".join(n for n, _ in batch) ctx.emit( EventType.LOG, level="info", round=round_no, parallel=len(batch), agents=[n for n, _ in batch], done=False, reason=reason, message=(f"调度 → {who}" + (f"({reason})" if reason else "") + (f" · {len(batch)} 名成员同时执行" if len(batch) > 1 else "")), ) return {"decision": value, "usage": spent} # 最后一轮派出去的成员(常常就是定稿的那个)交回来之后,调度者还没看过它的产出。 # 以前轮数一到就算用完:步数恰好等于最多轮数的正常流水线(查数 → 撰稿,2 轮)也会 # 被判成没完成。补这一次只判定、不派活的决定,它说完成了才算完成 @task async def verdict(round_no: int, progress: str) -> dict[str, Any]: ctx.emit(EventType.AGENT_ROUTE_START, round=round_no, closing=True) prompt = ( f"你是团队调度者。目标:\n{goal}\n\n" f"当前进展:\n{progress or '(还没有任何进展)'}\n\n" f"轮数已经用完({max_rounds} 轮),不能再派任何任务。只判断一件事:按上面的进展," "目标是不是已经达成、能不能把最后一个成员的产出作为结论交出去。\n" "达成了 done 填 true;没达成 done 填 false,并在 reason 里说清还缺什么。" "assignments 给空数组。" ) t0 = time.perf_counter() value, spent = await _decide(prompt, closing=True) _report(COORDINATOR, sup_model_id, spent, t0) done = value.get("done") is True reason = str(value.get("reason", "")) ctx.emit(EventType.AGENT_ROUTE_END, round=round_no, duration_ms=int((time.perf_counter() - t0) * 1000), agents=[], parallel=0, done=done, reason=reason, closing=True) if done: _finished(round_no, reason, closing=True) return {"done": done, "reason": reason, "usage": spent} # 成员的开始、结束两条事件也在 task 里发,理由同 route。失败也在 task 里收住、作为 # 结果缓存:重放时这一步照旧是失败,不会换一个结果。用量随结果带出来再记账——在 # task 里直接记进 usage_total 的话,重放取了缓存,这一笔就没了 @task async def work(round_no: int, name: str, instruction: str, progress: str, parallel: int = 1) -> dict[str, Any]: ctx.emit(EventType.AGENT_STEP_START, agent=name, instruction=instruction[:300], round=round_no, parallel=parallel) tally = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0} t0 = time.perf_counter() try: out: dict[str, Any] = await _member(name, instruction, progress, tally) except asyncio.CancelledError: raise except BaseException as exc: # noqa: BLE001 - 一个专家挂了不该拖垮整轮 # 把失败当成它的产出交回调度者,让它决定改派还是收工 why = describe_exception(exc) text = f"({name} 执行失败:{why})" out = {"text": text, "tool_calls": [], "failed": True, "error": why} ctx.emit(EventType.LOG, level="warn", round=round_no, message=f"{name} 本轮执行失败:{text}") each_ms = int((time.perf_counter() - t0) * 1000) # failed / error 让矩阵把这一格画成失败,而不是一个"完成"的格子里写着失败原因 failure = {"failed": True, "error": out.get("error") or ""} if out.get("failed") else {} ctx.emit(EventType.AGENT_STEP_END, agent=name, duration_ms=each_ms, round=round_no, parallel=parallel, preview=out["text"][:500], **failure) return {**out, "duration_ms": each_ms, "usage": tally} async def _member(name: str, instruction: str, progress: str, tally: dict[str, Any]) -> dict[str, Any]: cfg = agent_map[name] # 绑了分析工具的成员:这一次执行自己的分析容器(同 Agent 节点)。查询结果照 Agent 的写法编成「【证据 Qn】」,分析结论 # 编成「【分析 An】」;协作节点不记证据台账,这些编号只给成员自己接着分析、下钻用 scope = (AnalysisScope(node_id=ctx.node.id, snapshot=run_snapshot(ctx.run)) if any(is_analysis_tool(t) for t in cfg.get("tools", []) or []) else None) async with SessionLocal() as session: model, model_id = await get_chat_model( session, ModelSpec( provider=cfg.get("provider") or ctx.cfg("provider"), model=cfg.get("model") or ctx.cfg("model"), max_tokens=int(cfg.get("max_tokens") or 4096), ), ) tool_ctx = ToolContext( run_id=ctx.run.run_id, node_id=f"{ctx.node.id}:{name}", sandbox_session=ctx.run.thread_id, memory_scope=ctx.run.memory_scope, collection=ctx.run.collection, data_versions=run_versions(ctx.run), mask_results=run_masks_results(ctx.run), force_readonly=run_reads_only(ctx.run), fixed_scope=run_scope_fixed(ctx.run), # 读哪一份数据目录:发起时定下的(data/catalog_view.py),审批恢复、续跑都沿用 catalog_view=catalog_view.of_run(ctx.run), # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late) emit=ctx.emit, extra={ANALYSIS_SCOPE_KEY: scope} if scope is not None else {}, ) tools = await build_tools(cfg.get("tools", []) or [], tool_ctx, session=session) bound = bind_tools_safely(model, tools, parallel=False) tool_map = {t.name: t for t in tools} # 绑定的工具带的方法说明(分析方法)接在成员的 system 后面,同一份只注入一次(同 Agent 节点的 _build_messages) guide = guides_text(tools) system = cfg.get("system") or f"你是 {name}。" messages: list[Any] = [ SystemMessage(content=f"{system}\n\n{guide}" if guide else system), HumanMessage(content=f"团队目标:{goal}\n\n已有进展:\n{progress or '(无)'}\n\n你的任务:{instruction}"), ] calls: list[dict[str, Any]] = [] # 和 agent 节点同一个硬顶:成员上配多少步都过不去 max_agent_steps max_steps = min(int(cfg.get("max_steps") or 4), settings.max_agent_steps) nudged = False for _ in range(max_steps): t0 = time.perf_counter() reply = await bound.ainvoke(messages) spent = _usage(reply, model_id) _accumulate(spent, tally) _report(name, model_id, spent, t0) messages.append(reply) tool_calls = getattr(reply, "tool_calls", None) or [] if not tool_calls: text = message_text(reply) snippet = leaked_markup(text) if not snippet: return {"text": text, "tool_calls": calls} # 把工具调用写成了文字:这一步什么都没查到。纠正一次,还这样就记为失败, # 原因交给调度者——以前这段标记会被当成这个成员的结论交上去 if nudged: return {"text": f"({name} 这一步执行失败:{MEMBER_MARKUP_FAILED})", "tool_calls": calls, "failed": True, "error": MEMBER_MARKUP_FAILED} nudged = True ctx.emit(EventType.LOG, level="warn", code="tool_markup_leak", message=f"{markup_warning(snippet, name)},已要求模型重试一次") messages.append(HumanMessage(content=TOOL_MARKUP_NUDGE)) continue for call in tool_calls: tname = call.get("name", "") targs = call.get("args", {}) or {} cid = call.get("id") or f"{name}-{tname}" limit = None failure: dict[str, Any] = {} started = time.perf_counter() if tname not in tool_map: ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid) content = f"错误:没有名为 {tname} 的工具" else: schema = args_model_of(tool_map[tname]) fix_note = None args_error = None if schema is not None: try: targs, fix_note = prepare_args(schema, targs) except ToolArgsError as e: args_error = str(e) # 要不要人工确认、要不要先问门控:按纠正后的参数判 policy = "safe" gate_reason = "" if not args_error and approval != "never": policy = call_policy(tool_map[tname], tname, targs, ctx.run.tool_trust) if policy == "gate": gate = await ask_gate( tool=tool_map[tname], args=targs, node_title=f"{ctx.node.title} · {name}", task=f"团队目标:{goal}\n\n{name} 的任务:{instruction}") ctx.emit(EventType.TOOL_GATED, tool=tname, agent=name, call_id=cid, **gate.event()) _accumulate({"input_tokens": gate.usage.get("input_tokens", 0), "output_tokens": gate.usage.get("output_tokens", 0), "cost_usd": gate.usage.get("cost_usd", 0.0), "calls": 1}, tally) policy = "safe" if gate.allowed else "ask" gate_reason = gate.reason # 纠正后的参数才是真正要执行的那份,tool.start 要发它 limit = limit_of(tool_map[tname], tname, targs) ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid, **limit_fields(limit)) if fix_note: ctx.emit(EventType.LOG, level="warn", message=f"工具 {tname}:{fix_note}", code="tool_args_fixed") if args_error: # 参数就不对,没必要真调一次。把"它接受什么"喂回去让它改 content = args_error elif policy == "ask": # 专家们并行跑在同一个节点里,停不下来等人:审批恢复时节点整个 # 重放,几个人的 interrupt 谁先谁后对不上号。以前的做法是不审, # shell_exec / file_write / 可写库上的 DELETE 照跑不误—— # agent 节点守着的那道门,在这里是敞开的。现在是不跑,并说清原因 content = ( (f"门控模型未批准({gate_reason})。" if gate_reason else "") + f"没有执行:{tname} 这次调用需要人工审批,而协作团队里的成员" "不能停下来等人。换一种不需要它的做法;实在需要,交给团队外" "的 Agent 节点去做(那里可以逐次审批)。" ) ctx.emit(EventType.LOG, level="warn", code="tool_needs_approval", tool=tname, agent=name, call_id=cid, message=f"{name} 请求调用 {tname},该调用需要人工审批,协作节点内不执行") else: try: raw = await run_bounded(tool_map[tname].ainvoke(targs), limit, tname) content = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False, default=str) except ToolTimeout as e: content = str(e) failure = {"error": content, "timed_out": True} except CallError as e: # 分析调用写错了:不记结论,原话交回成员照着改(同 Agent 节点) content = call_error_text(e) failure = {"error": content[:300], "call_error": e.code} except Exception as e: # noqa: BLE001 content = f"工具失败:{describe_exception(e)}" failure = {"error": content, "detail": raw_detail(e)} # 查库的 SQL 检查结果单独带上:preview 截到前 1500 个字符,checks 通常在截掉的那段里 checks = event_checks(content) if not failure and tname.startswith(QUERY_PREFIX) else [] analysis = payload_of(content) if scope is not None and not failure and is_analysis_tool(tname) else None ctx.emit(EventType.TOOL_ERROR if failure else EventType.TOOL_END, tool=tname, agent=name, call_id=cid, preview=content[:1500], duration_ms=int((time.perf_counter() - started) * 1000), **failure, **({"checks": checks} if checks else {}), # 判定的那一小段同 Agent 节点(event_view)。不带编号:成员自己的分析容器里的 A1 不是报告目录里的 # A1(协作成员的结论还进不了台账),时间线上照它写「· A1」会和报告里点名的结论对不上 **({"analysis_artifact": analysis["artifact"], "analysis": event_view(analysis["result"])} if analysis else {})) if analysis is not None and analysis["result"].get("status") == "refused": ctx.emit(EventType.LOG, level="warn", code=ANALYSIS_REFUSED, agent=name, call_id=cid, tool=tname, reason_code=analysis["result"].get("reason_code"), reason=analysis["result"].get("reason"), message=f"{name} 的 {tname} 没有做成:{analysis['result'].get('reason') or '原因未知'}") calls.append({"tool": tname, "args": targs, "result": content[:2000]}) if scope is not None and not failure and tname in tool_map: content = await _numbered(scope, tname, content, analysis, ctx.run.run_id, ctx.node.id) messages.append(ToolMessage(content=content, tool_call_id=cid)) # 步数用完了:最后一步一定是要了工具的,模型从没拿到过"说结论"的那一轮。 # messages[-1] 是一条 ToolMessage——以前它就被当成这个成员的产出交给调度者, # 工具的原始返回冒充了结论。agent 节点修过同一个问题,这里照做:不给工具 # (用没绑工具的 model,结构上就发不出调用)补一轮收尾。 messages.append(HumanMessage(content=( f"步数预算用完了({max_steps} 步),现在起不能再调用任何工具。基于已经查到的" "信息给出你这部分的结论,说清哪些没查到、结论因此有什么局限。不要编造数据。" ))) try: t0 = time.perf_counter() settled = await model.ainvoke(messages) spent = _usage(settled, model_id) _accumulate(spent, tally) _report(name, model_id, spent, t0) text = message_text(settled).strip() except Exception as e: # noqa: BLE001 - 收尾失败不能把已有的过程一起赔进去 ctx.emit(EventType.LOG, level="warn", code="settle_failed", message=f"{name} 收尾失败:{describe_exception(e)}") text = next((t for m in reversed(messages) if isinstance(m, AIMessage) and (t := message_text(m).strip()) and not leaked_markup(t)), "") if snippet := leaked_markup(text): if not calls: return {"text": f"({name} 这一步执行失败:{MEMBER_MARKUP_FAILED})", "tool_calls": calls, "failed": True, "error": MEMBER_MARKUP_FAILED} # 真调过工具,只是收尾轮还想接着查:取它之前说过的话,和 agent 节点一样 ctx.emit(EventType.LOG, level="warn", code="tool_markup_leak", message=f"{markup_warning(snippet, name)}:已达步数上限,收尾时仍试图调用工具") text = next((t for m in reversed(messages) if isinstance(m, AIMessage) and (t := message_text(m).strip()) and not leaked_markup(t)), "") ctx.emit(EventType.LOG, level="warn", code="step_limit_settled", message=f"{name} 已用完 {max_steps} 步上限,结论基于已查到的部分") return {"text": text or f"({name} 已用完 {max_steps} 步上限,未给出结论)", "tool_calls": calls} progress_lines: list[str] = [] final_text = "" #: 成功交回来的产出(成员, 原话)。调度者说完成时,成果取最后一份成功的: #: 失败说明是写给调度者看的,不是结论 delivered: list[tuple[str, str]] = [] rounds_meta: list[dict[str, Any]] = [] #: 调度者最后一轮给的理由,以及派过活的成员。轮数用完时报错要说清卡在哪、谁从没上过场 last_reason = "" dispatched: set[str] = set() finished = False for round_no in range(max_rounds): progress = "\n\n".join(progress_lines) routed = await route(round_no, progress) decision = routed.get("decision", routed) if isinstance(routed, dict) else {} decision = decision if isinstance(decision, dict) else {} _accumulate(routed.get("usage") or _usage(None, sup_model_id)) reason = str(decision.get("reason", "")) last_reason = reason or last_reason batch = _batch_of(decision) if bool(decision.get("done")) or not batch: finished = True break dispatched.update(n for n, _ in batch) # 并发执行。它们拿到的是**同一份** progress 快照——这正是"任务必须 # 互不依赖"的技术含义,也是上面提示词里那几条反例的由来。 # # 每个人单独计时,而不是事后拿整轮墙钟去摊:界面要显示"并行省了多少 # 时间",那个数必须是量出来的。按 N×墙钟 推算等于假设每个人都跑满了 # 最慢那条,而实际上快的那个可能只花了三分之一。 # # 结束事件也在各自的 task 里一完成就发,不等整轮 gather:以前先做完的成员要 # 一直显示"进行中",直到最慢的那个做完——矩阵答不了"谁还在跑" async def _one(name: str, instruction: str, parallel: int) -> dict[str, Any]: return await work(round_no, name, instruction, progress, parallel) started = time.perf_counter() results = await asyncio.gather(*(_one(n, i, len(batch)) for n, i in batch)) wall_ms = int((time.perf_counter() - started) * 1000) # 进展仍按派活顺序拼:它是下一轮调度者读的输入,语义不随谁先回来而变 sum_ms = 0 for (name, instruction), result in zip(batch, results): outcome = dict(result) spent = outcome.pop("usage", None) if spent: _accumulate(spent) each_ms = int(outcome.pop("duration_ms", 0) or 0) sum_ms += each_ms progress_lines.append(f"【{name}】{outcome['text']}") transcript.append( {"round": round_no, "agent": name, "instruction": instruction, **outcome, "duration_ms": each_ms} ) final_text = outcome["text"] if not outcome.get("failed"): delivered.append((name, outcome["text"])) rounds_meta.append({ "round": round_no, "agents": [n for n, _ in batch], "parallel": len(batch), "wall_ms": wall_ms, "sum_ms": sum_ms, }) if not finished and progress_lines: judged = await verdict(max_rounds, "\n\n".join(progress_lines)) _accumulate(judged.get("usage") or _usage(None, sup_model_id)) last_reason = str(judged.get("reason") or "") or last_reason if judged.get("done"): finished = True exhausted: dict[str, Any] = {} failed = [t for t in transcript if t.get("failed")] never = [n for n in names if n not in dispatched] if finished and delivered: final_text = delivered[-1][1] if transcript[-1].get("failed"): # 调度者认可了收工,可最后派出去的人没交回东西:成果取最后一份成功的产出, # 并说清楚——下游拿到的是它,不是本该定稿的那个人的 late = "、".join(dict.fromkeys(t["agent"] for t in failed if t["round"] == transcript[-1]["round"])) ctx.emit(EventType.LOG, level="warn", code="team_last_failed", message=f"{late} 最后一轮未交回结果,团队成果取自{delivered[-1][0]}的产出") elif finished and transcript: # 调度者说完成了,派出去的成员却一个都没交回结果,手上只有失败说明。以前最后 # 那段失败说明就被当成团队的结论交出去:一条数据都没查到,运行照样是已完成。 # 不看 on_exhausted:那是「用完轮数时」的收场方式,降档交的是成员原话;这里 # 连原话都没有,交下去的只能是失败说明,界面还会把它说成「用完 N 轮」 why = ";".join(f"{who}:{err[:200]}" for who, err in dict.fromkeys( (t["agent"], t.get("error") or "未说明原因") for t in failed)) summary = (f"协作团队未交出结论:所有被分派的成员都未能交回结果,调度者仍判定完成" f"({last_reason or '未给出理由'})。{why}") if never: summary += f"。一次都没被派到的成员:{'、'.join(never)}" raise NodeError(ctx.node.id, f"{summary}。请根据各成员的失败原因修改配置后重新运行") elif not finished: # 轮数用完,调度者一次都没说「完成」。这时手上只有最后一个成员的原话—— # 以前它就被当作团队的结论交了出去:真实运行里是一段没执行的工具调用标记, # 而负责定稿的成员一次都没被派到 summary = (f"协作团队用完 {max_rounds} 轮仍未完成:" f"{last_reason or '调度者未给出理由'}") if never: summary += f"。一次都没被派到的成员:{'、'.join(never)}" if on_exhausted == "fail": # 「。先看成员」是前端切分这句话的锚点(decode.ts exhaustedOf、NodeCard、explain.ts),不要改 raise NodeError(ctx.node.id, f"{summary}。先看成员是否绑定了所需工具,再调大「最多" "轮数」;也可将「用完轮数时」改为「降档交付」") exhausted = {"exhausted": True, "exhausted_reason": last_reason, "never_dispatched": never} ctx.emit(EventType.LOG, level="warn", code="team_exhausted", message=f"{summary}。按降档交付:成果取自成员最后的回复,并非调度者认可的结论") usage_total["total_tokens"] = usage_total["input_tokens"] + usage_total["output_tokens"] # 并行省下的时间:各轮"串行本该花的"减去"实际花的"。只有并发过才有差值 saved_ms = sum(r["sum_ms"] - r["wall_ms"] for r in rounds_meta) result = { "text": final_text, "rounds": len(rounds_meta), "steps": len(transcript), "transcript": transcript, "agents": names, "schedule": rounds_meta, "parallel_saved_ms": saved_ms, **exhausted, } updates: dict[str, Any] = { "nodes": {ctx.node.id: result}, "usage": usage_total, } if ctx.cfg("emit_message", True): updates["messages"] = [AIMessage(content=final_text or "(空)")] var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: final_text} return updates async def _numbered(scope: AnalysisScope, tool: str, content: str, analysis: dict[str, Any] | None, run_id: str, node_id: str) -> str: """绑了分析工具的协作成员:一次成功的工具调用交回成员的内容。数据源查询(平台执行的,端点读取另核工件是这次运行落下 的)编号「【证据 Qn】」并放进分析容器;分析结论编号「【分析 An】」、换成给模型看的那一段。别的工具原样。""" from app.capabilities.registry import is_platform_query, snapshot_owned from app.engine.evidence import query_entry_fields if analysis is not None: return reply_text(scope.add_result(analysis["result"], analysis["artifact"]), analysis["result"]) if not is_platform_query(tool): return content fields = query_entry_fields(content) if fields is None or not await snapshot_owned(fields["artifact"], run_id, tool): return content alias = f"Q{len(scope.queries) + 1}" scope.add_query(alias, input_snapshot(json.loads(content), ref=alias, artifact=fields["artifact"], node_id=node_id)) return f"【证据 {alias}】\n{content}" # -------------------------------------------------------------------------- # 子图:把另一张工作流当成一个节点 # -------------------------------------------------------------------------- async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: """嵌套执行另一个工作流。 子图共享同一个 run 的事件流:节点 id 加前缀「子工作流节点 id/」(嵌套的一层层接上),事件经父运行交下来的写入口发 (engine/context.STREAM_KEY;以前只设了前缀没用上,子图里每一步在运行的事件里一条都没有),时间线上看得到它内部每一步, 封存也就覆盖了它查过的数。它有独立的状态,不会污染父图的变量池。 证据交回父图(docs/design/orchestration-round/00-总方案.md §2),三样: - 台账:子图记下的条目(查询快照、口径卡的指标集……)节点 id 加上前缀并进父图的 evidence,父图报告撰写的目录据此收进 子图里的查询和口径卡(report.report_catalog),cell(nodes.<子图>.output.<原样交出的查询结果>, …) 也按台账认得出快照; - 口径卡:子图里口径卡的产出以「子工作流节点 id/卡」为 id 进父图的 nodes(目录要卡上的指标值;父图转抄它的指标时认得 出那张卡、带上不完整和存疑); - 出处:出口的每个字段按出口模板认它来自哪里(_output_provenance),记在产出的 provenance 上;父图里读 nodes.<子图>.output.<字段> 的口径卡据此接上子图里的那一格(metrics._subgraph_field),engine/lineage 同一套认法。 """ from app.engine.compiler import compile_graph # 延迟导入,避免循环依赖 from app.engine.schema import GraphSpec, loop_steps if ctx.run.depth >= _MAX_DEPTH: raise NodeError(ctx.node.id, f"子工作流嵌套超过 {_MAX_DEPTH} 层,已阻止。" "可能是多个工作流相互引用,请检查「子工作流」节点选择的工作流") workflow_id = ctx.cfg("workflow_id") if not workflow_id: raise NodeError(ctx.node.id, "「子工作流」节点尚未选择要嵌套的工作流") # 版本钉死:这就是"方法卡"的机制核心。钉了 workflow_version 就永远 # 执行那个不可变快照,上游改了方法卡也不会让这里的口径悄悄漂移; # 没钉则跟随最新(画布试跑方便,但治理 lint 会在受管模板里拦下它)。 pinned = ctx.cfg("workflow_version") async with SessionLocal() as session: workflow = await session.get(Workflow, workflow_id) if not workflow: raise NodeError(ctx.node.id, f"「子工作流」引用的工作流({workflow_id})已不存在," "可能已被删除,请在节点中重新选择") if pinned: from sqlalchemy import select from app.db.models import WorkflowVersion snapshot = ( await session.execute( select(WorkflowVersion).where( WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.version == int(pinned), ) ) ).scalar_one_or_none() if not snapshot: raise NodeError( ctx.node.id, f"工作流「{workflow.name}」不存在 v{pinned} 版本" ) sub_graph = snapshot.graph used_version = int(pinned) else: sub_graph = workflow.graph used_version = workflow.version sub_input = ctx.render(ctx.cfg("input", {}) or {}, state) if not isinstance(sub_input, dict): sub_input = {"input": sub_input} sub_spec = GraphSpec.model_validate(sub_graph) sub_run = type(ctx.run)( run_id=ctx.run.run_id, thread_id=f"{ctx.run.thread_id}:{ctx.node.id}", spec=sub_spec, workflow_id=workflow.id, depth=ctx.run.depth + 1, memory_scope=ctx.run.memory_scope, collection=ctx.run.collection, approval_default=ctx.run.approval_default, tool_trust=ctx.run.tool_trust, agent_limits=ctx.run.agent_limits, # 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)。前缀和写入口见 engine/context: # 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀 extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")}, PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", LABEL_PREFIX_KEY: f"{ctx.run.global_label(ctx.node.title)} › ", STREAM_KEY: stream_writer(ctx.run)}, ) # 这个子工作流节点之前几次执行里,子图各节点跑过的记录(交回父图 trail 的,见下):子图从它们接着数第几次执行 # (engine/evidence.next_exec)。以前每次都从空的 trail 起步,循环里第二轮的子图节点又记成第 1 次:台账分不出是哪一次, # 写外部系统的幂等键(按第几次执行算)和第一轮撞上,第二轮的写入交回第一轮的回执、不发送 prefix = f"{ctx.node.id}/" seed = [{**t, "node_id": str(t.get("node_id"))[len(prefix):]} for t in state.get("trail") or [] if isinstance(t, dict) and str(t.get("node_id") or "").startswith(prefix)] ctx.emit(EventType.LOG, level="info", message=f"进入子工作流「{workflow.name}」({len(sub_spec.nodes)} 个节点)") compiled = compile_graph(sub_spec, sub_run) # 子图不带 checkpointer:它的断点由父图的 checkpoint 统一承载 app = compiled.compile() limit = settings.max_graph_steps + loop_steps(sub_spec) try: result_state = await app.ainvoke( { "input": sub_input, "vars": sub_input, "nodes": {}, "output": {}, "messages": [], "usage": {}, "trail": seed, "loops": {}, }, {"recursion_limit": limit}, ) except GraphRecursionError as e: # 不接住的话,外层包装成 NodeError 时带的是 LangGraph 的英文原文 raise NodeError( ctx.node.id, f"子工作流「{workflow.name}」已执行 {limit} 步仍未结束,已中止。可能存在没有轮数上限的环路," "请在子工作流中用「循环」节点限制轮数", ) from e except NodeError as e: # 子图里的节点报的错(含被驳回):节点 id 换成这一层图里的 id(「子工作流节点 id/…」),名字接上子工作流的名字。 # 以前带的是子图里的 id,运行记下的出错节点会和父图里同名的节点混在一起 titles = sub_spec.node_map() inner = getattr(e, "label", None) or (titles[e.node_id].title if e.node_id in titles else e.node_id) e.node_id, e.label = f"{ctx.node.id}/{e.node_id}", f"{ctx.node.title} › {inner}" raise output = result_state.get("output") or {} # 这一次执行新跑的那几步(trail 只追加:前面是交进去的 seed) ran = [t for t in (result_state.get("trail") or [])[len(seed):] if isinstance(t, dict)] result_state = {**result_state, "trail": ran} result = { "output": output, "workflow": workflow.name, # 溯源信息:这次到底跑的是哪个版本、是不是钉死的 "workflow_version": used_version, "pinned": bool(pinned), "text": json.dumps(output, ensure_ascii=False) if output else "", } if provenance := _output_provenance(sub_spec, sub_run, result_state, output, state, ctx): # 出口每个字段的出处(没有能认的字段时不带这个键,产出和以前一字不差) result["provenance"] = provenance ctx.emit(EventType.LOG, level="info", message=f"子工作流「{workflow.name}」完成") sub_nodes = result_state.get("nodes") or {} cards = {f"{ctx.node.id}/{nid}": payload for nid, payload in sub_nodes.items() if isinstance(payload, dict) and payload.get("kind") == "metric_set"} updates: dict[str, Any] = { "nodes": {ctx.node.id: result, **cards}, "usage": result_state.get("usage") or {}, } if ledger := [_forwarded(e, ctx.node.id) for e in result_state.get("evidence") or [] if isinstance(e, dict)]: updates["evidence"] = ledger if ran: # 子图这一次跑过的每一步交回父图的 trail,节点 id 接上前缀(「子工作流节点 id/…」):同一个子工作流节点下一次执行时 # 从这里接着数(上面的 seed);走了驳回出口的那几步(outcome=rejected)运行收尾据此在终态事件上照实写「途经驳回」 # (runner._rejected_via),实例页、通知、定时任务的 Webhook 说得出「子工作流 › 把关」被驳回、转到哪(名字、转到哪几步 # 在子图里已经按这次运行的说法记好:global_label、global_id)。父图自己的节点按自己的 id 数,不受影响 updates["trail"] = _forwarded_trail(ran, prefix) var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: output} return updates #: 台账条目里写着节点 id 的键:交回父图时一并加前缀(双盲复算的条目记着它复算的口径卡) _LEDGER_NODE_KEYS = ("node_id", "card") def _forwarded_trail(ran: list[dict[str, Any]], prefix: str) -> list[dict[str, Any]]: """子图这一次跑过的步骤交回父图 trail 的样子:节点 id 接上「子工作流节点 id/」前缀。""" return [{**t, "node_id": f"{prefix}{t.get('node_id')}"} for t in ran] def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]: """子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。""" out = dict(entry) for key in _LEDGER_NODE_KEYS: if isinstance(out.get(key), str) and out[key]: out[key] = f"{node_id}/{out[key]}" return out def _output_provenance(sub_spec: Any, sub_run: Any, sub_state: dict[str, Any], output: dict[str, Any], state: GraphState, ctx: NodeContext) -> dict[str, dict[str, Any]]: """子图出口每个字段的出处:{字段: 口径卡上一个输入的样子(via、node_id、artifact、locator、status、upstream……)}。 认法和口径卡认它的输入同一个(metrics._input_of),在子图的状态里认:字段的模板整串就是一个取值链(whole_ref),而且 渲染出来的值正是那个值转成的文字,才算这个字段就是那个值;拼了别的字、过了过滤器的不认(不在结果里)。几个成果节点 写同一个字段时按最后跑的那个。认出来的节点 id 换成父图这一层的(加「子工作流节点 id/」前缀)。 出自子图运行输入的(via input)再往父图追一层:子图的输入是父图按子工作流节点的「输入」模板渲染出来的,那个模板整串 也是一个取值链时,按父图的状态接着认(追到父图的运行输入、查询格……);否则记 via subgraph_input,停在子工作流的 入口——子图的运行输入对父图不是原始数据。""" from app.engine.expressions import _stringify from app.engine.nodes.metrics import _input_of, _value_of from app.engine.schema import NodeType outputs = {n.id: n for n in sub_spec.nodes if n.type == NodeType.OUTPUT} fields: dict[str, tuple[Any, str]] = {} for step in sub_state.get("trail") or []: exit_node = outputs.get(step.get("node_id")) if "error" not in step else None for field in (exit_node.config.get("fields") or []) if exit_node is not None else []: if isinstance(field, dict) and field.get("name"): fields[str(field["name"])] = (exit_node, field.get("value", "")) tctx = template_context(sub_state) out: dict[str, dict[str, Any]] = {} for name, (exit_node, template) in fields.items(): path = whole_ref(template) if path is None or name not in output: continue value = _value_of(path, tctx) if _stringify(value) != output[name]: continue item = _input_of(path, value, sub_state, NodeContext(node=exit_node, run=sub_run), tctx) out[name] = _lifted(item, state, ctx) return out def _lifted(item: dict[str, Any], state: GraphState, ctx: NodeContext) -> dict[str, Any]: """子图里认出的一个出处换到父图这一层:节点 id 加前缀;出自子图运行输入的,按子工作流节点的「输入」模板往父图追。""" from app.engine.expressions import _stringify from app.engine.nodes.metrics import _REF_HEAD, _input_of, _value_of if item.get("via") == "input": head = _REF_HEAD.match(str(item.get("path") or "")) name = head.group(2) if head else "" templates = ctx.cfg("input", {}) or {} path = whole_ref(templates.get(name)) if isinstance(templates, dict) and name else None if path is not None: parent = template_context(state) value = _value_of(path, parent) if _stringify(value) == _stringify(item.get("value")): return _input_of(path, value, state, ctx, parent) return {"path": item.get("path"), "value": item.get("value"), "node_id": ctx.node.id, "via": "subgraph_input", **({"field": name} if name else {}), "status": item.get("status") or "ok"} out = dict(item) for key in ("node_id", "through", "subgraph"): if isinstance(out.get(key), str) and out[key]: out[key] = f"{ctx.node.id}/{out[key]}" return out