Chapter 16

多 Agent 协作的工程化

从 ch07 的 delegate_task 介绍走向真正能扛长任务的多 Agent 系统。 SQLite WAL + CAS 的原子认领、失败熔断、文件状态协调、心跳续期—— 这些把"并发跑 N 个子 agent"的玩具升级成生产级机器。

本章约 10,500 字 阅读 ~35 分钟 关键词:delegate_task · Kanban · WAL · CAS · 失败熔断 · 文件状态

ch07 我们简短介绍了 delegate_task ——父 Agent 派子 Agent 这个 orchestrator-workers 模式。 这一章我们走"生产真要用"的深度:子 Agent 怎么共享父预算又不互相打架?并发 5 个 worker 时怎么防止两个抢同一个任务?子 Agent 改了文件父 Agent 怎么知道要重读?跑了一周的看板里 某个任务连续失败怎么自动停止试?这一章拆 delegate_tool.py 1900 行 + 整个 Kanban 子系统, 并讲清楚 Hermes 在每一处偏离"天真 spawn N children"模式的工程理由。

16.1两套机制的分工:同步 vs 异步

Hermes 有两套多 Agent 机制,解决不同问题:

delegate_taskKanban
调用方式父 Agent 调工具,等返回排进 SQLite,异步认领
生命周期父 turn 内同步阻塞跨进程、跨重启、跨用户
典型用例"切 5 段文档并行总结""为 50 个 GitHub issue 各跑一个修复"
失败处理父收到 error dict 继续自动熔断,N 次失败后阻塞
持久化无(进程结束子也结束)SQLite WAL 完整

记忆口诀:delegate_task = 函数调用,Kanban = 任务队列。本章先讲前者,再展开后者。

16.2delegate_task:单 / 批两种形态

16.2.1 入口签名

tools/delegate_tool.py:1918-1942
def delegate_task(
    goal: Optional[str] = None,
    context: Optional[str] = None,
    toolsets: Optional[List[str]] = None,
    tasks: Optional[List[Dict[str, Any]]] = None,    # 批量模式
    max_iterations: Optional[int] = None,
    acp_command: Optional[str] = None,
    acp_args: Optional[List[str]] = None,
    role: Optional[str] = None,                      # 'leaf' | 'orchestrator'
    parent_agent=None,
) -> str:
    """派一个或多个子 Agent。
    Single 模式:goal (+ 可选 context/toolsets/role)
    Batch 模式:tasks 数组 [{goal, context, toolsets, role}, ...]
    """
    if parent_agent is None:
        return tool_error("delegate_task requires a parent agent context.")

16.2.2 Batch 模式的并发模型

关键决定:Hermes 不用 asyncio,用 ThreadPoolExecutor。每个子 Agent 一个独立线程:

tools/delegate_tool.py:1492-1512
_timeout_executor = ThreadPoolExecutor(
    max_workers=1,
    initializer=_set_subagent_approval_cb,
    initargs=(_get_subagent_approval_callback(),),
)

def _run_with_thread_capture():
    _worker_thread_holder["t"] = threading.current_thread()
    return child.run_conversation(user_message=goal, task_id=child_task_id)

_child_future = _timeout_executor.submit(_run_with_thread_capture)
try:
    result = _child_future.result(timeout=child_timeout)

注意 max_workers=1每个子 Agent 自己的 executor,不是共享池。 Batch 模式下,父 Agent 在自己线程里循环 submit N 个独立 executor。这样:

16.2.3 Role:leaf vs orchestrator,深度有上限

Role 决定子 Agent 能不能再派:

role能调 delegate_task?typical 用途
"leaf"(默认)否(toolset 里被剥)聚焦 worker
"orchestrator"是,但深度受限分层任务分解

深度上限由两道闸守护:

