mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 12:41:58 +00:00
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 拦截、记忆工具,全部通过。
181 lines
7.2 KiB
Python
181 lines
7.2 KiB
Python
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))
|