mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
3998e821dc
调查时发现:run_subgraph 只设了节点 id 前缀(node_prefix)没用上,子图在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给父图的流——子图里每一步在运行的事件里一条都没有,时间线看不到、封存也不含它查过的数。「事件本来就在同一个 运行里」此前并不成立(docs/design/orchestration-round/00-总方案.md §2)。 - engine/context + nodes/multi:子图拿到父运行的写入口(STREAM_KEY)和前缀(PREFIX_KEY),子图里的节点经它发事件,节点 id 「子工作流节点 id/…」,嵌套的一层层接上;工件按这次运行里的 id 记。子图里报的错(含被驳回)交回时节点 id 换成这一层的、 名字接上子工作流(runner 的 run.failed / run.cancelled 写「子工作流 › 那一步」),不再和父图里同名的节点混在一起 - nodes/multi:子图的台账条目加前缀并进父图 evidence(父图报告的目录据此收进子图里的查询和口径卡);子图口径卡的产出以 「子工作流节点 id/卡」进父图 nodes;出口每个字段按出口模板认出处(模板整串就是一个取值链、渲染出来正是那个值的才认, 同口径卡认输入的那一套),出自子图运行输入的按子工作流节点的「输入」模板再往父图追一层,追不到的记 subgraph_input - engine/compiler:子工作流节点自己的 node.finished 不再重记交回来的台账条目(子图那几步自己的 node.finished 已经带着) - nodes/metrics:父图口径卡读 nodes.<子图>.output.<字段>(或 assign_to 变量的字段)时接上那个字段的出处,另记 subgraph、 field;经子图出口转抄子图口径卡的指标照样带上不完整、存疑;cell(nodes.<子图>.output.<原样交出的查询结果>, …) 按台账 认出子图里的那份快照 - engine/lineage:静态认法跨子图往下追(出口模板 → 子图里接着认;子图的运行输入按「输入」模板回父图认;最多 3 层), 和运行时同一套;whole_ref 是两边共用的「整串一个取值链」判断。lint_process.load_subgraphs 往下读嵌套的子工作流, subgraph_spec 给静态认法取图;没读好放进来的照旧停在子工作流节点 - nodes/report:报告收证据的范围加上上游子工作流交回来的(evidence_from 写了子工作流节点的一并算) - nodes/io:指标出具的缺口里,停在子图里的说停在那个子工作流(和静态检查同一句) - api/evidence:卡上记的节点 id 按卡所在那一层认(_scoped:子图里的卡写子图里的 id),转抄、输入、追不到的那几步一路给到 子图里的那一格;子图里的节点按 node.started 记的名字接成「子工作流 › 节点」(node_label、subgraph_label);双盲复算条目 里的卡 id 同样换成这次运行里的 - apps/resolve:实例进度不单列子图里的步骤 - 口径账本的 use_caliber 不改;比对读封存的查询,子图里的查询如今在封存范围里,照常认得出 测试:test_subgraph_evidence(子图每一步进运行、报错点名子图里那一步、实例进度、父图口径卡跨子图追到查询格并完整出具、 子图里追不到的父图降档、不是整串取值链的停在子工作流、运行输入往父图追、嵌套两层、静态检查与运行时一致(含两层和输入 模板)、父图报告引用子图里的查询和口径卡并一路点到那一格、封存与可验证报告、子图里的遮罩照父图的运行)。去掉「交写入口」 回跑,五条失败(快照不在封存范围里)。后端全量 14472 通过
833 lines
46 KiB
Python
833 lines
46 KiB
Python
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.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 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.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,
|
||
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]
|
||
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,
|
||
)
|
||
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}
|
||
messages: list[Any] = [
|
||
SystemMessage(content=cfg.get("system") or f"你是 {name}。"),
|
||
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 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 []
|
||
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 {}))
|
||
calls.append({"tool": tname, "args": targs, "result": content[:2000]})
|
||
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 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)}/", STREAM_KEY: stream_writer(ctx.run)},
|
||
)
|
||
|
||
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": [],
|
||
"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 {}
|
||
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
|
||
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(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
|