tools/delegate_tool.py:394-429 (深度配置)
def _get_max_spawn_depth():
    val = cfg.get("delegation", {}).get("max_spawn_depth", _DEFAULT_MAX_SPAWN_DEPTH)
    ival = int(val)
    clamped = max(_MIN_SPAWN_DEPTH, min(_MAX_SPAWN_DEPTH_CAP, ival))
    # _MIN_SPAWN_DEPTH = 1 (默认 flat)
    # _MAX_SPAWN_DEPTH_CAP = 3 (硬天花板)
    if clamped != ival:
        logger.warning("clamping max_spawn_depth %d → %d", ival, clamped)
    return clamped
tools/delegate_tool.py (子 Agent 构造,~line 1050)
# 父深度 + 1 = 子深度。子深度 ≥ 上限就强制 degrade 到 leaf
child_depth = parent_depth + 1
if child_depth >= max_spawn_depth:
    effective_role = "leaf"     # 沉默 downgrade

即使用户传 role="orchestrator",只要达到深度上限,Hermes 会静默把它降级为 leaf 并剥掉 delegation 工具。这防止意外的"orchestrator 派 orchestrator 派 orchestrator..."递归。

设计选择 Static cap=3 是"不信用户传对"的纵深防御。Anthropic Building Effective Agents 里反复强调"agent 复杂度增加时调试难度指数增长"——所以 Hermes 直接堵死大于 3 层的可能。 生产里 99% 任务一层 orchestrator + N 个 leaf 就够。

16.3子 Agent 的上下文隔离

这是 delegate_task 设计里最有价值的部分:子 Agent 从父继承什么,reset 什么。

16.3.1 构造时的 propagate 与 reset

tools/delegate_tool.py:1106-1138
child = AIAgent(
    # ── 从父继承(避免重复配置) ──────
    base_url=effective_base_url,
    api_key=effective_api_key,
    model=effective_model,
    provider=effective_provider,
    api_mode=effective_api_mode,
    max_iterations=max_iterations,
    max_tokens=getattr(parent_agent, "max_tokens", None),
    reasoning_config=child_reasoning,
    prefill_messages=getattr(parent_agent, "prefill_messages", None),
    fallback_model=parent_fallback,
    platform=parent_agent.platform,                  # cli/tui/gateway 透传
    session_db=getattr(parent_agent, "_session_db", None),
    parent_session_id=getattr(parent_agent, "session_id", None),

    # ── 强制 重置(隔离子 Agent) ────────
    enabled_toolsets=child_toolsets,                 # 收窄(无 delegation 等)
    quiet_mode=True,                              # 子不打印
    ephemeral_system_prompt=child_prompt,            # 全新 system prompt
    log_prefix=f"[subagent-{task_index}]",
    skip_context_files=True,                      # 不读 AGENTS.md/CLAUDE.md
    skip_memory=True,                              # 不 load 持久 memory
    clarify_callback=None,                         # 不能问用户
    iteration_budget=None,                         # 全新 budget(下文有重要细节)
)

三类关键 reset:

  1. System prompt 完全重写ephemeral_system_prompt=child_prompt 是基于 goal 现场生成的, 不带任何父 Agent 的身份/项目 context。
  2. skip_memory=True + skip_context_files=True。子 Agent 是 stateless worker, 不该看见父见过的 memory 或项目文件。
  3. iteration_budget=None新的预算——子的 max_iterations 是从父 propagate 来的, 但运行过程中子和父是独立计数的。

16.3.2 _last_resolved_tool_names:进程级 global 的 save/restore

这是一个非常微妙的设计。model_tools._last_resolved_tool_names 是个全局 list, 存"当前 LLM session 看到哪些工具"。某些工具(如 execute_code 的 sandbox 工具集)用它决定生成哪些 sub-tools。

问题:子 Agent 的 toolset 比父窄(delegation 被剥)。子启动后会把这个 global 改成自己窄的集合。 如果子跑完后不 restore,父就永久失去了它本来有的工具。

解法是"finally restore":

