mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:12:02 +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 可检索。
225 lines
8.8 KiB
Python
225 lines
8.8 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sys
|
||
from typing import Any
|
||
|
||
from app.core.config import settings
|
||
from app.sandbox.base import ExecResult, Sandbox, SandboxLimits
|
||
from app.sandbox.bubblewrap_sandbox import BubblewrapSandbox
|
||
from app.sandbox.local_sandbox import LocalSandbox
|
||
from app.sandbox.microvm_sandbox import MicroVMSandbox
|
||
from app.sandbox.seatbelt_sandbox import SeatbeltSandbox
|
||
|
||
|
||
class DisabledSandbox(Sandbox):
|
||
name = "off"
|
||
|
||
async def run(self, code: str, **kwargs: Any) -> ExecResult: # type: ignore[override]
|
||
return ExecResult(
|
||
ok=False,
|
||
backend=self.name,
|
||
error="沙箱已在设置中关闭(AGENTLAB_SANDBOX_BACKEND=off)",
|
||
)
|
||
|
||
async def health(self) -> dict[str, Any]:
|
||
return {"backend": self.name, "available": False, "isolated": False, "reason": "已关闭"}
|
||
|
||
|
||
# 按隔离强度从高到低排,auto 模式挑第一个能用的。
|
||
# microVM 排在最前:它是唯一能真正限住内存、隔离进程表的一档。
|
||
_BACKENDS: list[tuple[str, type[Sandbox]]] = [
|
||
("microvm", MicroVMSandbox),
|
||
("bubblewrap", BubblewrapSandbox),
|
||
("seatbelt", SeatbeltSandbox),
|
||
("local", LocalSandbox),
|
||
]
|
||
|
||
|
||
class SandboxManager:
|
||
"""挑选并持有代码执行后端。
|
||
|
||
隔离强度从高到低:microVM(独立内核,限额真实生效)→ bubblewrap / Seatbelt
|
||
(共享内核的访问控制)→ 裸子进程。
|
||
|
||
auto 模式挑第一个**已就绪**的,判断比 available() 更严:microVM 还要求
|
||
OCI 镜像已经拉到本地(实测首次拉取约 55s)。所以镜像没缓存的机器会安静地
|
||
用 Seatbelt,而不是让第一次跑代码的人干等一分钟;拉过一次之后 auto 就会
|
||
自动升到 microVM,不用改配置。
|
||
|
||
显式指定(配置写死 microvm,或节点选了 strict 档)不受这条限制——
|
||
那是使用者自己要的硬隔离,该等就等。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._backend: Sandbox | None = None
|
||
self._named: dict[str, Sandbox] = {} # 按名字缓存,供节点级档位使用
|
||
self._lock = asyncio.Lock()
|
||
self._resolved_from: str = ""
|
||
self._reaper: asyncio.Task[None] | None = None
|
||
|
||
async def get(self, prefer: str | None = None) -> Sandbox:
|
||
"""取后端。prefer 指定档位时给那一档,拿不到就退回默认那台。
|
||
|
||
节点级的"隔离档位"走这条路:strict 要硬件隔离、fast 要低延迟,
|
||
而整机默认仍由 auto 决定。
|
||
"""
|
||
if prefer:
|
||
resolved = self._resolve_named(prefer)
|
||
if resolved is not None:
|
||
return resolved
|
||
if self._backend is not None:
|
||
return self._backend
|
||
async with self._lock:
|
||
if self._backend is not None:
|
||
return self._backend
|
||
self._backend = self._resolve()
|
||
self._named[self._backend.name] = self._backend
|
||
return self._backend
|
||
|
||
def _resolve_named(self, prefer: str) -> Sandbox | None:
|
||
"""按档位名取一个**已就绪**的后端;不可用时返回 None 让调用方退回默认。"""
|
||
# 关掉就是关掉。这条必须在最前面:否则配置里 off 只挡得住默认路径,
|
||
# 图里任何一个 isolation=strict 的代码节点照样能跑代码,
|
||
# DisabledSandbox 那句"沙箱已在设置中关闭"根本到不了。
|
||
if (settings.sandbox_backend or "auto").lower() == "off":
|
||
return None
|
||
|
||
# 只认这两个档位。以前是 alias.get(prefer, prefer) 原样透传,而 isolation
|
||
# 是图 JSON 里的自由字符串,于是图里写 isolation="local" 就能点名那个
|
||
# 明说了"没有任何访问控制"的兜底后端——档位是用来提高隔离的,不该能降级。
|
||
alias = {"strict": "microvm", "fast": "seatbelt" if sys.platform == "darwin" else "bubblewrap"}
|
||
name = alias.get(prefer)
|
||
if name is None:
|
||
return None
|
||
if name in self._named:
|
||
return self._named[name]
|
||
impl = dict(_BACKENDS).get(name)
|
||
if impl is None or not getattr(impl, "available", lambda: True)():
|
||
return None
|
||
backend = impl()
|
||
self._named[name] = backend
|
||
return backend
|
||
|
||
def _resolve(self) -> Sandbox:
|
||
choice = (settings.sandbox_backend or "auto").lower()
|
||
|
||
if choice == "off":
|
||
self._resolved_from = "配置为关闭"
|
||
return DisabledSandbox()
|
||
|
||
named = dict(_BACKENDS)
|
||
if choice in named:
|
||
impl = named[choice]
|
||
backend = impl()
|
||
available = getattr(impl, "available", lambda: True)()
|
||
self._resolved_from = (
|
||
f"配置指定 {choice}" if available
|
||
else f"配置指定 {choice},但当前环境不可用"
|
||
)
|
||
return backend
|
||
|
||
for name, impl in _BACKENDS:
|
||
if not getattr(impl, "available", lambda: True)():
|
||
continue
|
||
# auto 模式额外要求"开箱即用":microVM 镜像没拉过就先跳过
|
||
warm = getattr(impl, "image_ready", None)
|
||
if warm is not None and not warm():
|
||
self._resolved_from = f"{name} 可用但镜像未缓存,auto 先跳过"
|
||
continue
|
||
self._resolved_from = f"自动选择 {name}({_why(name)})"
|
||
return impl()
|
||
|
||
self._resolved_from = "没有可用的隔离后端,降级到本地子进程"
|
||
return LocalSandbox()
|
||
|
||
async def run(
|
||
self,
|
||
code: str,
|
||
*,
|
||
language: str = "python",
|
||
limits: SandboxLimits | None = None,
|
||
session_id: str | None = None,
|
||
env: dict[str, str] | None = None,
|
||
files: dict[str, str] | None = None,
|
||
backend: str | None = None,
|
||
) -> ExecResult:
|
||
impl = await self.get(backend)
|
||
return await impl.run(
|
||
code,
|
||
language=language,
|
||
limits=limits,
|
||
session_id=session_id,
|
||
env=env,
|
||
files=files,
|
||
)
|
||
|
||
async def start_reaper(self) -> None:
|
||
"""起一个后台任务定期回收闲置会话。
|
||
|
||
以前回收只挂在 run() 里,也就是说"没人再跑代码"时闲置的 microVM
|
||
永远不会被收掉,microvm_idle_seconds 形同虚设——几百 MB 一台就这么
|
||
挂着。回收本来也不该占着请求路径的时间。
|
||
"""
|
||
if self._reaper is not None:
|
||
return
|
||
|
||
async def _loop() -> None:
|
||
interval = max(30, settings.microvm_idle_seconds // 4)
|
||
while True:
|
||
try:
|
||
await asyncio.sleep(interval)
|
||
for impl in list(self._named.values()) + ([self._backend] if self._backend else []):
|
||
reap = getattr(impl, "_reap_idle", None)
|
||
if reap is not None:
|
||
await reap()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception: # noqa: BLE001 - 回收失败不该让任务停掉
|
||
continue
|
||
|
||
self._reaper = asyncio.create_task(_loop())
|
||
|
||
async def health(self) -> dict[str, Any]:
|
||
backend = await self.get()
|
||
info = await backend.health()
|
||
info["selected_because"] = self._resolved_from
|
||
info["configured"] = settings.sandbox_backend
|
||
info["defaults"] = SandboxLimits().model_dump()
|
||
info["candidates"] = [
|
||
{
|
||
"name": name,
|
||
"available": getattr(impl, "available", lambda: True)(),
|
||
# 可用但没预热的(microVM 镜像未拉取)要单独标出来:
|
||
# 它不会被 auto 选中,但显式指定仍然能用
|
||
**({} if getattr(impl, "image_ready", None) is None
|
||
else {"warm": impl.image_ready()}),
|
||
}
|
||
for name, impl in _BACKENDS
|
||
]
|
||
return info
|
||
|
||
async def cleanup(self, session_id: str) -> None:
|
||
# 会话可能落在任意一档上(节点各自选了档位),全部清一遍
|
||
for impl in {id(b): b for b in [*self._named.values(), self._backend] if b}.values():
|
||
await impl.cleanup(session_id)
|
||
|
||
async def close(self) -> None:
|
||
if self._reaper is not None:
|
||
self._reaper.cancel()
|
||
self._reaper = None
|
||
for impl in {id(b): b for b in [*self._named.values(), self._backend] if b}.values():
|
||
await impl.close()
|
||
|
||
|
||
def _why(name: str) -> str:
|
||
return {
|
||
"microvm": "microsandbox 运行时已就绪,独立内核",
|
||
"seatbelt": "macOS 自带 sandbox-exec(microVM 未就绪)",
|
||
"bubblewrap": "检测到 bwrap",
|
||
"local": "无可用隔离原语",
|
||
}.get(name, "")
|
||
|
||
|
||
sandbox_manager = SandboxManager()
|