Files
AgentLab/backend/app/engine/nodes/human.py
T
ThearchyHelios 964eeaa829 AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
2026-09-13 17:27:00 +08:00

173 lines
6.6 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 '未说明原因'}")
elif mode == "edit":
value = response.get("value") if isinstance(response, dict) else response
result = {"value": value, "note": _note_of(response), "__decision__": "approved"}
else: # input
value = response.get("value") if isinstance(response, dict) else response
result = {"value": value, "__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))