tools/delegate_tool.py:1337-1343, 1863-1869
# 构造前快照
_saved_tool_names = getattr(child, "_delegate_saved_tool_names",
                              list(model_tools._last_resolved_tool_names))

# 子 Agent 启动会把 global 改成它自己的窄集合 ...
child = AIAgent(..., enabled_toolsets=child_toolsets, ...)

# 子跑完后,finally 块里 restore
saved_tool_names = getattr(child, "_delegate_saved_tool_names", None)
if isinstance(saved_tool_names, list):
    model_tools._last_resolved_tool_names = list(saved_tool_names)
为什么不去掉 global? 这是"工程妥协"。理想做法是工具上下文用 ContextVar 而非进程 global。 但 Hermes 早期某些工具(execute_code 的 sandbox 检测)依赖同步读 global, 改 ContextVar 涉及大量 refactor。所以 save/restore 是实用主义补丁。 Hermes AGENTS.md 第 964 行有专门警告:"_last_resolved_tool_names is a process-global"。

16.4子 Agent 的失败处理

16.4.1 三种失败:timeout、exception、interrupt

Hermes 对每种失败都返回 dict 而不是 raise:

tools/delegate_tool.py:1818-1840
except Exception as exc:
    duration = round(time.monotonic() - child_start, 2)
    logging.exception(f"[subagent-{task_index}] failed")
    if child_progress_cb:
        try:
            child_progress_cb("subagent.complete", ...)
        except Exception as e:
            logger.debug("Progress callback failure relay failed: %s", e)
    return {
        "task_index": task_index,
        "status": "error",
        "summary": None,
        "error": str(exc),
        "api_calls": 0,
        "duration_seconds": duration,
        "_child_role": getattr(child, "_delegate_role", None),
    }

关键设计:batch 模式下,父 Agent 收到 dict 而不是抛出。这样 5 个 worker 中 1 个挂了, 其他 4 个的成功结果仍然被收集。

16.4.2 0-API-call 超时诊断

最难调的 bug 是"子 Agent 还没调一次 LLM 就 timeout"——可能是 toolset 检测卡死、 某个 check_fn 阻塞、import 慢。Hermes 在这种情况下专门写诊断文件:

tools/delegate_tool.py:1544-1558 (诊断)
if is_timeout and child_api_calls == 0:
    # 0 API call 还 timeout — 不是 LLM 慢,是别的东西卡了
    diagnostic_path = log_dir / f"subagent-timeout-{task_index}-{ts}.log"
    with open(diagnostic_path, "w") as f:
        f.write(f"system_prompt_size: {len(child._system_prompt or '')}\n")
        f.write(f"toolset_count: {len(child.tools or [])}\n")
        f.write(f"activity_summary: {child.get_activity_summary()}\n")
        # 关键:worker 线程的 Python stack trace
        f.write("thread_stack:\n")
        if _worker_thread_holder.get("t"):
            frames = sys._current_frames().get(
                _worker_thread_holder["t"].ident)
            if frames:
                traceback.print_stack(frames, file=f)

这种诊断是事后无法重现的 bug的救命稻草。运维看到诊断文件就知道是哪行 Python 卡了。

16.4.3 subagent_auto_approve:默认拒,opt-in 允

子 Agent 调危险命令(如 rm -rf)时,审批 callback 走哪?默认"拒":

tools/delegate_tool.py:73-111
def _subagent_auto_deny(command, description, **kwargs) -> str:
    logger.warning("Subagent auto-denied: %s (%s)", command, description)
    return "deny"

def _subagent_auto_approve(command, description, **kwargs) -> str:
    logger.warning("Subagent auto-approved: %s (%s)", command, description)
    return "once"

def _get_subagent_approval_callback():
    cfg = _load_config()
    val = cfg.get("subagent_auto_approve", False)
    if is_truthy_value(val):
        return _subagent_auto_approve
    return _subagent_auto_deny

