Files
AgentLab/backend/app/engine/nodes/tools.py
T
ThearchyHelios 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 可检索。
2026-09-14 15:41:05 +08:00

177 lines
7.3 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
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