Files
AgentLab/backend/app/engine/nodes/multi.py
T
ThearchyHelios a819f92903 审批只认明确的"同意",而且每个会执行危险操作的地方都得过这道门
三个口子,都实测复现过:

1. 回复"拒绝",工具照样执行。四个节点四套审批解析:工具节点和代码节点对
   回复一律 bool(),"拒绝"、"no" 都是真;{"decision": "approve"} 反被当成
   拒绝;{"approved": "false"} 也是批准。现在收成一份 engine/approval.py:
   只认明确的批准,看不懂的一律按拒绝。填内容的 human input/edit 模式另算——
   裸字符串是交稿,只有结构体里明说"不"才驳回。

2. 可写数据源上的 DELETE 从来不问人。数据源工具是动态生成的,查不到
   ToolSpec,任何审批模式下都被当成安全;is_dangerous_call 写好了但全仓库
   没人调用。现在危险与否逐次判定:registry.call_is_dangerous 统一问,数据源
   工具把判定挂在 metadata["dangerous_if"] 上——同一个 db_query,查询直接
   跑,可写源上的写要先确认。工具节点、agent 节点、协作成员都走它。

   这一步以前没出事,只因为写入从不提交:run_query 走 stream,拿不到结果集就
   抛 ResourceClosedError,连接关闭时再回滚——审批通过的写也没落过库。审批接
   好之后才让写真正提交(先后不能反,反过来就是一个无审批写库的窗口);
   RETURNING 这类带结果集的写照样返回行。工具页的"试一下"是人自己在执行,
   不设审批。

3. 协作团队的成员调工具完全不经审批。成员们并行跑在一个节点里、停不下来等
   人(恢复时节点整个重放,几个人的 interrupt 对不上号),所以需要确认的调用
   不执行,把原因交还给成员让它换做法;节点上显式设成"全部自动放行"才跑。
   前端补了这个选项。顺带修了同一处的两个老问题:成员步数不受 max_agent_steps
   约束;步数用完时把工具的原始返回当结论交上去(agent 节点在 4057cdc 修过
   同一个问题,这里没跟上)——现在补一轮不带工具的收尾。

连带修的:多条审批并存时,交给引擎的是库里存的那份回复,而它存之前被 _safe
截到了 2000 字,解包时又只留 value——人工改过的长稿被砍一截,带了内容的驳回
被当成交稿。现在存全文、只拆自己包的那层 {"value": x}。

test_approval.py 36 例;去掉修复重跑,引擎层 10 例全部失败,各对应一个真问题。
并行驳回那例翻出了一个运行卡死的老 bug,已在上一个提交单独修掉。
338 → 374 passed,连跑三次全绿。
2026-09-25 15:21:20 +08:00

