mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
b0575a7d1b
## 一条分支条件能把后端冻死
求值器放行 ast.Pow,唯一护栏是 AST 节点数 <= 200。`9**9**9**9` 只有 11 个节点,
却让 CPython 无限期算大整数。而求值是在事件循环里同步执行的(分支条件、
transform、口径卡、每个节点的 skip_if),没有线程池隔离也没有超时,asyncio 的
cancel 打断不了同步 CPU。给底数和指数加上界,超界报 ExpressionError。
2**10、2**0.5、10**-2 这些正常用法不受影响。
## NodeError 绕过 retries / on_error / node.failed
`except NodeError: raise` 排在重试与兜底之前,于是节点自己抛的错既不重试、
也不发 node.failed、更不被 on_error=continue 接住——而 NodeError 恰恰是所有
执行器表达失败的标准方式(缺必填输入、模型没配、口径卡算不出…)。同一张图
配着同样的 retries=1 + on_error=continue,1/0 会重试并继续,而 JSON 模板非法
直接整个 run 失败。两条路径现在行为一致,node.failed 也带上 node_id
(前端靠它把出错节点从"转圈"收敛成"失败")。
## 没有 checkpoint 的运行被"恢复"成一份假结果
重启扫描把 running 和 queued 一律标成 interrupted,而 resume 不检查有没有
checkpoint 就发 Command。queued 的运行从没写过 checkpoint,LangGraph 会拿空
state 从 START 重跑整张图——run.input 完全没注入,用字段默认值算完还落成
succeeded。现在 queued 直接标 failed(没有进度可恢复,请重新发起),resume
先 aget_state 确认有断点,没有就 409 并说清为什么。
## 并行人工节点:一次 resume 打爆整个 run
同一 superstep 有多个 interrupt 时,resume 仍只发标量 Command,LangGraph 直接
抛 RuntimeError;而所有 pending 审批已经被写成同一个回复。Approval.interrupt_id
和 ResumeIn.approval_id 这两个字段一直存在却从没被用过。现在按 interrupt_id
逐条收集:多条待办时必须指定 approval_id,回复的那条转 answered 继续等,
凑齐引擎在等的全部 interrupt 才发 Command(resume={iid: value})。
验证:两个并行 human 节点——不指定 approval_id 被 409 拒、回复第一条后仍
interrupted 且剩 1 条 pending、凑齐后 succeeded。单人审批与 decide 通道回归通过。
471 lines
17 KiB
Python
471 lines
17 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from sqlalchemy import select as _select
|
||
|
||
from app.core.bus import bus
|
||
from app.core import artifact_store
|
||
from app.db.base import SessionLocal, get_session
|
||
from app.db.models import Approval, Artifact, Run, RunEvent, Workflow, WorkflowVersion
|
||
from app.engine.governance import unresolved_caliber_upgrades
|
||
from app.engine.runner import run_manager
|
||
from app.engine.schema import GraphSpec
|
||
|
||
router = APIRouter(prefix="/api/runs", tags=["runs"])
|
||
|
||
|
||
class StartRunIn(BaseModel):
|
||
workflow_id: str | None = None
|
||
graph: dict[str, Any] | None = None # 直接跑一张未保存的图(画布上的"试运行")
|
||
input: dict[str, Any] = Field(default_factory=dict)
|
||
memory_scope: str = "default"
|
||
collection: str = "default"
|
||
# formal:从已发布的不可变版本发起,可复现可出具;exploratory:随便玩
|
||
run_class: str = "exploratory"
|
||
version: int | None = None # formal 时可指定版本,默认用已发布版本
|
||
|
||
|
||
class RunOut(BaseModel):
|
||
id: str
|
||
workflow_id: str | None
|
||
workflow_name: str
|
||
status: str
|
||
input: dict[str, Any]
|
||
output: dict[str, Any]
|
||
error: str | None
|
||
usage: dict[str, Any]
|
||
run_class: str | None = "exploratory"
|
||
version: int | None = None
|
||
version_hash: str | None = None
|
||
manifest_hash: str | None = None
|
||
started_by: str | None = None
|
||
created_at: Any = None
|
||
started_at: Any = None
|
||
finished_at: Any = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
@router.post("", response_model=RunOut, status_code=201)
|
||
async def start_run(
|
||
payload: StartRunIn,
|
||
session: AsyncSession = Depends(get_session),
|
||
x_actor: str | None = Header(default=None),
|
||
) -> Run:
|
||
actor = (x_actor or "").strip() or None
|
||
name = "临时图"
|
||
version: int | None = None
|
||
version_hash: str | None = None
|
||
|
||
if payload.run_class == "formal":
|
||
# 正式运行的全部语义就这一条规则:必须引用一个不可变的已发布版本。
|
||
# 传裸 graph、或工作流还没发布过,都进不了 formal。
|
||
if not payload.workflow_id:
|
||
raise HTTPException(400, "正式运行必须指定 workflow_id")
|
||
if payload.graph is not None:
|
||
raise HTTPException(400, "正式运行不接受临时 graph——先保存并发布版本")
|
||
workflow = await session.get(Workflow, payload.workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, "工作流不存在")
|
||
# 只看 published_version:status 反映的是当前画布(改过图就退回 draft),
|
||
# 而 formal 跑的是已发布的那一版,两者本来就可以不一致
|
||
if not workflow.published_version:
|
||
raise HTTPException(409, f"「{workflow.name}」还没有发布版本,先在编排页发布")
|
||
version = payload.version or workflow.published_version
|
||
# "不可变"和"过过闸"是两件事。快照确实不可变,但 PATCH 保存出来的
|
||
# 草稿版本同样有快照——只认 version 存不存在的话,工作流只要发布过
|
||
# 任意一个版本,之后所有草稿都能以 formal 身份启动,发布期的
|
||
# validate_graph + lint_for_publish 全部绕过。
|
||
if version != workflow.published_version:
|
||
raise HTTPException(
|
||
409,
|
||
f"v{version} 不是当前发布版本(v{workflow.published_version})。"
|
||
"正式运行只能引用发布过的版本——要跑这一版就先发布它。",
|
||
)
|
||
snapshot = (
|
||
await session.execute(
|
||
_select(WorkflowVersion).where(
|
||
WorkflowVersion.workflow_id == workflow.id,
|
||
WorkflowVersion.version == version,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not snapshot:
|
||
raise HTTPException(404, f"版本 v{version} 不存在")
|
||
graph = snapshot.graph
|
||
version_hash = snapshot.graph_hash or artifact_store.graph_hash(graph)
|
||
name = workflow.name
|
||
|
||
# 口径升版是被迫处置的事件:钉住的方法卡有新版本而未声明策略,拒绝启动
|
||
spec = GraphSpec.model_validate(graph)
|
||
errors, upgrade_events = await unresolved_caliber_upgrades(session, spec)
|
||
if errors:
|
||
raise HTTPException(409, ";".join(errors))
|
||
else:
|
||
graph = payload.graph
|
||
upgrade_events = []
|
||
if payload.workflow_id:
|
||
workflow = await session.get(Workflow, payload.workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, "工作流不存在")
|
||
graph = graph or workflow.graph
|
||
name = workflow.name
|
||
if not graph:
|
||
raise HTTPException(400, "需要提供 workflow_id 或 graph")
|
||
|
||
try:
|
||
run = await run_manager.start(
|
||
graph=graph,
|
||
input_payload=payload.input,
|
||
workflow_id=payload.workflow_id,
|
||
workflow_name=name,
|
||
memory_scope=payload.memory_scope,
|
||
collection=payload.collection,
|
||
run_class="formal" if payload.run_class == "formal" else "exploratory",
|
||
version=version,
|
||
version_hash=version_hash,
|
||
started_by=actor,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(400, str(e)) from e
|
||
|
||
# 已声明策略的升版记入事件流,出具物上能看到"这期换口径了、怎么处置的"
|
||
for ev in upgrade_events:
|
||
payload_ev = dict(ev)
|
||
node = payload_ev.pop("node_id", None)
|
||
await run_manager.note(run.id, "caliber.upgrade", node_id=node, **payload_ev)
|
||
return run
|
||
|
||
|
||
@router.get("", response_model=list[RunOut])
|
||
async def list_runs(
|
||
workflow_id: str | None = None,
|
||
status: str | None = None,
|
||
limit: int = Query(default=50, le=200),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> list[Run]:
|
||
stmt = select(Run).order_by(Run.created_at.desc()).limit(limit)
|
||
if workflow_id:
|
||
stmt = stmt.where(Run.workflow_id == workflow_id)
|
||
if status:
|
||
stmt = stmt.where(Run.status == status)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
@router.get("/{run_id}", response_model=RunOut)
|
||
async def get_run(run_id: str, session: AsyncSession = Depends(get_session)) -> Run:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
return run
|
||
|
||
|
||
@router.get("/{run_id}/events")
|
||
async def get_events(
|
||
run_id: str,
|
||
after: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> list[dict[str, Any]]:
|
||
"""拉历史事件。刷新页面后重建时间线用这个,再接 WebSocket 续上实时流。"""
|
||
rows = await session.execute(
|
||
select(RunEvent)
|
||
.where(RunEvent.run_id == run_id, RunEvent.seq > after)
|
||
.order_by(RunEvent.seq)
|
||
)
|
||
return [
|
||
{
|
||
"id": e.id,
|
||
"run_id": e.run_id,
|
||
"seq": e.seq,
|
||
"type": e.type,
|
||
"node_id": e.node_id,
|
||
"ts": e.ts,
|
||
"data": e.data,
|
||
}
|
||
for e in rows.scalars()
|
||
]
|
||
|
||
|
||
@router.get("/{run_id}/artifacts")
|
||
async def list_artifacts(
|
||
run_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> list[dict[str, Any]]:
|
||
"""这次运行产出的全部工件引用。审阅出具物时从这里下钻到完整证据。"""
|
||
rows = await session.execute(
|
||
select(Artifact).where(Artifact.run_id == run_id).order_by(Artifact.created_at)
|
||
)
|
||
return [
|
||
{
|
||
"id": a.id,
|
||
"kind": a.kind,
|
||
"node_id": a.node_id,
|
||
"size": a.size,
|
||
"meta": a.meta,
|
||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||
}
|
||
for a in rows.scalars()
|
||
]
|
||
|
||
|
||
@router.post("/{run_id}/cancel")
|
||
async def cancel_run(run_id: str) -> dict[str, Any]:
|
||
stopped = await run_manager.cancel(run_id)
|
||
if not stopped:
|
||
raise HTTPException(409, "该运行当前不在执行中")
|
||
return {"ok": True}
|
||
|
||
|
||
class ResumeIn(BaseModel):
|
||
response: Any = None
|
||
approval_id: str | None = None
|
||
|
||
|
||
@router.post("/{run_id}/resume", response_model=RunOut)
|
||
async def resume_run(
|
||
run_id: str, payload: ResumeIn, x_actor: str | None = Header(default=None)
|
||
) -> Run:
|
||
try:
|
||
# approval_id 以前定义了却从没传下去——一次运行里有多条待审批时,
|
||
# 引擎无从知道这次回复的是哪一条
|
||
return await run_manager.resume(
|
||
run_id, payload.response,
|
||
approval_id=payload.approval_id,
|
||
actor=(x_actor or "").strip() or None,
|
||
)
|
||
except KeyError as e:
|
||
raise HTTPException(404, str(e)) from e
|
||
except ValueError as e:
|
||
raise HTTPException(409, str(e)) from e
|
||
|
||
|
||
@router.get("/{run_id}/state")
|
||
async def get_state(run_id: str) -> dict[str, Any]:
|
||
"""读取 checkpoint 里的当前状态。调试中断中的 run 时很有用。"""
|
||
from app.engine.compiler import compile_graph
|
||
from app.engine.context import RunContext
|
||
from app.engine.schema import GraphSpec
|
||
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
|
||
spec = GraphSpec.model_validate(run.graph)
|
||
app = compile_graph(spec, RunContext(run_id=run_id, thread_id=run_id, spec=spec)).compile(
|
||
checkpointer=run_manager.checkpointer
|
||
)
|
||
snapshot = await app.aget_state({"configurable": {"thread_id": run_id}})
|
||
values = dict(snapshot.values or {})
|
||
values.pop("messages", None) # 消息对象不好直接序列化,前端也用不到
|
||
return {
|
||
"values": _jsonable(values),
|
||
"next": list(snapshot.next or ()),
|
||
"interrupts": [
|
||
{"id": getattr(i, "id", None), "value": _jsonable(getattr(i, "value", None))}
|
||
for i in (snapshot.interrupts or ())
|
||
],
|
||
}
|
||
|
||
|
||
@router.get("/{run_id}/history")
|
||
async def get_history(run_id: str, limit: int = 50) -> list[dict[str, Any]]:
|
||
"""checkpoint 历史 —— 可以看到每一步之后的状态快照,用来做时间旅行式调试。"""
|
||
from app.engine.compiler import compile_graph
|
||
from app.engine.context import RunContext
|
||
from app.engine.schema import GraphSpec
|
||
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
|
||
spec = GraphSpec.model_validate(run.graph)
|
||
app = compile_graph(spec, RunContext(run_id=run_id, thread_id=run_id, spec=spec)).compile(
|
||
checkpointer=run_manager.checkpointer
|
||
)
|
||
out: list[dict[str, Any]] = []
|
||
async for snapshot in app.aget_state_history({"configurable": {"thread_id": run_id}}):
|
||
values = dict(snapshot.values or {})
|
||
values.pop("messages", None)
|
||
out.append(
|
||
{
|
||
"checkpoint_id": snapshot.config.get("configurable", {}).get("checkpoint_id"),
|
||
"next": list(snapshot.next or ()),
|
||
"created_at": snapshot.created_at,
|
||
"trail": _jsonable(values.get("trail") or [])[-1:],
|
||
"nodes_done": list((values.get("nodes") or {}).keys()),
|
||
}
|
||
)
|
||
if len(out) >= limit:
|
||
break
|
||
return out
|
||
|
||
|
||
@router.delete("/{run_id}", status_code=204)
|
||
async def delete_run(run_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
await session.delete(run)
|
||
await session.commit()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 实时事件流
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@router.websocket("/{run_id}/stream")
|
||
async def stream_run(websocket: WebSocket, run_id: str) -> None:
|
||
"""把一次运行的事件推给前端。
|
||
|
||
先补历史再接实时,所以中途打开页面、或者刷新之后,时间线都是完整的。
|
||
"""
|
||
await websocket.accept()
|
||
after = int(websocket.query_params.get("after", 0) or 0)
|
||
|
||
try:
|
||
async with SessionLocal() as session:
|
||
rows = await session.execute(
|
||
select(RunEvent)
|
||
.where(RunEvent.run_id == run_id, RunEvent.seq > after)
|
||
.order_by(RunEvent.seq)
|
||
)
|
||
for event in rows.scalars():
|
||
await websocket.send_json(
|
||
{
|
||
"seq": event.seq,
|
||
"type": event.type,
|
||
"node_id": event.node_id,
|
||
"ts": event.ts,
|
||
"data": event.data,
|
||
"replay": True,
|
||
}
|
||
)
|
||
run = await session.get(Run, run_id)
|
||
|
||
# 已经结束的 run 没有后续事件,补完历史就关掉
|
||
if run and run.status in ("succeeded", "failed", "cancelled"):
|
||
await websocket.send_json({"type": "stream.end", "status": run.status})
|
||
await websocket.close()
|
||
return
|
||
|
||
stream = bus.subscribe(run_id)
|
||
pump = asyncio.create_task(_pump(websocket, stream))
|
||
try:
|
||
# 客户端断开时读操作会抛异常,用它来结束这条连接
|
||
while True:
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
pass
|
||
finally:
|
||
pump.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError):
|
||
await pump
|
||
except WebSocketDisconnect:
|
||
return
|
||
except Exception: # noqa: BLE001
|
||
with contextlib.suppress(Exception):
|
||
await websocket.close()
|
||
|
||
|
||
async def _pump(websocket: WebSocket, stream: Any) -> None:
|
||
try:
|
||
async for event in stream:
|
||
await websocket.send_json(event.to_wire())
|
||
except (WebSocketDisconnect, RuntimeError):
|
||
return
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 审批
|
||
# --------------------------------------------------------------------------
|
||
|
||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||
|
||
|
||
class ApprovalOut(BaseModel):
|
||
id: str
|
||
run_id: str
|
||
node_id: str
|
||
mode: str
|
||
title: str
|
||
payload: dict[str, Any]
|
||
status: str
|
||
response: dict[str, Any]
|
||
created_at: Any = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
@approvals_router.get("", response_model=list[ApprovalOut])
|
||
async def list_approvals(
|
||
status: str = "pending",
|
||
run_id: str | None = None,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> list[Approval]:
|
||
stmt = select(Approval).order_by(Approval.created_at.desc()).limit(100)
|
||
if status != "all":
|
||
stmt = stmt.where(Approval.status == status)
|
||
if run_id:
|
||
stmt = stmt.where(Approval.run_id == run_id)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
class DecideIn(BaseModel):
|
||
approved: bool = True
|
||
note: str = ""
|
||
value: Any = None
|
||
args: dict[str, Any] | None = None
|
||
|
||
|
||
@approvals_router.post("/{approval_id}/decide", response_model=RunOut)
|
||
async def decide(
|
||
approval_id: str,
|
||
payload: DecideIn,
|
||
session: AsyncSession = Depends(get_session),
|
||
x_actor: str | None = Header(default=None),
|
||
) -> Run:
|
||
approval = await session.get(Approval, approval_id)
|
||
if not approval:
|
||
raise HTTPException(404, "审批请求不存在")
|
||
if approval.status != "pending":
|
||
raise HTTPException(409, "该请求已经处理过了")
|
||
|
||
# 责任归属先落库再恢复执行——恢复失败也要留下"谁试图批的"
|
||
approval.resolved_by = (x_actor or "").strip() or None
|
||
await session.commit()
|
||
|
||
response: dict[str, Any] = {"approved": payload.approved, "note": payload.note}
|
||
if payload.value is not None:
|
||
response["value"] = payload.value
|
||
if payload.args is not None:
|
||
response["args"] = payload.args
|
||
|
||
try:
|
||
return await run_manager.resume(
|
||
approval.run_id, response,
|
||
approval_id=approval.id,
|
||
actor=approval.resolved_by,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(409, str(e)) from e
|
||
|
||
|
||
def _jsonable(value: Any) -> Any:
|
||
if isinstance(value, dict):
|
||
return {str(k): _jsonable(v) for k, v in value.items()}
|
||
if isinstance(value, (list, tuple)):
|
||
return [_jsonable(v) for v in value]
|
||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||
return value
|
||
return str(value)
|