原因:子 Agent 跑在父线程下,父的 prompt_toolkit TUI 是阻塞的。子调 input() 会死锁。 所以必须自动决定。默认 deny 是"安全永远优先"。用户要 YOLO 就 opt-in。 两种选择都记日志——审计可见。

16.5文件状态协调:Hermes 的非显然亮点

多个子 Agent 并发读写文件时,有个微妙问题:

父 Agent 11:00 读了 foo.py(看 OOP 类结构)→ 派子 Agent → 子 11:05 写了 foo.py (改了一个 method)→ 父 Agent 11:10 想编辑 foo.py(但记得的是 11:00 那版)→ 修改基于过期内容

这种问题静默发生,容易出错。Hermes 用 file_state 模块跟踪:

tools/delegate_tool.py:1476-1487, 1722-1754
# 派子前快照:父之前读过的文件列表
parent_reads_snapshot = (
    list(file_state.known_reads(parent_task_id)) if parent_task_id else []
)

# ... 子 Agent 跑 ...

# 子跑完,检查"子有没有写父读过的文件"
if parent_task_id and parent_reads_snapshot:
    sibling_writes = file_state.writes_since(
        parent_task_id, wall_start, parent_reads_snapshot)
    if sibling_writes:
        mod_paths = sorted({p for paths in sibling_writes.values() for p in paths})
        reminder = (
            "\n\n[NOTE: subagent modified files the parent previously read — "
            "re-read before editing: " + ", ".join(mod_paths[:8]) + ...
        )
        if entry.get("summary"):
            entry["summary"] = entry["summary"] + reminder

子 Agent 的 return summary 里追加一条"嗨爹,你之前读过 foo.py,我刚改了它,你要继续编辑就先 re-read"。 这是 LLM-friendly 的诊断信息——下一轮父就会看到这段并自动 read_file。

这是什么级别的设计? 这是"了解 LLM 怎么 fail 才能写出来"的代码。 没踩过坑的人不会想到"父 Agent 之前看的版本可能过期"。这就是 ch07 第 7.5 节"hooks 化扩展点" 的具体例子——hook 在子 Agent 结束这个边界把"文件被改"信息主动喂回给父。

16.6Kanban:跨进程、跨重启的任务队列

delegate_task 是父 turn 内同步的。但生产里很多任务跨进程跨重启:

这种场景需要持久化 + 原子认领 + 心跳 + 熔断。Hermes 用 Kanban(SQLite + WAL + CAS)。

16.6.1 SQLite 表结构

hermes_cli/kanban_db.py:811-940 (节选)
CREATE TABLE IF NOT EXISTS tasks (
    id                   TEXT PRIMARY KEY,
    title                TEXT NOT NULL,
    body                 TEXT,
    assignee             TEXT,
    status               TEXT NOT NULL,
    priority             INTEGER DEFAULT 0,
    created_at           INTEGER NOT NULL,
    started_at           INTEGER,
    completed_at         INTEGER,
    workspace_kind       TEXT NOT NULL DEFAULT 'scratch',  -- scratch|worktree|dir
    workspace_path       TEXT,
    branch_name          TEXT,
    claim_lock           TEXT,                              -- 关键 CAS 字段
    claim_expires        INTEGER,
    tenant               TEXT,                              -- 软隔离
    result               TEXT,
    idempotency_key      TEXT,                              -- 防双创建
    consecutive_failures INTEGER NOT NULL DEFAULT 0,        -- 熔断计数
    worker_pid           INTEGER,
    max_runtime_seconds  INTEGER,
    last_heartbeat_at    INTEGER,
    current_run_id       INTEGER,
    skills               TEXT,                              -- JSON array
    model_override       TEXT,
    max_retries          INTEGER                            -- per-task 失败上限覆盖
);

CREATE TABLE IF NOT EXISTS task_runs (
    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id             TEXT NOT NULL,
    profile             TEXT,
    status              TEXT NOT NULL,    -- running|done|blocked|crashed|timed_out|failed|released
    started_at          INTEGER NOT NULL,
    ended_at            INTEGER,
    outcome             TEXT,             -- completed|blocked|crashed|timed_out|spawn_failed|gave_up|reclaimed
    summary             TEXT,
    metadata            TEXT,             -- JSON: {changed_files, tests_run, findings, ...}
    error               TEXT
);

