mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:01:59 +00:00
761117344b
## 每跑一次图就多一台常驻 VM,没有任何自动回收路径 _reap_idle 只在 run() 里调,也就是说没人再跑代码时闲置 VM 永远收不掉, microvm_idle_seconds 形同虚设;而 thread_id 每个 run 都是新的,runner 的 finally 又从不调 cleanup,于是每跑一次带代码节点的图就多一台几百 MB 的常驻 microVM(seatbelt 那边同样积了几十个历史会话目录)。 两处一起补:run 到终态就 cleanup(run_id)(interrupted 除外,人回来还要接着跑), 外加 lifespan 里起一个后台回收任务定期 _reap_idle——回收本来也不该占着请求 路径的时间。实测连跑三次 live_vms 稳定回到 0。 ## 超时会连人带文件销毁整台 VM 会话契约是"同 session_id 共享文件系统,可以先写文件下一步再读回来",而 microVM 超时直接 _destroy,工作区连同用户数据一起没了,返回结果里没有任何 提示。另外三个后端超时只 killpg、工作目录照留——换到 microVM 之后同一张图的 行为就变了。现在超时先在 VM 内 pkill 掉跑飞的进程(入口名带唯一后缀,不会 误伤同会话的并发执行),确认 VM 还活着就保住工作区;收拾不动才销毁。 限额变更那条没法避免重建(VM 内存是启动时定死的),但不能不吭声:同一个 run 里两个代码节点填了不同的 memory_mb 就会触发。ExecResult 新增 session_reset, 代码节点在时间线上明确告警,否则下游只会看到一个无从追查的 FileNotFoundError。 ## 新建 scope 写入第一条后永远搜不到 hybrid_rank 的 _norm 在 hi==lo 时把所有分数抹成 0,而候选只有一条时 hi 恒等于 lo,合并分 0.0 会被 min_score 过滤掉。"先写一条、下一轮再 recall"恰恰是 agent 最常见的用法。全都一样分是"都一样好",不是"都一样差",改成返回 1.0。 ## 记忆去重误判,写入被静默丢弃且返回不相关的旧记忆 remember 拿 recall 的分数判重,但那是 min-max 排名归一化的结果——排第一的那条 在两路信号上都会被归成 1.0,与它和查询像不像毫无关系。于是 scope 里只要已有 两条记忆,新内容随便撞一个字排到第一就被判成重复丢掉,接口还返回 201 并把那条 不相关的旧记忆当成"刚保存的"交回去。改成直接比未归一化的余弦相似度。 验证:三条互不相关的记忆都能写进去、完全相同的仍被去重、单条 scope 可检索。
177 lines
7.3 KiB
Python
177 lines
7.3 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
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
|
||
from app.sandbox.base import SandboxLimits
|
||
from app.sandbox.manager import sandbox_manager
|
||
from app.tools.registry import ToolContext, call_tool, get_spec
|
||
|
||
|
||
def _tool_ctx(ctx: NodeContext) -> ToolContext:
|
||
return ToolContext(
|
||
run_id=ctx.run.run_id,
|
||
node_id=ctx.node.id,
|
||
sandbox_session=ctx.run.thread_id,
|
||
memory_scope=ctx.cfg("memory_scope") or ctx.run.memory_scope,
|
||
collection=ctx.cfg("collection") or ctx.run.collection,
|
||
)
|
||
|
||
|
||
async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""直接调用一个工具。参数里的 {{ }} 会先用当前状态渲染。"""
|
||
name = ctx.cfg("tool", "")
|
||
if not name:
|
||
raise NodeError(ctx.node.id, "工具节点没有选择工具")
|
||
|
||
args = ctx.render(ctx.cfg("args", {}) or {}, state)
|
||
if not isinstance(args, dict):
|
||
raise NodeError(ctx.node.id, "工具参数必须是对象")
|
||
|
||
spec = get_spec(name)
|
||
approval = ctx.cfg("approval", "dangerous")
|
||
if approval == "always" or (approval == "dangerous" and spec and spec.dangerous):
|
||
ctx.emit(EventType.HUMAN_REQUESTED, mode="approve", tool=name, args=args,
|
||
title=f"是否允许调用 {name}?")
|
||
decision = interrupt(
|
||
{"kind": "tool_approval", "node_id": ctx.node.id, "tool": name, "args": args,
|
||
"title": f"是否允许调用 {name}?"}
|
||
)
|
||
approved = decision if isinstance(decision, bool) else bool(
|
||
(decision or {}).get("approved") if isinstance(decision, dict) else decision
|
||
)
|
||
if isinstance(decision, dict) and isinstance(decision.get("args"), dict):
|
||
args = decision["args"]
|
||
ctx.emit(EventType.HUMAN_RESOLVED, tool=name, approved=approved)
|
||
if not approved:
|
||
raise NodeError(ctx.node.id, f"用户拒绝执行工具 {name}")
|
||
|
||
ctx.emit(EventType.TOOL_START, tool=name, args=args)
|
||
started = time.perf_counter()
|
||
try:
|
||
async with SessionLocal() as session:
|
||
result = await call_tool(name, args, _tool_ctx(ctx), session=session)
|
||
except KeyError as e:
|
||
raise NodeError(ctx.node.id, str(e)) from e
|
||
except Exception as e: # noqa: BLE001
|
||
ctx.emit(EventType.TOOL_ERROR, tool=name, error=f"{type(e).__name__}: {e}")
|
||
if ctx.cfg("fail_fast", True):
|
||
raise NodeError(ctx.node.id, f"工具 {name} 执行失败:{e}") from e
|
||
result = {"error": f"{type(e).__name__}: {e}"}
|
||
|
||
elapsed = int((time.perf_counter() - started) * 1000)
|
||
# 取数快照:query(args)和结果集一起进工件库,正式出具时数字回指的就是它
|
||
from app.core.artifact_store import put_json
|
||
|
||
try:
|
||
snapshot_id = await put_json(
|
||
{"tool": name, "args": args, "result": result},
|
||
kind="tool_snapshot", run_id=ctx.run.run_id, node_id=ctx.node.id,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
snapshot_id = None
|
||
ctx.emit(EventType.TOOL_END, tool=name, duration_ms=elapsed,
|
||
preview=json.dumps(result, ensure_ascii=False, default=str)[:2000],
|
||
artifact=snapshot_id)
|
||
|
||
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
|
||
var_name = ctx.cfg("assign_to", "")
|
||
if var_name:
|
||
updates["vars"] = {var_name: result}
|
||
return updates
|
||
|
||
|
||
async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""沙箱代码节点。代码本身支持模板插值,可以把上游结果直接嵌进去。"""
|
||
code = ctx.render_str(ctx.cfg("code", ""), state)
|
||
if not code.strip():
|
||
raise NodeError(ctx.node.id, "代码节点是空的")
|
||
|
||
language = ctx.cfg("language", "python")
|
||
limits = SandboxLimits(
|
||
timeout=int(ctx.cfg("timeout", 30) or 30),
|
||
memory_mb=int(ctx.cfg("memory_mb", 512) or 512),
|
||
network=bool(ctx.cfg("network", False)),
|
||
)
|
||
|
||
if ctx.cfg("approval", "never") == "always":
|
||
ctx.emit(EventType.HUMAN_REQUESTED, mode="approve", title="是否执行这段代码?",
|
||
code=code[:4000], language=language)
|
||
decision = interrupt(
|
||
{"kind": "code_approval", "node_id": ctx.node.id, "code": code,
|
||
"language": language, "title": "是否执行这段代码?"}
|
||
)
|
||
approved = decision if isinstance(decision, bool) else bool(
|
||
(decision or {}).get("approved") if isinstance(decision, dict) else decision
|
||
)
|
||
if isinstance(decision, dict) and decision.get("code"):
|
||
code = decision["code"] # 允许人工改完再跑
|
||
ctx.emit(EventType.HUMAN_RESOLVED, approved=approved)
|
||
if not approved:
|
||
raise NodeError(ctx.node.id, "用户拒绝执行代码")
|
||
|
||
# 隔离档位:strict 要硬件级(microVM),fast 要低延迟(Seatbelt/bwrap),
|
||
# 留空跟随整机默认。要的那档不可用时会安静退回默认,不阻断运行。
|
||
isolation = ctx.cfg("isolation", "") or None
|
||
|
||
ctx.emit(EventType.SANDBOX_START, language=language, limits=limits.model_dump(),
|
||
isolation=isolation or "auto")
|
||
result = await sandbox_manager.run(
|
||
code,
|
||
language=language,
|
||
limits=limits,
|
||
session_id=ctx.run.thread_id,
|
||
files=ctx.render(ctx.cfg("files", {}) or {}, state),
|
||
backend=isolation,
|
||
)
|
||
if isolation == "strict" and result.backend != "microvm":
|
||
# 要了硬件隔离却没拿到,必须说出来——否则用户以为自己在 VM 里跑
|
||
ctx.emit(EventType.LOG, level="warn",
|
||
message=f"节点要求 strict 隔离,但 microVM 未就绪,实际用了 {result.backend}")
|
||
if result.session_reset:
|
||
# 会话工作区被清空了。同一会话的下游节点会突然读不到自己写的文件,
|
||
# 不说一声的话那个 FileNotFoundError 根本无从追查
|
||
ctx.emit(EventType.LOG, level="warn",
|
||
message="沙箱会话已重置,此前在该会话写入的文件已丢失"
|
||
"(多为超时重建,或同会话中各节点的内存限额不一致)")
|
||
ctx.emit(
|
||
EventType.SANDBOX_END,
|
||
ok=result.ok,
|
||
exit_code=result.exit_code,
|
||
duration_ms=result.duration_ms,
|
||
backend=result.backend,
|
||
stdout=result.stdout[:4000],
|
||
stderr=result.stderr[:2000],
|
||
)
|
||
|
||
if not result.ok and ctx.cfg("fail_fast", True):
|
||
raise NodeError(
|
||
ctx.node.id,
|
||
f"代码执行失败(exit={result.exit_code}):{result.error or result.stderr[:500]}",
|
||
)
|
||
|
||
payload = result.model_dump()
|
||
# 如果 stdout 是 JSON,顺手解析出来,下游就能直接用字段而不是再写解析
|
||
parsed: Any = None
|
||
text = result.stdout.strip()
|
||
if text.startswith(("{", "[")):
|
||
try:
|
||
parsed = json.loads(text)
|
||
except json.JSONDecodeError:
|
||
parsed = None
|
||
payload["data"] = parsed
|
||
payload["text"] = result.stdout
|
||
|
||
updates: dict[str, Any] = {"nodes": {ctx.node.id: payload}}
|
||
var_name = ctx.cfg("assign_to", "")
|
||
if var_name:
|
||
updates["vars"] = {var_name: parsed if parsed is not None else result.stdout}
|
||
return updates
|