Chapter 15

后台执行栈

Agent 不只是"等用户问话再答"。Cron 调度、批量轨迹生成、压缩、SWE 评测—— 这一章是 Hermes 里"用户看不见但产业用得最重"的子系统。

本章约 9,500 字 阅读 ~30 分钟 关键词:cron · catchup window · inactivity timeout · batch trajectory · compression

前 14 章看的都是"用户输入 → Agent 答"的同步流程。但生产 Agent 里有一大半工作不在这条路上: 夜里自动跑数据 + 推送报告;白天并发跑几百个 prompt 生成训练轨迹;跑完后压缩到能塞进 SFT; 从开源仓库挑 SWE-Bench 任务做评测。 这一章拆 Hermes 的 5 个后台子系统:Cron、cronjob 工具、Batch Runner、Trajectory Compressor、Mini SWE Runner。 代码量加起来 7600 行——但每一行都解决一个"夜里没人盯着也得稳"的工程问题。

15.1三种"不在 turn 里跑"的场景

场景触发方式对应子系统
定时自动跑("每天 9 点把昨日 PR 列表总结发到 Slack")调度器 tickcron/scheduler.py
Agent 自己排课("下次见到这报错时 1 小时后再试")LLM 调 cronjob 工具tools/cronjob_tools.py
批量生成训练数据(几千条 prompt → 几千条轨迹)CLI batch_runner.pybatch_runner.py + trajectory_compressor.py
跑 SWE-Bench 风格评测CLI mini_swe_runner.pymini_swe_runner.py

共同特点:没有用户看着。这意味着所有失败都得"自己救",所有产出都得记盘,所有资源都得有 cap。

flowchart TB
  subgraph Foreground["前 14 章 — 同步 Agent"]
    U["用户消息"] --> Loop["run_conversation"]
    Loop --> R["回复"]
  end
  subgraph Background["本章 — 后台执行栈"]
    direction TB
    Sched["Cron Scheduler
(tick 每 60s)"] --> Job["定时 Agent"] LLM["LLM 自己排课
(cronjob 工具)"] --> Job Batch["batch_runner
(多进程 Pool)"] --> Traj["轨迹文件"] Traj --> Comp["trajectory_compressor
(LLM 总结中段)"] Comp --> SFT["SFT 训练集"] SWE["mini_swe_runner"] --> Eval["pass/fail 报告"] end Background -.-> KeyPoints["共同关切:
失败自救 · 持久化 · 资源 cap"]:::accent classDef accent fill:#f5ede0,stroke:#8b1538,color:#8b1538

15.2Cron 调度:jobs.py + scheduler.py

15.2.1 Job 持久化结构

Cron 的"job 数据库"是一个 JSON 文件 ~/.hermes/cron/jobs.json。粗暴但有效。看 job schema(来自 create_job()):

cron/jobs.py:654 (节选)
job = {
    "id": job_id,                       # UUID hex[:12], FS-safe
    "name": name or label_source[:50],
    "prompt": prompt_text,               # 给 Agent 的指令
    "skills": normalized_skills,         # 要加载的 skill
    "model": normalized_model,           # 单 job 模型覆盖(可空)
    "provider": normalized_provider,
    "base_url": normalized_base_url,
    "script": normalized_script,         # 预脚本路径 ~/.hermes/scripts/
    "no_agent": normalized_no_agent,     # True = 跳过 LLM,直接跑脚本
    "context_from": context_from,       # 链式上游 job IDs
    "schedule": parsed_schedule,         # {kind, minutes, expr, run_at}
    "repeat": {"times": repeat, "completed": 0},
    "enabled": True,
    "state": "scheduled",             # scheduled|paused|completed|error
    "next_run_at": compute_next_run(parsed_schedule),
    "last_run_at": None,
    "deliver": deliver,                  # local|origin|telegram:xxx
    "origin": origin,                    # {platform, chat_id, thread_id}
    "enabled_toolsets": normalized_toolsets,
    "workdir": normalized_workdir,       # 项目根(AGENTS.md/CLAUDE.md 注入)
    "profile": normalized_profile,       # Hermes profile 隔离
}

持久化用 tempfile + atomic rename + fsync,owner-only 权限(0600):

cron/jobs.py:455-465
def save_jobs(jobs):
    ensure_dirs()
    fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent),
                                     suffix='.tmp', prefix='.jobs_')
    try:
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f)
            f.flush()
            os.fsync(f.fileno())            # 强制落盘
        atomic_replace(tmp_path, JOBS_FILE)   # 原子 rename
        _secure_file(JOBS_FILE)               # chmod 0600
