Files
AgentLab/backend/app/engine/nodes/multi.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

1019 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.evidence import ledger_enabled, next_exec
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),
),
)
# K10:这次运行的类别、发起人与来源照运行记录来——成员绑的 caliber_query 和调用工具节点、Agent、别条路
# 上拿到同一个答案(formal 父 Run 走 formal judge、用发起人当主体、把固定的数据版本与选项带进派生的子运行)
from app.engine.runner import RUN_OPTIONS_KEY
from app.tools.registry import RUN_CLASS_KEY, RUN_ORIGIN_KEY, RUN_STARTED_BY_KEY
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,
run_class=ctx.run.extra.get(RUN_CLASS_KEY),
started_by=ctx.run.extra.get(RUN_STARTED_BY_KEY),
run_options=dict(ctx.run.extra.get(RUN_OPTIONS_KEY) or {}),
origin=ctx.run.extra.get(RUN_ORIGIN_KEY),
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]] = []
# K10 收尾:成员这一次查到的数进不进运行封存证据(子工作流转发那一套的同一个位置:node.finished 的 evidence)。
# 升级前发起的运行一个字都不加(ledger_enabled,同 tools/llm 的 evidence_on);模版拼出来的普通工具调用
# (calculator 这类)不是平台执行的查询,entry 一个都不产生,产出与以前一字不差
evidence_on = ledger_enabled(ctx.run)
entries: list[dict[str, Any]] = []
exec_no = next_exec(state, ctx.node.id) if evidence_on else 0
# 和 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, **_member_evidence(entries)}
# 把工具调用写成了文字:这一步什么都没查到。纠正一次,还这样就记为失败,
# 原因交给调度者——以前这段标记会被当成这个成员的结论交上去
if nudged:
return {"text": f"({name} 这一步执行失败:{MEMBER_MARKUP_FAILED})",
"tool_calls": calls, "failed": True, "error": MEMBER_MARKUP_FAILED,
**_member_evidence(entries)}
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 '原因未知'}")
record = {"tool": tname, "args": targs, "result": content[:2000]}
if not failure and tname in tool_map and (scope is not None or evidence_on):
content = await _numbered(scope, tname, content, analysis, ctx.run.run_id, ctx.node.id,
call_id=cid, member=name, exec_no=exec_no,
record=record if evidence_on else None,
ledger=entries if evidence_on else None)
calls.append(record)
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,
**_member_evidence(entries)}
# 真调过工具,只是收尾轮还想接着查:取它之前说过的话,和 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,
**_member_evidence(entries)}
progress_lines: list[str] = []
final_text = ""
#: 成功交回来的产出(成员, 原话)。调度者说完成时,成果取最后一份成功的:
#: 失败说明是写给调度者看的,不是结论
delivered: list[tuple[str, str]] = []
rounds_meta: list[dict[str, Any]] = []
#: 成员实际查到的数:这一次执行里各成员交回的 query 条目(K10 收尾)。随这个节点的 node.finished 进运行封存证据,
#: 让证据面板、导出包、运行对比、双盲复算读得到成员查过什么。从**缓存下来的成员产出**里取(work 是 task):重放
#: 拿到的还是同一批条目,不会因为重跑一次多记或少记。普通成员(没查过库)一条都没有,产出与以前一字不差
ledger: 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)
# 成员的查询条目交给这个节点去记(不进 transcript:产出里那一条已经带着编号与执行身份)
found = outcome.pop("evidence", None)
if isinstance(found, list):
ledger += [e for e in found if isinstance(e, dict)]
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 ledger:
# 成员查到的数进运行封存证据:node.finished 的 evidence(这一份进封存范围,封存读取、导出包的来源与遮罩判断、
# 运行对比、双盲复算都从这里认),条目带成员的节点前缀(「协作节点 id:成员名」,和成员查库时记在工件上的那个
# 节点一致)。不带「协作节点 id/成员名」那种前缀:那是子工作流转发的写法,_own_evidence 会把它当成子图里节点
# 自己记过的、从这个节点的事件里滤掉(engine/compiler.py),这些条目就没进封存;而 _own_evidence 放行的又必须
# 是祖先节点或「祖先/…」,所以「节点:成员」既进封存、又不进报告目录的 Q 编号(见 _numbered 的说明)
updates["evidence"] = ledger
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
def _member_evidence(entries: list[dict[str, Any]]) -> dict[str, Any]:
"""成员这一次执行记下的查询条目(没有就一个键都不加:普通成员的产出与以前一字不差)。"""
return {"evidence": list(entries)} if entries else {}
async def _platform_queries(content: str, tool: str, run_id: str) -> list[dict[str, Any]]:
"""这次工具调用交回、**可以作证**的查询条目:按身份摊开(capabilities.registry.query_items——数据源查询一条、
口径查询是 queries 数组里**多条**),逐条认工件(snapshot_owned:数据源查询的快照由数据层自己落,照旧放行;口径
查询那条必须是这次运行或它明确派生的子运行落下的查询快照)。不是平台执行的查询返回空——拼一份同样形状的 JSON
不算证据;伪造的、别的运行落的、别的种类的一律不要。抽取与认工件和 Agent 节点、调用工具节点是同一份。"""
from app.capabilities.registry import is_platform_query, query_items, snapshot_owned
if not is_platform_query(tool):
return []
out: list[dict[str, Any]] = []
for item in query_items(content, tool):
artifact = str(item.get("artifact") or "")
if artifact and await snapshot_owned(artifact, run_id, tool):
out.append(item)
return out
async def _numbered(scope: AnalysisScope | None, tool: str, content: str, analysis: dict[str, Any] | None,
run_id: str, node_id: str, *, call_id: str = "", member: str = "", exec_no: int = 0,
record: dict[str, Any] | None = None, ledger: list[dict[str, Any]] | None = None) -> str:
"""一次成功的工具调用交回成员的内容。平台执行的查询(数据源查询、K10 的口径查询 caliber_query、登记里声明了表格的
端点读取)编号「【证据 Qn】」并放进成员自己的分析容器(scope 给了才做——绑了分析工具的成员才有容器,没绑的成员看到的
内容一个字不改);分析结论编号「【分析 An】」、换成给模型看的那一段。别的工具原样。
**一次调用交回多条查询**(口径查询的结果里是 queries 数组)时逐条编号、逐条放进容器:抽取与认工件走和数据源查询、
Agent 节点、调用工具节点**同一份**(_platform_queries),不另写一套按结果长相猜的逻辑,也不因为带的是数组就只认
第一条。伪造的、别的运行落的、不是查询快照的工件一律不进容器、不进台账。
ledger 给了就逐条往它上面记一条运行台账的 query 条目(和 tools/llm 记的那一种同形:工件、来源、列与行数、截断、
query_id/execution_id/attempt,口径查询那条另带 origin/parameters,另带 member=哪个成员),由调用方作为这个节点的
evidence 交出去、随 node.finished 进封存。条目上的节点 id 是「协作节点 id:成员名」——带成员归属,又不会被
engine/compiler 的 _own_evidence 当成子工作流转发的条目滤掉(那种前缀是「协作节点 id/成员名」,滤掉就进不了封存)。
封存读取(engine/evidence_reader 的 queries / query_records / entries)、导出包的来源与遮罩判断、运行对比、双盲复算
的原始编号都从这里认;报告目录按祖先收证据(report.report_catalog 的 allowed)不收它,所以报告里的全局 Q 编号与
面板按报告引用的那一份一个字不变,成员查到的数是**附加**在封存证据上的(证据面板按报告引用列的那份仍只有报告引过的)。
record 是这次调用的调用记录(进团队产出、随运行封存):编号只活在成员自己的容器里,记下 alias / query_id /
execution_id / attempt 才看得出这个 Qn 是哪一次执行的哪条查询。执行身份同 tools/registry.execution_id_of。"""
from app.tools.registry import execution_id_of
if analysis is not None:
return reply_text(scope.add_result(analysis["result"], analysis["artifact"]), analysis["result"])
items = await _platform_queries(content, tool, run_id)
if not items:
return content
data = json.loads(content)
aliases: list[str] = []
traced: list[dict[str, Any]] = []
owner = f"{node_id}:{member}" if member else node_id
for item in items:
artifact = str(item["artifact"])
alias = ""
if scope is not None:
alias = f"Q{len(scope.queries) + 1}"
aliases.append(alias)
scope.add_query(alias, input_snapshot(data if isinstance(data, dict) else {}, ref=alias,
artifact=artifact, node_id=node_id))
# 实际执行的执行身份(K10):query_id 是逻辑查询、execution_id 是这一次、attempt 是第几次。口径查询那条自己
# 带着(K09 算好的),别的按这次调用的 id 算
identity = {"query_id": item.get("query_id") or node_id,
"execution_id": item.get("execution_id") or execution_id_of(node_id, attempt=exec_no,
call_id=call_id, run_id=run_id),
"attempt": item.get("attempt") or exec_no or 1}
traced.append({"artifact": artifact, **({"alias": alias} if alias else {}), **identity})
if ledger is not None:
# 运行台账的 query 条目:和 tools/llm 记的那一种同形(工件、来源、列与行数、截断、执行身份),
# 口径查询那条另带 origin(哪一版、哪张图、哪个取数节点)与 parameters(输入/模板哈希,值本身不在)
ledger.append({
"kind": "query", "node_id": owner, "exec": exec_no or 1, "artifact": artifact,
"call_id": call_id, "tool": tool, "member": member,
"source": item.get("source"), "columns": [str(c) for c in item.get("columns") or []],
"rows": int(item.get("rows") or 0), "truncated": bool(item.get("truncated")), **identity,
**({"origin": dict(item["origin"])} if isinstance(item.get("origin"), dict) else {}),
**({"parameters": dict(item["parameters"])} if isinstance(item.get("parameters"), dict) else {}),
})
if record is not None:
record.update(artifact=str(items[0]["artifact"]), queries=traced,
**({"alias": traced[0]["alias"]} if traced[0].get("alias") else {}))
return f"【证据 {'、'.join(aliases)}】\n{content}" if aliases else 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