多 Agent 协作的工程化
从 ch07 的 delegate_task 介绍走向真正能扛长任务的多 Agent 系统。 SQLite WAL + CAS 的原子认领、失败熔断、文件状态协调、心跳续期—— 这些把"并发跑 N 个子 agent"的玩具升级成生产级机器。
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_task | Kanban | |
|---|---|---|
| 调用方式 | 父 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。这样:
- 每个子 Agent 有自己的线程隔离
- 每个 executor 都有审批 callback initializer——deferred 安装到 worker 线程
- 父并发上限由
delegation.max_concurrent_children(默认 3)控制
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..."递归。
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:
- System prompt 完全重写。
ephemeral_system_prompt=child_prompt是基于 goal 现场生成的, 不带任何父 Agent 的身份/项目 context。 skip_memory=True+skip_context_files=True。子 Agent 是 stateless worker, 不该看见父见过的 memory 或项目文件。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)
_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。
16.6Kanban:跨进程、跨重启的任务队列
delegate_task 是父 turn 内同步的。但生产里很多任务跨进程跨重启:
- 夜里给 50 个 Linear issue 各跑一个 fix
- 每周一上午把 100 个 PR 各做 review
- 多人协作:Alice 创建任务,Bob 的 worker 自动认领
这种场景需要持久化 + 原子认领 + 心跳 + 熔断。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);
三张表的分工:
- tasks:当前状态(每任务一行)
- task_runs:历史尝试(每次 retry 一行)。Crashed 后 re-spawn = 新行。
- task_events:append-only 事件流。Gateway 通过订阅推送给用户。
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.
翻译:
- SQLite 开 WAL 模式 → 读不阻塞写,写不阻塞读。
BEGIN IMMEDIATE→ 立刻获取写锁(而非延后)。WAL 内串行化。- UPDATE 时用 CAS:
UPDATE tasks SET claim_lock=? WHERE id=? AND claim_lock IS NULL。 - 影响 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 隔离
| Board | Tenant | |
|---|---|---|
| 本质 | 独立 SQLite DB + workspaces | 同 DB 内的一列(tenant 字段) |
| 隔离强度 | 硬(看不到其他 board 任务) | 软(可 WHERE 过滤) |
| 典型用法 | 不同项目(atm10-server / website / research) | 同项目不同子模块 |
| 路径 | ~/.hermes/kanban/boards/<slug>/ | tasks.tenant 字段 |
| 解析 | HERMES_KANBAN_BOARD env > ~/.hermes/kanban/current 符号链接 > default | kanban_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))
设计要点:
- max_spawn 是 live 并发上限(不是每 tick 配额)。计算 = "已 running" + "本 tick 新派"。
- Respawn guard:刚失败的任务别立即重派(防 thrash)。等下个 tick 或回滚配额。
- Spawn 失败也走熔断:
_record_spawn_failure增consecutive_failures,超阈值自动 block。
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 不级联
)
关键设计:
start_new_session=True:子进程脱离 tty——父按 Ctrl+C 时不会被级联 kill。- DB 路径硬绑 env:即使 worker 的 profile 改了 HERMES_HOME,kanban 路径还是 dispatcher 那份。 防 worker 误写到错 board。
- 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本章带走的
- 多 Agent 有两个模型:同步 delegate_task(turn 内函数调用)和异步 Kanban(SQLite 队列)。
- delegate_task 的 batch 模式用 ThreadPoolExecutor,不用 asyncio。每子一个独立 executor + worker 线程隔离。
- 子 Agent 上下文完全 reset:全新 system prompt、no memory、no context files、no clarify。 只继承 credential / model / session_db。
- Role + 深度上限双重防御:role="orchestrator" 在深度上限处静默降级为 leaf。
- _last_resolved_tool_names save/restore 是实用补丁——理想是 ContextVar,现实是 global。
- 0-API-call 超时诊断:子还没调 LLM 就 timeout 时写专门诊断文件(prompt size + toolset + 线程栈)。
- 文件状态协调:子改了父读过的文件,父的 result summary 自动追加"re-read before editing"提示。
- Kanban 用 SQLite WAL + CAS 实现原子认领。没抢到就放弃,不重试。
- Stale claim 带活性检查:PID 活着就延期,不回收。保护慢 LLM call。
- 心跳机制让 worker 主动延期 + 记进度。
- 失败熔断 default 2 次:结构性问题别空转。
- Board 硬隔离 / Tenant 软隔离:不同强度的多项目分离。
章末练习
- Easy 子 Agent 的 system prompt 完全独立于父——这跟 ch05 讲的"system prompt 不能改避免破 cache" 矛盾吗?分析两者关系。
- Easy 为什么 delegate_task 失败返回 dict 而不是 raise?用 batch 模式 5 个子有 1 个挂的场景说明。
-
Medium
Kanban 的 CAS 用
UPDATE WHERE claim_lock IS NULL。 如果改成SELECT看 NULL 再UPDATE会出什么经典并发 bug? - Medium 失败 default 2 次熔断。但 LLM API 偶发 rate limit 也算失败。 你怎么改 dispatcher 区分"flaky 临时"和"结构性失败"?
- Hard Board 硬隔离用不同 SQLite DB。如果需求是"一个跨 board 任务"(比如 task A 在 board X 完成才能跑 task B 在 board Y), 你怎么设计?给两个方案,讨论 trade-off。
-
Hard
文件状态协调依赖
file_state模块跟踪每个 task_id 的 read/write。 如果用户在 task 外手动vim foo.py改了文件,Hermes 不知道。 设计一种"外部修改 detection"机制(提示:文件 mtime + 哈希)。