为什么不用 SQLite Cron 状态非常小(几十到几百个 job),频率低(查询间隔 60 秒)。JSON + 锁简单可读、 可手动编辑、可 grep。SQLite 对这种用例反而过重。对的工具用对的地方

15.2.2 调度解析:四种格式统一

cron/jobs.py:209 (parse_schedule)
"30m"                     30 分钟后一次   (kind="once", run_at=now+30m)
"every 30m"               每 30 分钟重复  (kind="interval", minutes=30)
"0 9 * * *"               标准 cron 表达式 (kind="cron", expr="0 9 * * *")
"every 2h"                每 2 小时       (kind="interval", minutes=120)
"2026-02-03T14:00:00"     一次性绝对时刻   (kind="once", run_at=ISO_str)

"interval" 和 "every Xm" 是同一回事的两种说法——Hermes 都接受,内部统一成 minutes。 "cron" 用 croniter 库解析。所有格式都汇成 dict,后续 compute_next_run() 统一处理。

15.2.3 tick 循环 + 文件锁

Cron 没有独立 daemon ——它在每次 Hermes Gateway 进程的事件循环里每 60 秒检查一次。 但如果用户同时跑了 CLI 和 Gateway,两边都想 tick 怎么办? 文件锁:

cron/scheduler.py:1843 (tick 入口)
def tick(verbose=True, adapters=None, loop=None) -> int:
    """检查并跑所有到期 job。用文件锁,只允许一个 tick 同时跑。"""
    lock_dir, lock_file = _get_lock_paths()
    lock_dir.mkdir(parents=True, exist_ok=True)

    lock_fd = None
    try:
        lock_fd = open(lock_file, "w", encoding="utf-8")
        if fcntl:
            fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)   # 非阻塞
        elif msvcrt:
            msvcrt.locking(lock_fd.fileno(), msvcrt.LK_NBLCK, 1)
    except (OSError, IOError):
        logger.debug("Tick skipped — 另一实例持锁")
        if lock_fd is not None:
            lock_fd.close()
        return 0

    try:
        due_jobs = get_due_jobs()
        ...

设计要点:

15.2.4 catchup window:120s–2h clamp

问题:Hermes 重启,中间错过了 6 小时的"每 5 分钟一次"任务。要补跑 72 次吗?显然不要。

Hermes 的策略:用 grace window 决定补跑还是 fast-forward

cron/jobs.py:344-373
def _compute_grace_seconds(schedule: dict) -> int:
    """算出一个 job 最多可以晚多少秒还允许 catchup,
    超出就 fast-forward 到下一次。

    用周期的一半,clamp 在 [120s, 7200s] 区间。
    每日 job → 12h//2 = 6h → 被 clamp 到 2h
    每小时 job → 30 分钟
    每 5 分钟 job → 2.5 分钟 → 被 clamp 到 120s
    """
    MIN_GRACE = 120
    MAX_GRACE = 7200   # 2 hours

    kind = schedule.get("kind")

    if kind == "interval":
        period_seconds = schedule.get("minutes", 1) * 60
        grace = period_seconds // 2
        return max(MIN_GRACE, min(grace, MAX_GRACE))

    if kind == "cron" and HAS_CRONITER:
        try:
            now = _hermes_now()
            cron = croniter(schedule["expr"], now)
            first = cron.get_next(datetime)
            second = cron.get_next(datetime)
            period_seconds = int((second - first).total_seconds())
            grace = period_seconds // 2
            return max(MIN_GRACE, min(grace, MAX_GRACE))
        except Exception:
            pass

    return MIN_GRACE

_get_due_jobs_locked() 里应用:

