Files
AgentLab/backend/app/engine/nodes/human.py
T
ThearchyHelios b634c89be4 人工介入的驳回按钮不再是摆设,画布脏边与待办入口
## 点"驳回"实际等于放行

ApprovalCard 无条件渲染"通过/驳回"两个按钮,而后端 human 节点只在 approve
模式下读 approved:edit 和 input 分支直接取 value,__decision__ 硬编码成
"approved",approved 字段看都不看。按钮在、文案在、toast 说"已驳回",运行却
照常往下跑——比没有这个按钮更糟。

这两种模式在图上只有一个 out 出口(没有 rejected 那条边),所以驳回的正确
语义是终止运行。缺省仍是通过,只传 value 不带 approved 的老调用方行为不变。
前端按钮文案改成"驳回并终止",toast 也说清楚。

顺带:run 级的错误消息不再给 NodeError 套类型名前缀,界面上从
"NodeError: 人工驳回:内容不合格" 变成 "人工驳回:内容不合格"。

## 改分支 case key 会留下看不见、删不掉的脏边

出口 handle 是从 config 算出来的(分支的 case key、human 的 mode),而
Inspector 的输入框逐字符 setConfig。key 从 yes 改成 yesno 的过程中 handle id
会依次变成 y/ye/yes…,已有边的 sourceHandle 仍是 yes。React Flow 对这种边只在
console 打一条 008 警告然后不渲染——看不见、点不到、删不掉,却照样被 toGraph
序列化存盘,后端校验也不管。

updateNode 现在同步处理:出口数量没变就按位置重映射(改名时连好的线跟着走),
出口真的没了就把边一并清掉。

## 未保存的编辑会被静默丢弃

切换工作流直接 load() 覆盖画布,没有 dirty 检查;全局也没有撤销和
beforeunload。加了切换确认与关页面拦截。撤销栈是更该补的,但那是另一件事。

## 刷新后"等待人工"的运行没有任何入口

审批卡片挂在 studio store 的当前 run 上,而那是纯内存的,刷新后为 null,卡片
就不渲染了。更糟的是导航把待办徽标挂在"运行"页上,用户点进去——而 RunsPage
从头到尾没有审批 UI,只有只读事件列表。现在 RunsPage 为选中的运行渲染
ApprovalCard,徽标指向的地方终于能处理待办。

回归:input/edit 两种模式的通过与驳回、approve 模式、代码节点 strict 档、
分支、口径卡出具 formal、工具节点、SSRF 拦截、记忆工具,全部通过。
2026-09-14 15:46:45 +08:00