CREATE TABLE IF NOT EXISTS task_events (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    task_id    TEXT NOT NULL,
    run_id     INTEGER,
    kind       TEXT NOT NULL,
    payload    TEXT,                       -- 事件 payload(JSON)
    created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status);
CREATE INDEX IF NOT EXISTS idx_tasks_status          ON tasks(status);

三张表的分工:

16.6.2 SQLite WAL + CAS:为什么能扛并发

这是 Kanban 设计的灵魂:

Concurrency strategy: WAL mode + BEGIN IMMEDIATE for write transactions + compare-and-swap (CAS) updates on tasks.status and tasks.claim_lock. SQLite serializes writers via its WAL lock, so at most one claimer can win any given task. Losers observe zero affected rows and move on — no retry loops, no distributed-lock machinery.
kanban_db.py 文件 docstring

翻译:

  1. SQLite 开 WAL 模式 → 读不阻塞写,写不阻塞读。
  2. BEGIN IMMEDIATE → 立刻获取写锁(而非延后)。WAL 内串行化。
  3. UPDATE 时用 CAS:UPDATE tasks SET claim_lock=? WHERE id=? AND claim_lock IS NULL
  4. 影响 0 行 = 没抢到 = 别人抢了。不重试,直接放弃,看下个 task。

没有 Redis,没有 Zookeeper,没有分布式锁机制。SQLite WAL 是单点,所有 worker 必须共享同一个 DB 文件。 这对"一台机器跑 N 个 worker"完美;跨机要么 NFS 要么改用 Postgres。