cron/jobs.py:1065-1085
if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace:
    # 错过 grace window — 这是"陈旧"任务,跳过本次,推到下一次
    new_next = compute_next_run(schedule, now.isoformat())
    if new_next:
        logger.info(
            "Job '%s' missed scheduled time (%s, grace=%ds). "
            "Fast-forwarding to next run: %s",
            job.get("name", job["id"]), next_run, grace, new_next,
        )
        for rj in raw_jobs:
            if rj["id"] == job["id"]:
                rj["next_run_at"] = new_next
                needs_save = True
                break
        continue   # 跳过本次
心智模型 Catchup 不是"全部补跑"也不是"全部丢弃"——是"晚一点还行,晚太多就算了"。 Daily 任务晚 1 小时补;晚 3 小时跳。这是对生产 Agent 最友好的折中。

15.2.5 Inactivity Timeout:基于"活动",不是 wall-clock

看 AGENTS.md 里说"3 分钟硬中断"。但读源码发现真实实现是 600 秒 inactivity-based 超时:

cron/scheduler.py:1644-1730 (节选)
# Job 可以跑几小时,只要它在调工具或收 stream tokens;
# 但如果 600 秒没活动(API 卡住或工具死循环),就杀。
# 默认 600s;HERMES_CRON_TIMEOUT 覆盖;0 = 无限。
_cron_timeout = 600.0
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
_POLL_INTERVAL = 5.0
_cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)

_cron_context = contextvars.copy_context()
_cron_future = _cron_pool.submit(_cron_context.run, agent.run_conversation, prompt)
_inactivity_timeout = False

try:
    while True:
        done, _ = concurrent.futures.wait({_cron_future}, timeout=_POLL_INTERVAL)
        if done:
            result = _cron_future.result()
            break
        # 还在跑 — 检查 inactivity
        _idle_secs = 0.0
        if hasattr(agent, "get_activity_summary"):
            _act = agent.get_activity_summary()
            _idle_secs = _act.get("seconds_since_activity", 0.0)
        if _idle_secs >= _cron_inactivity_limit:
            _inactivity_timeout = True
            break

if _inactivity_timeout:
    _activity = agent.get_activity_summary()
    logger.error(
        "Job '%s' idle %.0fs (limit %.0fs) | last=%s | iter=%s/%s | tool=%s",
        job_name, _activity["seconds_since_activity"], _cron_inactivity_limit,
        _activity["last_activity_desc"], _activity["api_call_count"],
        _activity["max_iterations"], _activity.get("current_tool") or "none",
    )
    agent.interrupt("Cron job timed out (inactivity)")
    raise TimeoutError(...)
"3 分钟"是过时的文档 AGENTS.md 第 805 行讲的"3-minute hard interrupt"早期版本是。 现在的实现是 600s inactivity,完全不一样的语义。 读文档时永远 cross-check 代码——这是文档 rot 的典型案例。

15.2.6 Cron-spawned AIAgent 的差异

cron/scheduler.py:1611-1642 构造 cron 用的 AIAgent:

cron/scheduler.py:1611-1642 (关键字段)
agent = AIAgent(
    model=model,
    max_iterations=max_iterations,
    enabled_toolsets=_resolve_cron_enabled_toolsets(job, _cfg),
    disabled_toolsets=_resolve_cron_disabled_toolsets(_cfg),
    quiet_mode=True,                          # 不打印 banner
    skip_context_files=not bool(_job_workdir),  # 无 workdir 就不自动找 AGENTS.md
    load_soul_identity=True,                   # 但 SOUL.md 总是加载
    skip_memory=True,                          # 关键!不污染主 memory
    platform="cron",                            # 平台标签
    session_id=_cron_session_id,                  # 独立 session
    session_db=_session_db,
)

三个非显然的设计:

  1. skip_memory=True:不 load Mem0/Supermemory 等持久 memory。 理由——cron job 是"自动跑",输出不该污染人类用户的 memory layer,反过来 memory 里的 上下文也不该影响 job 的客观执行。
  2. disabled_toolsets 默认含 ["cronjob", "messaging", "clarify"]。 cron job 不能创建新 cron job(防递归),不能 send_message(模糊责任),不能 clarify(无人答)。
  3. platform="cron":工具集筛选时可以针对 cron 应用特殊限制 (例如某些工具只在 cli 模式有意义)。

