mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 11:01:58 +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 可检索。
97 lines
3.2 KiB
Python
97 lines
3.2 KiB
Python
from __future__ import annotations
|
||
|
||
import abc
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.core.config import settings
|
||
|
||
# 入口脚本的前缀。列"这次执行产出了哪些文件"时要把它们排掉——
|
||
# 它们是执行机制自己的临时文件,不是用户代码的产出。
|
||
ENTRY_PREFIX = "__entry_"
|
||
|
||
|
||
def entry_name(ext: str) -> str:
|
||
"""给这一次执行生成独立的入口脚本名。
|
||
|
||
同一个会话共用一个工作区(跨节点传文件要靠它),所以入口脚本**不能**
|
||
用固定的 main.py:一张图里并排的两个代码节点会在同一 superstep 并发执行,
|
||
都写 main.py 就会互相覆盖——后写的赢,而每个节点都以为跑的是自己的代码,
|
||
还都返回 ok。这是静默的结果污染,比报错难查得多。
|
||
"""
|
||
return f"{ENTRY_PREFIX}{uuid4().hex[:12]}{ext}"
|
||
|
||
|
||
class SandboxLimits(BaseModel):
|
||
timeout: int = Field(default_factory=lambda: settings.sandbox_timeout)
|
||
memory_mb: int = Field(default_factory=lambda: settings.sandbox_memory_mb)
|
||
cpus: float = Field(default_factory=lambda: settings.sandbox_cpus)
|
||
network: bool = Field(default_factory=lambda: settings.sandbox_network)
|
||
max_output: int = Field(default_factory=lambda: settings.sandbox_max_output)
|
||
|
||
|
||
class ExecResult(BaseModel):
|
||
ok: bool = True
|
||
exit_code: int = 0
|
||
stdout: str = ""
|
||
stderr: str = ""
|
||
duration_ms: int = 0
|
||
timed_out: bool = False
|
||
truncated: bool = False
|
||
backend: str = ""
|
||
files_written: list[str] = Field(default_factory=list)
|
||
error: str | None = None
|
||
# 这次执行导致会话工作区被重置了(比如 microVM 因超时或限额变更被销毁)。
|
||
# 会话契约说"同 session_id 共享文件系统,可以先写文件下一步再读回来",
|
||
# 所以工作区没了必须说出来——否则下一个节点只会看到 FileNotFoundError,
|
||
# 完全不知道是谁把文件弄丢的。
|
||
session_reset: bool = False
|
||
|
||
def summary(self) -> str:
|
||
if self.timed_out:
|
||
return f"执行超时(>{'?'}s)"
|
||
if self.error:
|
||
return self.error
|
||
return self.stdout or self.stderr or "(无输出)"
|
||
|
||
|
||
class Sandbox(abc.ABC):
|
||
"""代码执行后端的统一接口。"""
|
||
|
||
name: str = "base"
|
||
|
||
@abc.abstractmethod
|
||
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,
|
||
) -> ExecResult:
|
||
"""执行一段代码。session_id 相同的调用共享同一个文件系统,
|
||
这样 agent 可以先写文件、再在下一步读回来。"""
|
||
|
||
async def health(self) -> dict[str, Any]:
|
||
return {"backend": self.name, "available": True}
|
||
|
||
async def cleanup(self, session_id: str) -> None:
|
||
return None
|
||
|
||
async def close(self) -> None:
|
||
return None
|
||
|
||
|
||
def truncate(text: str, limit: int) -> tuple[str, bool]:
|
||
if len(text) <= limit:
|
||
return text, False
|
||
half = limit // 2
|
||
return (
|
||
text[:half] + f"\n\n…(省略 {len(text) - limit} 字符)…\n\n" + text[-half:],
|
||
True,
|
||
)
|