flowchart LR
  W1["Worker A
(PID 1234)"] -.UPDATE WHERE claim_lock IS NULL.-> DB[(SQLite + WAL)] W2["Worker B
(PID 5678)"] -.同样 UPDATE.-> DB W3["Worker C"] -.同样 UPDATE.-> DB DB --> WinA["A 抢到
(影响 1 行)"] DB --> LoseB["B 影响 0 行
(直接放弃,看下一个)"] DB --> LoseC["C 影响 0 行
(直接放弃)"] classDef good fill:#ecf3eb,stroke:#2f5d3a,color:#2f5d3a classDef warn fill:#f5ede0,stroke:#a86420,color:#a86420 class WinA good class LoseB,LoseC warn

16.6.3 Board vs Tenant 隔离

BoardTenant
本质独立 SQLite DB + workspaces同 DB 内的一列(tenant 字段)
隔离强度硬(看不到其他 board 任务)软(可 WHERE 过滤)
典型用法不同项目(atm10-server / website / research)同项目不同子模块
路径~/.hermes/kanban/boards/<slug>/tasks.tenant 字段
解析HERMES_KANBAN_BOARD env > ~/.hermes/kanban/current 符号链接 > defaultkanban_create(tenant=...)

设计取舍:Board 给"不同上下文不能互相看见"的强隔离;Tenant 给"同一个 board 多个子项目"的方便过滤。 Worker 看不到跨 board 任务是硬保证(env 变量 + path)。

16.7Kanban Dispatcher:认领-生成-杀死循环

Dispatcher 是 daemon。dispatch_once() 做 7 件事:

hermes_cli/kanban_db.py:5088-5389 (摘要)
def dispatch_once(...):
    result = DispatchResult()

    # 1. Reap zombies(非 Windows)
    while True:
        pid, _ = os.waitpid(-1, os.WNOHANG)
        if pid == 0: break
        # 记录退出状态,区分"crash" vs "protocol violation"

    # 2. 回收 stale claim
    result.reclaimed = release_stale_claims(conn)

    # 3. 检测无心跳的 running 任务
    result.stale = detect_stale_running(conn, stale_timeout_seconds)

    # 4. 检测 worker 进程死亡
    result.crashed = detect_crashed_workers(conn)

    # 5. 强制 max_runtime
    result.timed_out = enforce_max_runtime(conn)

    # 6. todo → ready(parents 都 done 了的)
    result.promoted = recompute_ready(conn)

    # 7. 认领 + 生成
    ready_rows = conn.execute(
        "SELECT id, assignee FROM tasks "
        "WHERE status = 'ready' AND claim_lock IS NULL "
        "ORDER BY priority DESC, created_at ASC"
    ).fetchall()

    for row in ready_rows:
        if max_spawn is not None and running_count + spawned >= max_spawn:
            break
        if not row["assignee"]:
            result.skipped_unassigned.append(row["id"])
            continue
        # 重派 guard:刚失败的别立即重试
        guard_reason = check_respawn_guard(conn, row["id"])
        if guard_reason is not None:
            result.respawn_guarded.append((row["id"], guard_reason))
            continue
        # 原子认领
        claimed = claim_task(conn, row["id"], ttl_seconds=ttl_seconds)
        if claimed is None:
            continue   # 被别人抢了,正常
        # 解析 workspace + 生成进程
        workspace = resolve_workspace(claimed, board=board)
        pid = _default_spawn(claimed, str(workspace), board=board)
        if pid:
            _set_worker_pid(conn, claimed.id, int(pid))

设计要点:

16.7.1 Stale Claim Reclamation——带活性检查

Claim 有 TTL(默认 15 分钟)。过期了就回收。但"过期"不等于"worker 死了"—— 也许 worker 正卡在一个 30 分钟的 LLM call 里。所以:

hermes_cli/kanban_db.py:2516-2626
def release_stale_claims(conn):
    host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
    # 对每个 expired claim:
    for row in expired_rows:
        host_local = row["claim_lock"].startswith(host_prefix)
        if host_local and row["worker_pid"] and _pid_alive(row["worker_pid"]):
            # PID 还活着 — 延长 claim,不回收
            new_expires = now + _resolve_claim_ttl_seconds()
            _append_event(conn, row["id"], "claim_extended", {...})
            continue
        # 死了或不在本机 — 杀掉(如果还在本机) + reset 状态
        termination = _terminate_reclaimed_worker(row["worker_pid"], ...)
        # UPDATE: status='ready', claim_lock=NULL, worker_pid=NULL

这条"PID 还活着就延期"的设计是 Hermes 的"对慢 LLM 不杀"的核心保护—— 大 model + reasoning + 长 prompt 容易 30 分钟一个 round trip,不能因为这个就杀。

16.7.2 心跳机制

Worker 可以主动延期 claim,不依赖 dispatcher 的活性检查。kanban_heartbeat 工具:

tools/kanban_tools.py:552-600
def _handle_heartbeat(args, **kw):
    """Signal that worker is alive. Extends claim TTL + records event."""
    tid = _default_task_id(args.get("task_id"))
    claim_lock = os.environ.get("HERMES_KANBAN_CLAIM_LOCK")
    kb.heartbeat_claim(conn, tid, claimer=claim_lock)   # 关键:延 TTL
    ok = kb.heartbeat_worker(conn, tid, note=note, ...)
    return _ok(task_id=tid)

Agent 跑 100GB 数据 ETL 时,主动每 5 分钟调一下 kanban_heartbeat(note="processing batch 12/50"), 既延期了 claim,又留了进度记录。

16.8Worker 进程的环境注入

Dispatcher 生成 worker 时注入大量 env vars:

hermes_cli/kanban_db.py:5682-5741
env = dict(os.environ)

# Profile-scoped 配置
try:
    env["HERMES_HOME"] = resolve_profile_env(profile_arg)
except FileNotFoundError:
    pass

# 任务 context
env["HERMES_KANBAN_TASK"] = task.id
env["HERMES_KANBAN_WORKSPACE"] = workspace
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock     # 心跳需要

# 深度防御:board 路径硬绑
env["HERMES_KANBAN_DB"] = str(kanban_db_path(board=board))
env["HERMES_KANBAN_BOARD"] = resolved_board
env["HERMES_PROFILE"] = profile_arg

cmd = [
    *_resolve_hermes_argv(),
    "-p", profile_arg,
    "--accept-hooks",
    "--skills", "kanban-worker",    # 自动加载 worker skill
]
if task.model_override:
    cmd.extend(["-m", task.model_override])
cmd.extend(["chat", "-q", prompt])   # prompt = "work kanban task <id>"

proc = subprocess.Popen(
    cmd,
    cwd=workspace if os.path.isdir(workspace) else None,
    stdin=subprocess.DEVNULL,
    stdout=log_f,
    stderr=subprocess.STDOUT,
    env=env,
    start_new_session=True,             # 脱离 tty,父 Ctrl+C 不级联
)

关键设计:

  1. start_new_session=True:子进程脱离 tty——父按 Ctrl+C 时不会被级联 kill。
  2. DB 路径硬绑 env:即使 worker 的 profile 改了 HERMES_HOME,kanban 路径还是 dispatcher 那份。 防 worker 误写到错 board。
  3. kanban-worker skill 自动 load:确保 worker 知道"我是 kanban worker,该用 kanban_heartbeat 和 kanban_complete"。

16.9失败熔断:default 2 次就停

累积失败:

hermes_cli/kanban_db.py:4750-4815
if failure_limit is None:
    failure_limit = DEFAULT_FAILURE_LIMIT   # = 2
effective_limit = task.max_retries if task.max_retries is not None else failure_limit

if consecutive_failures >= effective_limit:
    # Auto-block: status = 'blocked', outcome = 'gave_up'
    _append_event(conn, task_id, "spawn_auto_blocked", {...})
    return True

默认 2 次失败就 auto-block。不是 3,不是 5——是2。原因:Hermes 设计者观察到 真实失败模式里"第二次失败就有 80% 概率是结构性问题"(missing dep、auth 问题、prompt 写错) 而不是"flaky 偶发"。所以快速熔断省 token。

遇到结构性问题该人介入而不是机器空转:Auto-block 之后,任务停在 blocked 状态, Linear/Slack notifier 推 owner 看。

16.10本章带走的

这一章是 Anthropic Harness Engineering 的具体实现 ch13 提到 Anthropic 2025 的"long-running agent harness"——两 Agent 协作 + 文件系统状态外化 + 失败恢复。 Hermes 的 delegate_task + Kanban 就是这个范式的开源具体实现。 你下次想搭"agent 跑一天不偏离"的系统时,本章这 11 条原则是必须的砖。

章末练习

  1. Easy 子 Agent 的 system prompt 完全独立于父——这跟 ch05 讲的"system prompt 不能改避免破 cache" 矛盾吗?分析两者关系。
  2. Easy 为什么 delegate_task 失败返回 dict 而不是 raise?用 batch 模式 5 个子有 1 个挂的场景说明。
  3. Medium Kanban 的 CAS 用 UPDATE WHERE claim_lock IS NULL。 如果改成 SELECT 看 NULL 再 UPDATE 会出什么经典并发 bug?
  4. Medium 失败 default 2 次熔断。但 LLM API 偶发 rate limit 也算失败。 你怎么改 dispatcher 区分"flaky 临时"和"结构性失败"?
  5. Hard Board 硬隔离用不同 SQLite DB。如果需求是"一个跨 board 任务"(比如 task A 在 board X 完成才能跑 task B 在 board Y), 你怎么设计?给两个方案,讨论 trade-off。
  6. Hard 文件状态协调依赖 file_state 模块跟踪每个 task_id 的 read/write。 如果用户在 task 外手动 vim foo.py 改了文件,Hermes 不知道。 设计一种"外部修改 detection"机制(提示:文件 mtime + 哈希)。