15.3cronjob 工具:让 Agent 自己排课

这是 LLM-facing 的工具。Agent 在跑过程中可以决定"这事 1 小时后再做"。

15.3.1 9 个 actions

tools/cronjob_tools.py (schema 节选)
"action": {
    "enum": [
        "create", "list", "show", "update", "remove",
        "pause", "resume", "run", "run_now", "trigger",
    ]
}

所有 cron 操作收敛在一个工具里——schema 里的 description 把所有动词解释清楚。 LLM 选 action,余下参数按需带。比给 10 个独立工具紧凑。

15.3.2 Prompt Injection 扫描——16 类 pattern

因为 cron job 的 prompt 是"将来某个时刻自动执行"的,一次创建潜在跑 N 次。 所以创建时必须扫:

tools/cronjob_tools.py:43-140 (扫描器)
_CRON_THREAT_PATTERNS = [
    (r'ignore\s+(?:\w+\s+)*(?:previous|all|above|prior)\s+(?:\w+\s+)*instructions', "prompt_injection"),
    (r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
    (r'system\s+prompt\s+override', "sys_prompt_override"),
    (r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
    (r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
    (r'authorized_keys', "ssh_backdoor"),
    (r'/etc/sudoers|visudo', "sudoers_mod"),
    (r'rm\s+-rf\s+/', "destructive_root_rm"),
]
_CRON_SECRET_VAR_RE = r'\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)\w*\}?'
_CRON_EXFIL_COMMAND_PATTERNS = [
    (rf'curl\s+[^\n]*https?://[^\s"\'`]*{_CRON_SECRET_VAR_RE}', "exfil_curl_url"),
    (rf'wget\s+[^\n]*https?://[^\s"\'`]*{_CRON_SECRET_VAR_RE}', "exfil_wget_url"),
    ...
]

def _scan_cron_prompt(prompt: str) -> str:
    # GitHub API token 是合法的(自带 skill 用)
    github_auth_header = re.search(r'curl\s+...token\s+...api.github.com', prompt, re.IGNORECASE)
    prompt_to_scan = prompt.replace(...) if github_auth_header else prompt

    # 检查不可见 Unicode
    for char in _CRON_INVISIBLE_CHARS:
        if char in _strip_legitimate_emoji_zwj(prompt_to_scan):
            return f"Blocked: invisible unicode U+{ord(char):04X}"

    # 威胁模式
    for pattern, pid in _CRON_THREAT_PATTERNS:
        if re.search(pattern, prompt_to_scan, re.IGNORECASE):
            return f"Blocked: threat pattern '{pid}'"

    # 数据泄露模式
    for pattern, pid in _CRON_EXFIL_COMMAND_PATTERNS:
        if re.search(pattern, prompt_to_scan, re.IGNORECASE):
            return f"Blocked: threat pattern '{pid}'"

    return ""

三类威胁:

  1. Prompt override:"ignore previous instructions" 等典型 indirect injection 范式。
  2. 系统破坏:rm -rf /、改 sudoers、改 authorized_keys。
  3. 数据泄露:curl + 凭证 env var → 外部 URL(典型 token exfiltration)。 但 GitHub API token + api.github.com 是合法 skill 用例,白名单。

这是第 7 章 Capability Security 的纵深——即使工具能调,prompt 本身也得过扫描。

15.3.3 no_agent=True:Watchdog 模式

有些任务根本不需要 LLM——比如"每 5 分钟检查 GPU 温度,>80 度报警"。no_agent=True:

cronjob(
    action="create",
    schedule="every 5m",
    no_agent=True,
    script="gpu_watchdog.sh",         # 路径相对 ~/.hermes/scripts/
    deliver="origin",                # 输出回到我手机
)

"delivery 语义" 写得很清楚:

Watchdog 模式的智慧 这种"成功无输出 = 静默"的契约是 Unix 哲学。一个监控脚本如果没事就吵, 早晚被用户静音;静音后真出事也看不见。"沉默 = 一切正常"是健康的合约。 Hermes 把这个原则做进了 cron 工具的 delivery 语义。

15.4Batch Runner:并行轨迹生成

批量 prompt → 批量 trajectories(给 SFT 用)。1321 行,用 multiprocessing.Pool:

batch_runner.py (核心)
from multiprocessing import Pool, Lock

_WORKER_CONFIG = {}    # 给 worker 进程的全局配置

def _process_single_prompt(prompt_index, prompt_data, batch_num, config):
    """每个 worker 进程处理一条 prompt"""
    prompt = prompt_data["prompt"]
    task_id = f"task_{prompt_index}"

    # Per-prompt container image override
    container_image = prompt_data.get("image") or prompt_data.get("docker_image")
    if container_image:
        env_type = os.getenv("TERMINAL_ENV", "local")
        if env_type == "docker":
            # 提前验证镜像可用,免得花了 token 再发现镜像不在
            probe = subprocess.run(["docker", "image", "inspect", container_image],
                                   capture_output=True, timeout=10)
            if probe.returncode != 0:
                pull = subprocess.run(["docker", "pull", container_image],
                                      capture_output=True, timeout=600)
                if pull.returncode != 0:
                    return {"success": False, "error": f"Docker image not available: ..."}

关键设计:

  1. multiprocessing 而非 threading:每个进程独立 Python GIL,真并行。LLM API call 是 IO bound 但 token 处理是 CPU bound。
  2. 每 prompt 可指定 docker image:不同任务可用不同环境(Python 3.11、Node 20、自定义)。Hermes 内部还支持 Modal/Singularity/Daytona 多种 sandbox 后端,通过 register_task_env_overrides() 注入。
  3. 提前 docker inspect 验镜像:不在镜像可用前花 token——这是节省费用的关键防御。

15.4.1 Tool Stats 提取

训练数据需要标注"每条轨迹用了哪些工具,各调几次,成功率多少"。Hermes 用 5 步状态机提取:

batch_runner.py:125-205
def _extract_tool_stats(messages) -> Dict[str, Dict[str, int]]:
    tool_stats = {}
    tool_calls_map = {}   # tool_call_id → tool_name

    for msg in messages:
        if msg["role"] == "assistant" and msg.get("tool_calls"):
            for tool_call in msg["tool_calls"]:
                tool_name = tool_call["function"]["name"]
                tool_call_id = tool_call["id"]
                if tool_name not in tool_stats:
                    tool_stats[tool_name] = {"count": 0, "success": 0, "failure": 0}
                tool_stats[tool_name]["count"] += 1
                tool_calls_map[tool_call_id] = tool_name

        elif msg["role"] == "tool":
            # 解析 tool result 判成功/失败
            content_json = json.loads(msg["content"]) if isinstance(msg["content"], str) else ...
            is_success = True
            if isinstance(content_json, dict) and content_json.get("error") is not None:
                is_success = False
            if tool_call_id in tool_calls_map:
                tool_name = tool_calls_map[tool_call_id]
                tool_stats[tool_name]["success" if is_success else "failure"] += 1

    return tool_stats

注意"成功"的判定标准:工具返回 JSON 且 error 字段为 None 才算成功。如果 error: nullerror 字段不存在都算成功——这是故意宽松的,因为很多工具不显式标 success。

15.4.2 输出格式

{
    "success": True,
    "prompt_index": i,
    "trajectory": [{from, value}, ...],   # ShareGPT 格式
    "tool_stats": {tool_name: {count, success, failure}},
    "reasoning_stats": {turns_with_reasoning, ...},
    "completed": True,                    # 模型自然结束?
    "partial": False,                     # 被中断?
    "api_calls": 12,                       # 模型调用次数
    "toolsets_used": ["web", "file", ...],
    "metadata": {batch_num, timestamp, model}
}

每条 prompt 一个独立 result dict,可以分批写盘、可以恢复。--resume 标志通过对比已有 batch 输出文件和 dataset 决定从哪里继续。

15.5Trajectory Compressor:用 LLM 压 LLM

大 model 跑出来的 trajectory 动辄 30K-100K tokens。但 SFT 训练时,大多 LLM 训练框架要求每条样本 <= 16K tokens。怎么压?

方案:保护头尾,LLM 总结中段。这就是 trajectory_compressor.py

15.5.1 配置

trajectory_compressor.py:82-179
@dataclass
class CompressionConfig:
    # Tokenizer
    tokenizer_name: str = "moonshotai/Kimi-K2-Thinking"

    # 压缩目标
    target_max_tokens: int = 15250      # 压完上限
    summary_target_tokens: int = 750    # 总结约多长

    # 保护层
    protect_first_system: bool = True   # 首 system
    protect_first_human: bool = True    # 首 user 输入
    protect_first_gpt: bool = True      # 首 assistant 响应
    protect_first_tool: bool = True     # 首 tool 结果
    protect_last_n_turns: int = 4      # 最后 4 轮

    # 总结用 OpenRouter Gemini Flash(便宜)
    summarization_model: str = "google/gemini-3-flash-preview"
    api_key_env: str = "OPENROUTER_API_KEY"
    temperature: float = 0.3

    add_summary_notice: bool = True
    summary_notice_text: str = "\n\nSome of your previous tool responses may be summarized to preserve context."

15.5.2 算法

flowchart TB
  In["输入轨迹
(50 turns, 32K tokens)"] --> Count["数 token 数"] Count --> Cmp{"总 token
≤ 15.25K?"} Cmp -->|是| Skip["不压"] Cmp -->|否| Find["定位可压区间
(头尾保护之间)"] Find --> Calc["算需省 tokens
= 总数 - target + summary_size"] Calc --> Accum["从前往后累加 turns
直到累计 ≥ 需省"] Accum --> Sum["送 Gemini Flash 总结"] Sum --> Build["重建:头 + 总结 + 尾"] Build --> Out["压缩后轨迹
(~15K tokens)"]
trajectory_compressor.py:709-827 (核心)
def compress_trajectory(self, trajectory):
    metrics = TrajectoryMetrics()
    metrics.original_turns = len(trajectory)

    turn_tokens = self.count_turn_tokens(trajectory)
    total_tokens = sum(turn_tokens)
    metrics.original_tokens = total_tokens

    if total_tokens <= self.config.target_max_tokens:
        metrics.skipped_under_target = True
        return trajectory, metrics

    # 找保护区:头部 4 个 + 尾部 4 个
    protected, compress_start, compress_end = self._find_protected_indices(trajectory)

    # 算要省多少 tokens
    tokens_to_save = total_tokens - self.config.target_max_tokens
    target_tokens_to_compress = tokens_to_save + self.config.summary_target_tokens

    # 从前往后累加,累到够省即止
    accumulated_tokens = 0
    compress_until = compress_start
    for i in range(compress_start, compress_end):
        accumulated_tokens += turn_tokens[i]
        compress_until = i + 1
        if accumulated_tokens >= target_tokens_to_compress:
            break

    # 提取要压的内容
    content_to_summarize = self._extract_turn_content_for_summary(
        trajectory, compress_start, compress_until)

    # 用 LLM 总结(这里是真调 API)
    summary = self._generate_summary(content_to_summarize, metrics)

    # 重建:头 + 总结 + 尾
    compressed = []
    for i in range(compress_start):
        turn = trajectory[i].copy()
        if turn.get("from") == "system" and self.config.add_summary_notice:
            turn["value"] = turn["value"] + self.config.summary_notice_text
        compressed.append(turn)

    compressed.append({"from": "human", "value": summary})

    for i in range(compress_until, len(trajectory)):
        compressed.append(trajectory[i].copy())

    return compressed, metrics

设计精髓:

15.6Mini SWE Runner:Hermes 内置 SWE-Bench

SWE-Bench 评测需要在隔离环境里跑、捕获 patch、跑测试、记 pass/fail。mini_swe_runner.py(735 行)是 Hermes 自己的简化实现。

15.6.1 任务格式

# 单任务模式
python mini_swe_runner.py --task "Create a hello world Python script" --env local

# 批量模式(JSONL)
python mini_swe_runner.py --tasks tasks.jsonl --env docker

# tasks.jsonl 每行:
{"task": "...", "env": "docker", "image": "python:3.11-slim"}

15.6.2 环境抽象

mini_swe_runner.py:121-150
def create_environment(env_type="local", image="python:3.11-slim",
                       cwd="/tmp", timeout=60, **kwargs):
    if env_type == "local":
        from tools.environments.local import LocalEnvironment
        return LocalEnvironment(cwd=cwd, timeout=timeout)
    elif env_type == "docker":
        from tools.environments.docker import DockerEnvironment
        return DockerEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs)
    elif env_type == "modal":
        from tools.environments.modal import ModalEnvironment
        ...

三种沙箱后端:local(开发用)、docker(评测主力)、modal(serverless)。 每个后端都提供 execute(cmd) + cleanup() 接口——典型的策略模式。

15.6.3 完成标记

怎么知道 Agent 觉得任务完成了?用哨兵字符串:

mini_swe_runner.py:97
# When task is complete, output: echo "MINI_SWE_AGENT_FINAL_OUTPUT" followed by your result

Agent 跑 echo "MINI_SWE_AGENT_FINAL_OUTPUT\n<result>" 时,runner 监听 terminal stdout, 看到这个 token 就知道结束。这种"约定优于实现"的方法比让 Agent 调一个 finish_task 工具简单很多—— 不用 schema,prompt 里说一句就行。

15.7设计原则提炼

5 个子系统看下来,提炼出后台执行的 6 条工程原则:

  1. 持久化用最简结构。Cron job 用 JSON + tempfile + atomic rename + fsync + chmod 0600, 不上 SQLite。规模小、可读、可手改、可 grep。
  2. 锁但别等。Cron tick 用 LOCK_NB 非阻塞——抢不到立刻让出,下一轮再来。 避免雪崩。
  3. Catchup 要有"放弃"机制。Grace window 决定"补"还是"跳"。重启后不该补 1000 次"每 5 分钟"。
  4. 超时基于活动,不是 wall-clock。慢 LLM call 是正常的;没有活动才是异常。 get_activity_summary() 是核心 instrumentation。
  5. 子任务的 toolset 默认窄。Cron job 不能创建 cron job(防递归),不能 send_message(模糊责任), 不能 clarify(无人答)。能力受限优于约束 prompt(第 7 章主题)。
  6. 能在花 token 之前验证的,先验证。Docker pull 失败比花了一百次 LLM 调用才发现镜像没拉成本低 1000 倍。
回想 ch13 提到的"长程任务可靠性" METR HCAST 数据显示 Agent 4 小时任务成功率只有 20%。Hermes 后台执行栈的 设计本质都是"延长可靠 timeline"的工程努力。锁、catchup clamp、inactivity timeout、 能力限制——每一条都是"让 Agent 走完一天不偏离"路上的具体砖。

章末练习

  1. Easy Cron tick 的"先 advance_next_run 再执行"设计保证了什么不变量? 用 50 字描述如果反过来(先执行再 advance)会出什么 bug。
  2. Easy 为什么 cron-spawned AIAgent 要 skip_memory=True? 列出 2 个具体反面例子(不 skip 会出什么问题)。
  3. Medium Catchup grace window 是周期的 1/2,clamp 到 [120s, 2h]。 给一个"每周一上午 9 点"的 job 算出 grace 是多少。 如果服务器周日整天宕,周一中午才起,这 job 会跑吗?
  4. Medium Trajectory Compressor 用 Gemini Flash 总结。但如果生成训练数据时用 Claude Opus, 压缩时用 Gemini——会不会引入"模型风格差异"污染训练?讨论怎么权衡。
  5. Hard Cron prompt injection 扫描器有 16 类 pattern + 1 个白名单(GitHub API)。 设计一个能绕过扫描器的 prompt(纯思想实验,不要真的运行), 然后讨论怎么改 scanner 防御它。
  6. Hard Mini SWE Runner 用"echo MINI_SWE_AGENT_FINAL_OUTPUT 标记结束"而不是工具。 这种"约定式"方案有什么失败模式?设计一个"模型本想输出 final 但因 token 被截"的场景。