mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 12:21:59 +00:00
5928cc052e
显示成生效却没生效,比明说没有更危险——它让人以为有东西在兜底。
1. max_run_seconds 只存在于配置和设置接口里,没有任何代码执行它:一个挂住的
模型或工具能把运行和它占着的并发名额一直拖着。现在 runner 用 asyncio.timeout
强制执行,按"一段执行"计(等人工审批时 _drive 已结束,不算);超时判 failed、
说清是哪道上限,可以接着跑。别处抛的 TimeoutError 不冒充这道上限(看
Timeout.expired())。
2. 模型调用从来没设过超时:ModelSpec.timeout 从没被赋值,那条传参分支一次都没
执行过,所有调用走 SDK 默认的 10 分钟外加重试。新增 model_timeout_seconds
(默认 300 秒)作为缺省,节点显式配的仍优先;已确认 ChatAnthropic / ChatOpenAI
都接这个参数。
3. manifest 哈希只写不验,而且是在 run.finished 落库之前算的——清单里恰恰少了
宣布"跑完了、成果是什么"的那条,改成果清单照样对得上。现在终态事件落库之后
再封存,并记下封到哪一条(manifest_seq,新列);新增 verify_manifest 与
GET /api/runs/{id}/verify,运行详情页加了核对按钮。封存之后追加的事件不算篡改。
老运行没有 manifest_seq,按当时的口径还原范围——开发库里 300 条带哈希的历史
运行全部核对通过(成功 / 失败 / 取消各类都有),没有一条误报。
设置页「运行环境」补上单次执行时限和模型调用超时两项,README 加了对应的常见问题。
顺带修了一个被 _seal 放大的老问题:_drive 的收尾被打断时(关停取消、库连接出错)
漏掉 _tasks.pop,留下的任务让下一次 shutdown 的 gather 在别的事件循环里抛
"future belongs to a different loop"——测试里表现为每八九轮一次、一出就是几十个
teardown error。pop 移进内层 finally,shutdown 只等未结束的任务并清空。修后全量
连跑 12 轮 12 轮全绿。
test_guardrails.py 8 例:超时中止(挂住的节点 1 秒后被停下)、缺省模型超时、封存覆盖
run.finished、改成果被戳穿、事后追加不误报、老运行按旧口径核、中断态未封存、接口
404。414 → 422 passed。
138 lines
5.4 KiB
Python
138 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
|
PROJECT_DIR = BACKEND_DIR.parent
|
|
|
|
# session_id 拼进路径前必须过这一关。允许的字符刻意收得很窄:字母数字加
|
|
# 连字符下划线——点号也不放行,免得出现 ".." 这种能往上走的名字。
|
|
_SESSION_MAX = 64
|
|
|
|
|
|
def sanitize_session(session_id: str | None) -> str:
|
|
"""把任意字符串收敛成一个能安全当目录名的 token。
|
|
|
|
以前这段逻辑在四个沙箱后端里各抄了一遍,而文件工具那条路径漏抄了,
|
|
结果 sandbox_session 传 ".." 就能读到工作区外的文件(连加密主密钥都
|
|
读得到)。所以统一收到这里,只此一份。
|
|
"""
|
|
safe = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")
|
|
return safe[:_SESSION_MAX] or "default"
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""全局配置。所有项都可以用 AGENTLAB_ 前缀的环境变量覆盖。"""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="AGENTLAB_",
|
|
env_file=(PROJECT_DIR / ".env", BACKEND_DIR / ".env"),
|
|
extra="ignore",
|
|
)
|
|
|
|
# --- 服务 ---
|
|
host: str = "127.0.0.1"
|
|
port: int = 8000
|
|
cors_origins: list[str] = ["http://localhost:5273", "http://127.0.0.1:5273"]
|
|
|
|
# --- 存储 ---
|
|
data_dir: Path = PROJECT_DIR / "data"
|
|
|
|
# --- 密钥加密 ---
|
|
# 用于加密落库的 provider api key。留空则自动生成并存到 data_dir/.secret_key。
|
|
secret_key: str = ""
|
|
|
|
# --- 沙箱 ---
|
|
sandbox_backend: str = "auto" # auto | microvm | seatbelt | bubblewrap | local | off
|
|
sandbox_timeout: int = 30
|
|
sandbox_memory_mb: int = 512
|
|
sandbox_cpus: float = 1.0
|
|
sandbox_network: bool = False
|
|
sandbox_max_output: int = 20_000
|
|
|
|
# --- microVM(硬件级隔离,独立内核)---
|
|
# 跑代码用的 OCI 镜像。首次使用会拉取并缓存到 ~/.microsandbox。
|
|
microvm_image: str = "python:3.12-slim"
|
|
# microVM 是长驻的:同一 session 的多次执行复用同一台,省掉重复冷启动。
|
|
# 空闲超过这个秒数就回收,避免闲置 VM 一直占着内存。
|
|
microvm_idle_seconds: int = 300
|
|
# 单次启动(含首次拉镜像)的等待上限
|
|
microvm_boot_timeout: int = 300
|
|
|
|
# --- 执行引擎护栏 ---
|
|
# 单次执行(两次人工介入之间的那一段)的墙钟上限,runner 里强制执行
|
|
max_run_seconds: int = 600
|
|
# 单次模型调用的超时。流式调用时是"两段输出之间最长沉默多久",非流式是整次
|
|
# 请求。不设的话走 SDK 默认的 10 分钟、外加重试——一个挂住的服务商能把一个
|
|
# 节点拖上半小时
|
|
model_timeout_seconds: int = 300
|
|
max_graph_steps: int = 200
|
|
# 节点上配多少步都过不去这个硬顶。默认值仍是节点上的 12(nodeDefs.ts),
|
|
# 这里只决定"想调大的人最多能调到哪"——实测 87% 的 agent 节点 ≤10 步就收口,
|
|
# 绷紧的是那 10%,不该让所有人陪着多花钱
|
|
max_agent_steps: int = 100
|
|
#: 上传文件的大小上限(MB)。原来写死 10,而且是读完整个文件才检查——
|
|
#: 传一个 1GB 的文件,内存在报 413 之前就吃掉了
|
|
max_upload_mb: int = 50
|
|
max_concurrent_runs: int = 20
|
|
|
|
# --- 工具 ---
|
|
http_tool_timeout: int = 20
|
|
http_tool_max_bytes: int = 2_000_000
|
|
# 逗号分隔的域名白名单;为空表示不限制(仅拦截内网地址)
|
|
http_tool_allowlist: list[str] = []
|
|
|
|
@property
|
|
def db_path(self) -> Path:
|
|
return self.data_dir / "agentlab.db"
|
|
|
|
@property
|
|
def db_url(self) -> str:
|
|
return f"sqlite+aiosqlite:///{self.db_path}"
|
|
|
|
@property
|
|
def checkpoint_path(self) -> Path:
|
|
"""LangGraph checkpointer 用独立的库文件,避免和业务表抢写锁。"""
|
|
return self.data_dir / "checkpoints.db"
|
|
|
|
@property
|
|
def workspace_dir(self) -> Path:
|
|
"""沙箱与文件工具的可写根目录,一切文件访问都被限制在这里面。"""
|
|
return self.data_dir / "workspace"
|
|
|
|
def session_dir(self, session_id: str | None, *, sub: str = "") -> Path:
|
|
"""会话工作目录。session_id 一律先净化再拼路径。
|
|
|
|
这个函数存在的理由:session_id 有些调用点是外部可控的(工具 API 的
|
|
请求体就能指定),拿它直接拼路径就等于把工作区根目录交给调用方摆布,
|
|
后面再怎么做越界检查都是在被挪走的根下面做的,形同虚设。
|
|
"""
|
|
safe = sanitize_session(session_id)
|
|
root = (self.workspace_dir / (sub or "") / safe).resolve()
|
|
base = self.workspace_dir.resolve()
|
|
# 净化之后理应不可能跑出去,这里再断言一次:这条路径值得多一道保险
|
|
if root != base and base not in root.parents:
|
|
raise ValueError(f"非法的 session 目录:{session_id!r}")
|
|
return root
|
|
|
|
@property
|
|
def uploads_dir(self) -> Path:
|
|
return self.data_dir / "uploads"
|
|
|
|
def ensure_dirs(self) -> None:
|
|
for d in (self.data_dir, self.workspace_dir, self.uploads_dir):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
s = Settings()
|
|
s.ensure_dirs()
|
|
return s
|
|
|
|
|
|
settings = get_settings()
|