181 lines
7.2 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 json
from typing import Any
from langgraph.types import interrupt
from app.core.events import EventType
from app.db.base import SessionLocal
from app.engine.context import NodeContext, NodeError
from app.engine.state import GraphState, message_text, template_context
from app.providers.factory import ModelSpec, get_chat_model
async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""人工介入节点。
执行到这里会挂起整张图并落盘 checkpoint;进程重启也不影响,
人在前端回复之后从断点继续,而不是从头重跑。
"""
mode = ctx.cfg("mode", "approve") # approve | input | edit
payload = {
"kind": "human_node",
"node_id": ctx.node.id,
"mode": mode,
"title": ctx.render_str(ctx.cfg("title", "需要你确认"), state),
"message": ctx.render_str(ctx.cfg("message", ""), state),
"schema": ctx.cfg("schema") or {},
"context": ctx.render(ctx.cfg("context", {}) or {}, state),
}
if mode == "edit":
payload["draft"] = ctx.render(ctx.cfg("draft", ""), state)
ctx.emit(EventType.HUMAN_REQUESTED, **payload)
response = interrupt(payload)
ctx.emit(EventType.HUMAN_RESOLVED, response=response)
if mode == "approve":
approved = _truthy(response)
result: dict[str, Any] = {
"approved": approved,
"note": _note_of(response),
"__decision__": "approved" if approved else "rejected",
}
if not approved and ctx.cfg("stop_on_reject", False):
raise NodeError(ctx.node.id, f"人工拒绝:{result['note'] or '未说明原因'}")
else: # edit / input
# 驳回在这两种模式下以前是个摆设:__decision__ 硬编码成 approved,
# approved 字段看都不看。界面上按钮在、toast 说"已驳回",运行却照常
# 往下走——比没有这个按钮更糟。
#
# 这两种模式在图上只有一个 out 出口(没有 rejected 那条边),所以驳回
# 的正确语义是终止运行,而不是当作通过继续。缺省仍是通过:只传 value
# 不带 approved 的老调用方行为不变。
rejected = isinstance(response, dict) and response.get("approved") is False
if rejected:
note = _note_of(response)
raise NodeError(ctx.node.id, f"人工驳回:{note or '未说明原因'}")
value = response.get("value") if isinstance(response, dict) else response
result = {"value": value, "note": _note_of(response), "__decision__": "approved"}
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: result.get("value", result.get("approved"))}
return updates
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, dict):
if "approved" in value:
return bool(value["approved"])
return str(value.get("decision", "")).lower() in ("approve", "yes", "true", "同意")
if isinstance(value, str):
return value.strip().lower() in ("yes", "y", "true", "approve", "ok", "同意", "通过")
return bool(value)
def _note_of(value: Any) -> str:
if isinstance(value, dict):
return str(value.get("note", "") or value.get("comment", ""))
return ""
# --------------------------------------------------------------------------
# 结构化校验
# --------------------------------------------------------------------------
async def run_validate(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""按 JSON Schema 校验上游产出。
校验失败时可以把错误原样喂回模型让它改 —— 这比在 prompt 里祈祷它输出正确格式可靠得多。
"""
import jsonschema
schema = ctx.cfg("schema")
if not schema:
raise NodeError(ctx.node.id, "校验节点需要一个 JSON Schema")
source_cfg = ctx.cfg("source", "{{ last_message }}")
raw = ctx.render(source_cfg, state) if not isinstance(source_cfg, str) else ctx.render_str(source_cfg, state)
max_retries = int(ctx.cfg("max_retries", 2) or 0)
repair = bool(ctx.cfg("repair_with_llm", True))
attempt = 0
errors: list[str] = []
value: Any = raw
while True:
value, parse_error = _coerce_json(raw)
if parse_error:
errors = [parse_error]
else:
try:
jsonschema.validate(value, schema)
ctx.emit(EventType.LOG, level="info", message=f"校验通过(第 {attempt + 1} 次尝试)")
result = {"valid": True, "data": value, "attempts": attempt + 1, "errors": []}
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: value}
return updates
except jsonschema.ValidationError as e:
path = "/".join(str(p) for p in e.absolute_path) or "(根)"
errors = [f"{path}: {e.message}"]
ctx.emit(EventType.LOG, level="warn", message=f"第 {attempt + 1} 次校验失败:{errors[0]}")
attempt += 1
if attempt > max_retries or not repair:
break
raw = await _repair(raw, schema, errors, ctx, state)
result = {"valid": False, "data": value, "attempts": attempt, "errors": errors}
if ctx.cfg("fail_fast", True):
raise NodeError(ctx.node.id, f"结构化校验未通过:{'; '.join(errors)}")
return {"nodes": {ctx.node.id: result}}
def _coerce_json(raw: Any) -> tuple[Any, str | None]:
"""尽量把模型输出解析成 JSON:剥掉 ``` 围栏,再退一步找第一个完整对象。"""
if isinstance(raw, (dict, list)):
return raw, None
text = str(raw or "").strip()
if text.startswith("```"):
lines = text.splitlines()
text = "\n".join(lines[1 : -1 if lines[-1].strip().startswith("```") else None]).strip()
try:
return json.loads(text), None
except json.JSONDecodeError:
pass
start = min(
(i for i in (text.find("{"), text.find("[")) if i >= 0), default=-1
)
if start >= 0:
for end in range(len(text), start, -1):
try:
return json.loads(text[start:end]), None
except json.JSONDecodeError:
continue
return text, "输出不是合法 JSON"
async def _repair(raw: Any, schema: dict[str, Any], errors: list[str], ctx: NodeContext,
state: GraphState) -> str:
async with SessionLocal() as session:
model, _ = await get_chat_model(
session,
ModelSpec(provider=ctx.cfg("provider"), model=ctx.cfg("model"), max_tokens=4096),
)
prompt = (
"下面这段输出没有通过 JSON Schema 校验,请修正后重新输出。\n\n"
f"Schema:\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n\n"
f"错误:\n- " + "\n- ".join(errors) + "\n\n"
f"原始输出:\n{str(raw)[:6000]}\n\n"
"只输出修正后的 JSON,不要任何解释或代码围栏。"
)
return message_text(await model.ainvoke(prompt))