465 lines
22 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.func import task
from app.core.config import settings
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import Workflow
from app.engine.context import NodeContext, NodeError
from app.engine.state import GraphState, message_text, template_context
from app.providers import catalog
from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model
from app.tools.registry import (
ToolArgsError,
ToolContext,
args_model_of,
build_tools,
call_is_dangerous,
prepare_args,
)
_MAX_DEPTH = 3
# --------------------------------------------------------------------------
# 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, "supervisor 节点至少要配一个 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)
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.cfg("approval", "dangerous")
transcript: list[dict[str, Any]] = []
usage_total = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0}
def _accumulate(msg: Any, model_id: str) -> None:
meta = getattr(msg, "usage_metadata", None) or {}
i, o = int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0)
usage_total["input_tokens"] += i
usage_total["output_tokens"] += o
usage_total["calls"] += 1
usage_total["cost_usd"] = round(
usage_total["cost_usd"] + catalog.estimate_cost(model_id, i, o), 6
)
@task
async def route(round_no: int, progress: str) -> dict[str, Any]:
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 给空数组。"
)
try:
value = await supervisor_model.with_structured_output(route_schema).ainvoke(prompt)
if not isinstance(value, dict):
value = json.loads(message_text(value))
except Exception: # noqa: BLE001
# 不支持结构化输出的模型退回文本:从回复里认出一个成员名,按单人派
raw = message_text(await supervisor_model.ainvoke(prompt))
picked = next((n for n in names if n in raw), None)
value = ({"assignments": [{"agent": picked, "instruction": raw[:500]}]}
if picked else {"assignments": [], "done": True, "reason": raw[:200]})
return value
@task
async def work(round_no: int, name: str, instruction: str, progress: str) -> 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,
)
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)
for _ in range(max_steps):
reply = await bound.ainvoke(messages)
_accumulate(reply, model_id)
messages.append(reply)
tool_calls = getattr(reply, "tool_calls", None) or []
if not tool_calls:
return {"text": message_text(reply), "tool_calls": calls}
for call in tool_calls:
tname = call.get("name", "")
targs = call.get("args", {}) or {}
cid = call.get("id") or f"{name}-{tname}"
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)
# 纠正后的参数才是真正要执行的那份,tool.start 要发它
ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid)
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 approval != "never" and call_is_dangerous(tool_map[tname], tname, targs):
# 专家们并行跑在同一个节点里,停不下来等人:审批恢复时节点整个
# 重放,几个人的 interrupt 谁先谁后对不上号。以前的做法是不审,
# shell_exec / file_write / 可写库上的 DELETE 照跑不误——
# agent 节点守着的那道门,在这里是敞开的。现在是不跑,并说清原因
content = (
f"没有执行:{tname} 这次调用需要人工确认,而协作团队里的成员"
"不能停下来等人。换一种不需要它的做法;实在需要,交给团队外"
"的 agent 节点去做(那里可以逐次审批)。"
)
ctx.emit(EventType.LOG, level="warn", code="tool_needs_approval",
message=f"{name} 想调用 {tname},需要人工确认,协作节点里不执行")
else:
try:
raw = await tool_map[tname].ainvoke(targs)
content = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False, default=str)
except Exception as e: # noqa: BLE001
content = f"工具失败:{type(e).__name__}: {e}"
ctx.emit(EventType.TOOL_END, tool=tname, agent=name, call_id=cid, preview=content[:1500])
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:
settled = await model.ainvoke(messages)
_accumulate(settled, model_id)
text = message_text(settled).strip()
except Exception as e: # noqa: BLE001 - 收尾失败不能把已有的过程一起赔进去
ctx.emit(EventType.LOG, level="warn", code="settle_failed",
message=f"{name} 的收尾轮没跑成:{type(e).__name__}: {e}")
text = next((t for m in reversed(messages)
if isinstance(m, AIMessage) and (t := message_text(m).strip())), "")
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 = ""
rounds_meta: list[dict[str, Any]] = []
for round_no in range(max_rounds):
progress = "\n\n".join(progress_lines)
decision = await route(round_no, progress)
_accumulate(AIMessage(content=""), sup_model_id) # route 用了一次调用
reason = str(decision.get("reason", ""))
# 只留认得出的成员,并按上限截断。模型偶尔会重复派同一个人或者编一个
# 不存在的名字——这两种在并发里都是纯浪费
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
if decision.get("done") or not batch:
# agents / done 是结构化字段,界面照着它渲染。以前只有一句中文
# 消息串,解码层得拿正则去拆「调度 → X(理由)」——文案一改就散架
ctx.emit(EventType.LOG, level="info", round=round_no,
agents=[], done=True,
message="调度 → 结束协作" + (f"({reason})" if reason else ""))
final_text = progress_lines[-1].split("】", 1)[-1] if progress_lines else ""
break
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 "")),
)
for name, instruction in batch:
ctx.emit(EventType.AGENT_STEP_START, agent=name, instruction=instruction[:300],
round=round_no, parallel=len(batch))
# 并发执行。它们拿到的是**同一份** progress 快照——这正是"任务必须
# 互不依赖"的技术含义,也是上面提示词里那几条反例的由来。
#
# 每个人单独计时,而不是事后拿整轮墙钟去摊:界面要显示"并行省了多少
# 时间",那个数必须是量出来的。按 N×墙钟 推算等于假设每个人都跑满了
# 最慢那条,而实际上快的那个可能只花了三分之一
async def _timed(name: str, instruction: str) -> tuple[str, Any, int]:
t0 = time.perf_counter()
try:
out: Any = await work(round_no, name, instruction, progress)
except BaseException as exc: # noqa: BLE001 - 交给下面统一处置
out = exc
return name, out, int((time.perf_counter() - t0) * 1000)
started = time.perf_counter()
results = await asyncio.gather(*(_timed(n, i) for n, i in batch))
wall_ms = int((time.perf_counter() - started) * 1000)
sum_ms = 0
for (name, instruction), (_, outcome, each_ms) in zip(batch, results):
if isinstance(outcome, BaseException):
# 一个专家挂了不该拖垮整轮:把失败当成它的产出交回调度者,
# 让它决定改派还是收工
text = f"({name} 执行失败:{type(outcome).__name__}: {outcome})"
outcome = {"text": text, "tool_calls": []}
ctx.emit(EventType.LOG, level="warn", round=round_no,
message=f"{name} 这一轮失败了:{text}")
sum_ms += each_ms
ctx.emit(EventType.AGENT_STEP_END, agent=name, duration_ms=each_ms,
round=round_no, parallel=len(batch), preview=outcome["text"][:500])
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"]
rounds_meta.append({
"round": round_no, "agents": [n for n, _ in batch],
"parallel": len(batch), "wall_ms": wall_ms, "sum_ms": sum_ms,
})
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,
}
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 会加前缀),所以在时间线上能看到它内部
每一步;但它有独立的状态,不会污染父图的变量池。
"""
from app.engine.compiler import compile_graph # 延迟导入,避免循环依赖
from app.engine.schema import GraphSpec
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,
extra={**ctx.run.extra, "node_prefix": f"{ctx.node.id}/"},
)
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()
result_state = await app.ainvoke(
{
"input": sub_input,
"vars": sub_input,
"nodes": {},
"output": {},
"messages": [],
"usage": {},
"trail": [],
"loops": {},
},
{"recursion_limit": settings.max_graph_steps},
)
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 "",
}
ctx.emit(EventType.LOG, level="info", message=f"子图「{workflow.name}」完成")
updates: dict[str, Any] = {
"nodes": {ctx.node.id: result},
"usage": result_state.get("usage") or {},
}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: output}
return updates