Skip to content

Learn Claude Code

约 3610 个字 885 行代码 预计阅读时间 23 分钟

1 Core Loop

1.1 最小 Agent Loop

把 “LLM + Tools” 连接成一个能持续推进任务的 Main Loop

把工具输出回头喂给 LLM 用于下一步推理,而不是仅仅输出一段文本

名词解释

  • turn:Agent 的一轮行动,在我们的最小版本中

    user message
        |
        v
       LLM
        |
        |   +--> 普通回答  ----> 结束
        |---|
            +--> tool_use ----> 执行工具 --> tool_result --> 写回 messages ----> 下一轮
    
  • tool_result_block:工具执行结果被打包回工作流的形态

    {
        "type": "tool_result",
        "tool_use_id": "...",   # 用于追踪具体是哪一次工具调用
        "content": "...",
    }
    
  • state:主循环需要一直携带的 Context,在最小版本中包含:

    state = {
        'messages': [
            {"role": "user", "content": "..."},
            {"role": "assistant", "content": [...]},
            ...
        ],
        'turn_count': 1,           # 当前轮次
        'transition_reason': None  # 这一轮结束后,为什么要继续下一轮
    }
    
    • msgs 不是聊天记录展示,而是 LLM 下一轮需要读取的 Ctx
    • MVP 中的 transition_reason 只有 "tool_result" 一种取值,即:刚执行完工具,需要继续

实现

# 基于用户请求初始化 msgs
messages = [{"role": "user", "content": query}]

# MVP 实现
def agent_loop(state):
    while True:
        # 调用 LLM
        response = client.messages.create(
            model=MODEL,
            system=SYSTEM,
            messages=state["messages"],
            tools=TOOLS,
            max_tokens=8000,
        )
        # 将 response 写回 Context
        state["messages"].append({
            "role": "assistant",
            "content": response.content,
        })
        # 不调用工具 => 退出
        if response.stop_reason != "tool_use":
            state["transition_reason"] = None
            return
        # 调用工具
        results = []
        for block in response.content:
            if block.type == "tool_use":
                output = run_tool(block)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        # 将工具调用结果写回 Context
        state["messages"].append({"role": "user", "content": results})
        # 更新轮次信息
        state["turn_count"] += 1
        state["transition_reason"] = "tool_result"

1.2 Tool-using

追加工具不需要修改 Main Loop,只需要在 handler map + schema 中进行注册

handler map 是一个由 tool_name: handler_func 构成的 dict

对 1.1 中的工具调用 block 进行优化,使其能从 handler map 中调用多种工具

# handler map
TOOL_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    ...
}
# tool schema(这部分其实会通过 tools=TOOLS 的形式塞在 prompt 里)
TOOLS = [
    {
        "name": "bash", 
        "description": "Run a shell command.",
        "input_schema": {
            "type": "object", 
            "properties": {"command": {"type": "string"}}, 
            "required": ["command"]
        }
    },
    {   
        "name": "read_file", 
        "description": "Read file contents.",
        "input_schema": {
            "type": "object", 
            "properties": {
                "path": {"type": "string"}, 
                "limit": {"type": "integer"}
            }, 
        "required": ["path"]
        }
    },
    ...
]

# mod: tool-using block
for block in response.content:
    if block.type == "tool_use":
        handler = TOOL_HANDLERS.get(block.name)
        output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
        results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": output,
        })

handler map 好像不能完全兜住工具使用

  • 是的,在更完备的实现中,Tool-using 应该是一个 Layer,而不仅仅是 Handler Map + Schema Map
  • 你还需要往里塞进:鉴权、curMsg + AppState、MCP Client、文件读取缓存、通知 + query 追踪 ...
如何防止 read_file / write_file 等工具从指定工作区中逃逸

添加一个 safe_path 作为沙箱(其实只是 check 一下指定文件的 path 是否在工作区中)

def safe_path(p: str) -> Path:           # p 在理想状态下为 相对路径
    path = (WORKDIR / p).resolve()       # 拼接 WORKDER + p,并转换为绝对路径
    if not path.is_relative_to(WORKDIR): # 防止 p 通过 ../ 或者 绝对路径 跳出工作区
        raise ValueError(f"Path escapes workspace: {p}")
    return path

def run_read(path: str, limit: int = None) -> str:
    text = safe_path(path).read_text()  # 在此处连接路径
    lines = text.splitlines()
    if limit and limit < len(lines):
        lines = lines[:limit]
    return "\n".join(lines)[:50000]
如何处理内部 messages 消息列表格式与 LLM API 协议不兼容
  • LLM 的 API 协议给出的消息存在 3 点约束

    1. 每个 tool_use block 必须存在对应的 tool_result (通过 tool_use_id 关联)
    2. user - assitant 必须交替出现(两条同角色消息不得连续)
    3. 只接受协议定义的字段( msg 中的一些内部字段会导致 400 错误)
  • Solution:加一个函数,在 发送请求前 做一次规范化

    response = client.messages.create(
        messages=normalize_messages(messages),  # <= 在这里
        ...
    )
    

    messages 列表是系统的内部表示, API 看到的是规范化后的副本,两者不是一个东西

def normalize_messages(messages: list) -> list:
    normalized = []

    for msg in messages:
        # Step 1: 去除内部字段
        clean = {"role": msg["role"]}
        if isinstance(msg.get("content"), str):
            clean["content"] = msg["content"]
        elif isinstance(msg.get("content"), list):
            clean["content"] = [
                {
                    k: v for k, v in block.items() \
                        if k not in ("_internal", "_source", "_timestamp")
                } for block in msg["content"]
            ]
        normalized.append(clean)

    # Step 2: tool_result 配对补齐
    # 收集所有已有的 tool_result ID
    existing_results = set()
    for msg in normalized:
        if isinstance(msg.get("content"), list):
            for block in msg["content"]:
                if block.get("type") == "tool_result":
                    existing_results.add(block.get("tool_use_id"))

    # 找出缺失配对的 tool_use (被取消), 插入占位 result
    for msg in normalized:
        if msg["role"] == "assistant" and isinstance(msg.get("content"), list):
            for block in msg["content"]:
                if (block.get("type") == "tool_use"
                        and block.get("id") not in existing_results):
                    # 在下一条 user 消息中补齐
                    normalized.append({"role": "user", "content": [{
                        "type": "tool_result",
                        "tool_use_id": block["id"],
                        "content": "(cancelled)",
                    }]})

    # Step 3: 合并连续同角色消息
    merged = [normalized[0]] if normalized else []
    for msg in normalized[1:]:
        if msg["role"] == merged[-1]["role"]:
            # 合并内容
            prev = merged[-1]
            prev_content = prev["content"] if isinstance(prev["content"], list) \
                else [{"type": "text", "text": prev["content"]}]
            curr_content = msg["content"] if isinstance(msg["content"], list) \
                else [{"type": "text", "text": msg["content"]}]
            prev["content"] = prev_content + curr_content
        else:
            merged.append(msg)

    return merged

1.3 Session 待办更新

LLM 一口气列出很多步骤后,跑一半自己搞忘了怎么办

外显当前会话计划并随任务推进不断更新(⚠️ 不是跨对话的持久化任务系统)

  • Pipeline

    用户提出大任务 --> 模型写一份计划 --> 做完一步,进行更新计划
    

    其中,complted * N + in_progress * 1 + pending * M

  • 数据结构

    • PlanItem:每一步在干嘛

      {
          "content": "Read the failing test",                # 这一步应该干什么(祈使句)
          "status": "pending" | "in_progress" | "completed",
          "activeForm": "Reading the failing test",          # 状态描述(用于回显、更有活人感)
      }
      
    • PlanningState:计划整体执行状态

      {
          "items": [PlanItems],
          "rounds_since_update": 0, # 多少轮没更新计划了
      }
      

TodoManager 如何与 Main Loop 交互

  • 把 TodoManager 也作为一个 Tool 加入 Tools Map
  • 此后,Main Loop 将在 messages 列表的基础上额外对 PlanningState 进行维护
class TodoManager:
    def __init__(self):
        self.state = PlanningState()

    def render(self) -> str:
        """把 Todo 以更可读的形式输出"""
        lines = []
        for item in self.state.items:
            marker = {
                "pending": "[ ]",
                "in_progress": "[>]",
                "completed": "[x]",
            }[item.status]
            line = f"{marker} {item.content}"
            if item.status == "in_progress" and item.active_form:
                line += f" ({item.active_form})"
            lines.append(line)
        completed = sum(1 for item in self.state.items if item.status == "completed")
        lines.append(f"\n({completed}/{len(self.state.items)} completed)")
        return "\n".join(lines)

    def update(self, items: list) -> str:
        """允许更新 Todo"""
        validated = []
        in_progress_count = 0

        for item in items:
            status = item.get("status", "pending")
            if status == "in_progress":
                in_progress_count += 1
            validated.append({
                "content": item["content"],
                "status": status,
                "activeForm": item.get("activeForm", ""),
            })

        if in_progress_count > 1: # 约束 check
            raise ValueError("Only one plan item can be in_progress")

        self.state.items = validated
        self.state.rounds_since_update = 0
        return self.render()

    # 连续 PLAN_REMINDER_INTERVAL 轮没有更新计划,进行提醒
    def note_round_without_update(self) -> None:
        self.state.rounds_since_update += 1
    def reminder(self) -> str | None:
        if not self.state.items:
            return None
        if self.state.rounds_since_update < PLAN_REMINDER_INTERVAL:
            return None
        return "<reminder>Refresh your current plan before continuing.</reminder>"

1.4 SubAgent

看起来还是 顺序执行,只是把 dirty work 外包了

把探索性任务丢给 SubAgent,让父 Agent 在干净的 Context 中盯紧主目标

在组会上汇报你的实验结果就可以了,不要和我讲你的失败小故事

  • 隔离

    • 上下文隔离:SubAgent 有自己的独立 messages(基础实现为 “白手起家”)

      拓展:fork => 继承 Parent 的已有 msgs(Ctx)、并追加子任务 Prompt

    • 工具隔离:只获取必要工具、不允许继续派生 SubSubAgent

  • 保护措施:防止无限循环

    • 最大允许运行轮次
    • 工具调用出错时如何退出
  • SubAgent 只把 最终摘要/结果 写回父智能体,中间不会改动 Parent Msgs

    return {
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": summary_text,
    }
    
  • task tool:让 Parent 能够主动外包任务

    TOOLS += {
        "name": "task",
        "description": "Run a subtask in a clean context and return a summary.",
        "input_schema": {
            "type": "object",
            "properties": {
                "prompt": {"type": "string"}
            },
            "required": ["prompt"]
        }
    }
    

    对应的 handler 如下,此处为 “白手起家” 模式

    def run_subagent(prompt: str) -> str:
        # 白手起家
        sub_messages = [{"role": "user", "content": prompt}]
        # 最大轮次限制:只操作 SubAgent msgs
        for _ in range(30):
            response = client.messages.create(
                model=MODEL, system=SUBAGENT_SYSTEM, messages=sub_messages,
                tools=CHILD_TOOLS, max_tokens=8000, # 工具隔离
            )
            sub_messages.append({"role": "assistant", "content": response.content})
            if response.stop_reason != "tool_use":
                break
            results = []
            for block in response.content:
                if block.type == "tool_use":
                    handler = TOOL_HANDLERS.get(block.name)
                    output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
                    results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)[:50000]})
            sub_messages.append({"role": "user", "content": results})
        # 只返回摘要
        return "".join(b.text for b in response.content if hasattr(b, "text")) or "(no summary)"
    

1.5 Skills

Skill 是一种支持按需加载的领域知识(system prompt),解决 '怎么做' 的问题

Skill - Memory - CLAUDE.md

  • Skill: 可选的知识包,仅在完成特定任务时 按需加载
  • Memory:系统记住的 跨对话 信息(通常是一些 fact / preference),不是任务 SOP
  • CLAUDE.md:全局、稳定、长期的规则说明

名词解释

  • skill:用于解决特定任务的说明书(可复用),通常包含以下内容

    • 什么时候用
    • 解决任务的具体步骤
    • 有哪些注意事项
  • actions

    • discovery:列出可用的 skills、以及这些 skill 能干什么( name + description )

    • loading:按需注入 —— 真正将完整的 skill 内容放进当前上下文

实现

  1. 如何存放 skills:一般来说会按照以下目录来塞 md 文档

    skills/
        code-review/SKILL.md
        git-workflow/SKILL.md
    
  2. 代码实现:

    # 被塞进 sysPrompt 只有格式化为 “- name: desc” 的 manifest 列表
    SKILL_REGISTRY = SkillRegistry(SKILLS_DIR)
    SYSTEM = f"""You are a coding agent at {WORKDIR}.
    Use load_skill when a task needs specialized instructions before you act.
    Skills available:
    {SKILL_REGISTRY.describe_available()}
    """
    
    # 提供用于加载特定技能的 tool:对应的 result 为 skill 的完整正文
    TOOL_HANDLERS = {
        "load_skill": lambda **kw: SKILL_REGISTRY.load_full_text(kw["name"]),
    }
    
    # 从 `skills` 路径下整理所有可用技能
    class SkillRegistry:
        def __init__(self, skills_dir):
            self.skills = {}
            self._load_all()
    
        def _load_all(self):
            for path in skills_dir.rglob("SKILL.md"):
                meta, body = parse_frontmatter(path.read_text())
                name = meta.get("name", path.parent.name)
                self.skills[name] = {
                    "manifest": {
                        "name": name,
                        "description": meta.get("description", ""),
                    },
                    "body": body,
                }
    
        def load_full_text(self, name: str) -> str:
            skill = self.skills.get(name)
            if not skill:
                known = ", ".join(sorted(self.skills)) or "(none)"
                return f"Error: Unknown skill '{name}'. Available skills: {known}"
            return (
                f"<skill name=\"{skill['manifest']name}\">\n"
                f"{skill['body']}\n"
                "</skill>"
            )
    

1.6 Ctx 压缩

Ctx 不是越多越好,只在活跃区域保留有价值的部分

tool output
   |
   +-- 太大 -----------------------> 保存到磁盘 + 留预览
   |
   v
messages
   |
   +-- 太旧 -----------------------> 替换成简短的占位提示
   |
   +-- 整体还是太长 / 手动 compact --> 把历史信息压缩成摘要
   |
   v
neo_history
  • 需要维护的压缩状态 Meta:

    {
        "has_compacted": True / False,
        "last_summary": "",            # 最新一次的压缩 Summary
        "recent_files": [],            # 最近改过的文件
    }
    
  • tool_result Dump 工具:返回存储路径 + 开头的 preview

    def persist_large_output(tool_use_id: str, output: str) -> str:
        if len(output) <= PERSIST_THRESHOLD:
            return output
    
        stored_path = save_to_disk(tool_use_id, output)
        preview = output[:2000]
        return (
            "<persisted-output>\n"
            f"Full output saved to: {stored_path}\n"
            f"Preview:\n{preview}\n"
            "</persisted-output>"
        )
    
  • 把太旧的 tool_result 替换成占位符:此处仅保留最近 3 次工具调用结果

    def micro_compact(messages: list) -> list:
        tool_results = collect_tool_results(messages)
        for result in tool_results[:-3]:
            result["content"] = "[Earlier tool result omitted for brevity]"
        return messages
    
  • 整体历史压缩工具:需要保留 当前目标、已完成/未完成的事项、被改动的文件、需要保留的决定

    # 具体的压缩函数
    def summarize_history(messages: list) -> str:
        conversation = json.dumps(messages, default=str)[:80000]
        prompt = (
            "Summarize this coding-agent conversation so work can continue.\n"
            "Preserve:\n"
            "1. The current goal\n"
            "2. Important findings and decisions\n"
            "3. Files read or changed\n"
            "4. Remaining work\n"
            "5. User constraints and preferences\n"
            "Be compact but concrete.\n\n"
            f"{conversation}"
        )
        response = client.messages.create(
            model=MODEL,
            messages=[{"role": "user", "content": prompt}],
            max_tokens=2000,
        )
        return response.content[0].text.strip()
    
    # 保留 focus 目标 + 最近改动的 files
    def compact_history(messages, state, focus):
        # 先保存以下压缩前的内容(万一之后还要找回)
        transcript_path = write_transcript(messages)
        print(f"[transcript saved: {transcript_path}]")
    
        summary = summarize_history(messages)
        if focus:
            summary += f"\n\nFocus to preserve next: {focus}"
        if state.recent_files:
            recent_lines = "\n".join(f"- {path}" for path in state.recent_files)
            summary += f"\n\nRecent files to reopen if needed:\n{recent_lines}"
    
        state.has_compacted = True
        state.last_summary = summary
        return [{
            "role": "user",
            "content": (
                "This conversation was compacted so the agent can continue working.\n\n"
                f"{summary}"
            ),
        }]
    
  • 接入 Main Loop:插在 LLM Call 之前

    def agent_loop(state):
        while True:
            state["messages"] = micro_compact(state["messages"])
    
            if estimate_context_size(state["messages"]) > CONTEXT_LIMIT:
                state["messages"] = compact_history(state["messages"])
                state["has_compacted"] = True
    
            response = call_model(...)
            ...
    

2 系统安全性

2.1 鉴权

不是所有 '意图' 都应该被执行

一个最小的权限管理系统遵循以下风格(顺序检查即可):

  1. Deny Rules:命中了就直接拒绝(提前拒绝看起来就不对劲的命令)

  2. Mode Check:当前系统风格(决定特定分组下的 tool 是否执行)

    模式 含义 适用场景
    default 未命中规则时问用户 日常交互
    plan 只允许读,不允许写 计划、审查、分析
    auto 简单安全操作自动过,危险操作再问 高流畅度探索
  3. Allow Rules:命中了就直接通过(跳过 常见/重复 操作的检查)

  4. Ask User:剩下不确定的都问 User

  5. Rule 结构定义

    PermissionRule = {
        # MVP: 对于指定名称的 tool 做什么决定
        "tool": str,
        "behavior": "allow" | "deny" | "ask",
        # Optional
        "path": str | None,
        "content": str | None,
    }
    

    一定要检查 bash,你也不想自动 sudo rm -rf *

    # Always deny dangerous patterns
    DEFAULT_RULES = [
        {"tool": "bash", "content": "rm -rf /", "behavior": "deny"},
        {"tool": "bash", "content": "sudo *", "behavior": "deny"},
    ]
    
  6. MVP 实现

    def check_permission(tool_name: str, tool_input: dict) -> dict:
        # 1. deny rules
        for rule in deny_rules:
            if matches(rule, tool_name, tool_input):
                return {"behavior": "deny", "reason": "matched deny rule"}
    
        # 2. mode
        if mode == "plan" and tool_name in WRITE_TOOLS:
            return {"behavior": "deny", "reason": "plan mode blocks writes"}
        if mode == "auto" and tool_name in READ_ONLY_TOOLS:
            return {"behavior": "allow", "reason": "auto mode allows reads"}
    
        # 3. allow rules
        for rule in allow_rules:
            if matches(rule, tool_name, tool_input):
                return {"behavior": "allow", "reason": "matched allow rule"}
    
        # 4. fallback to User
        return {"behavior": "ask", "reason": "needs confirmation"}
    
  7. 接入 Tool-using Loop:在真正调用前

    decision = perms.check(tool_name, tool_input)
    
    if decision["behavior"] == "deny":
        return f"Permission denied: {decision['reason']}"
    if decision["behavior"] == "ask":
        ok = ask_user(...)
        if not ok:
            return "Permission denied by user"
    # "behavior" == "allow" 真正调用
    return handler(**tool_input)
    

最好再实现一下 '连续拒绝' 计数 => 你的 Agent 可能卡住了

你可以:给出 reminder / 切换到 plan 模式 / 让用户澄清目标

2.2 Hooks

在不修改 Main Loop 的前提下,在固定时期插入自定义附加行为

Main Loop Running ...
        |
        +-- 到了某个预留时机
        |
        +-- 调用 hook runner
        |
        +-- 收到 hook 返回
        |
        +-- 决定如何处理:继续、阻止、补充说明
  • (简化版)Hook 返回值协议:不要今天返回字符串,明天返回布尔值,后天返回整数

    retValue 含义
    0 正常,继续运行
    1 阻止当前行为
    2 注入补充消息后,继续运行
  • Handler Map:为 event 指定对应的 handler 函数(可以是一组 / 为空)

    HOOKS = {
        "SessionStart": [on_session_start],
        "PreToolUse": [pre_tool_guard],
        "PostToolUse": [post_tool_log],
    }
    
  • 统一运行(一组)hooks:此处 阻止/注入 返回将阻断运行、并直接返回

    def run_hooks(event_name: str, payload: dict) -> dict:
        for handler in HOOKS.get(event_name, []):
            result = handler(payload)
            if result["exit_code"] in (1, 2):
                return result
        return {"exit_code": 0, "message": ""}
    

    其中,payload 可以设计为如下格式:

    {
        "tool_name": "bash",
        "input": {"command": "pytest"},
    }
    
  • 接入 tool-using Loop:

    results = []
    for block in response.content:
        # -- PreToolUse --
        pre  = hooks.run_hooks("PreToolUse", payload)
        # Inject hook messages into results
        if pre["exit_code"] == 1:
            results.append(blocked_tool_result(pre["message"]))
            continue
        if pre["exit_code"] == 2:
            messages.append({"role": "user", "content": pre["message"]})
    
        # 真正执行工具
        handler = TOOL_HANDLERS.get(block.name)
    
        # -- PostToolUse --
        post = hooks.run_hooks("PostToolUse", payload)
        # Inject post-hook messages(这里不可能被 block 了)
        if post["exit_code"] == 2:
            messages.append({"role": "user", "content": pre["message"]})
    
        # 正常塞回 tool_result
        results.append({
            "type": "tool_result", "tool_use_id": block.id,
            "content": str(output),
        })
    

2.3 Memory 管理

维护跨对话、无法直接从当前仓库状态推导的信息,在 新会话开启时自动加载

memory 只用来提供方向,不能替代当前观察

  • memory 记录的是“曾经成立过的事实”,不是永久真理
  • 当 code 与 memo 冲突时,请相信 code
  • 根据 memo 推荐资源前,再 check 一遍(万一它改了呢)

如果用户让我 '忽略之前的记忆' 呢 => 在 当前轮次 中,按照 memory 为空进行工作

  • 一些值得记录的 Memory

    type desc sample
    user 用户偏好 代码风格 / 回复风格 / 偏好工具链
    feedback 用户的人工纠正 / 正反馈 xxx 之前错过 / 以后遇到这种情况要先 xxx / xxx 判断方式是有效的
    project 不容易直接从 code 看出来的约定 / 背景 特定目录不能改 / 某个决定是因为合规要求
    reference 外部资源指针 资料库对应的 URL / 特定监控面板的访问方式
  • 存储结构

    • 每条 memory 对应一个单独文件:带 meta + 具体内容

      ---
      name: prefer_tabs
      description: User prefers tabs for indentation
      type: user
      ---
      The user explicitly prefers tabs over spaces when editing source files.
      

      对应 dump 工具实现

      def save_memory(name, description, mem_type, content):
          path = memory_dir / f"{safe_name}.md"
          path.write_text(frontmatter + content)
          rebuild_index() # 更新 index
      
    • 索引文件:展示所有可用 memory( + desc )

      # Memory Index
      - prefer_tabs: User prefers tabs for indentation [user]
      - avoid_mock_heavy_tests: User dislikes mock-heavy tests [feedback]
      
  • 接入:在会话开始时,读取所有 memory 拼接为 section、塞进 system prompt

2.4 System Prompt

你面对的是可是超级拼装口牙

System Prompt = 
    # 静态部分
    core              # 核心身份和行为说明
    + tools           # 工具列表
    + skills          # skill 元信息
    + memory
    + claude_md
    # 动态部分
    + dynamic_context # 动态环境信息(date / pwd / model / mode)

在实现上也不过是顺序拼装(每个 builder 只负责对应模块的加载):

class SystemPromptBuilder:
    def build(self) -> str:
        parts = []
        parts.append(self._build_core())
        ...
        parts.append(self._build_dynamic())
        return "\n\n".join(p for p in parts if p)

2.5 错误恢复

错误不是例外,而是必须预留的正常分支

先对错误进行分类,再选择恢复路径(有限次补救),失败最后才暴露给用户

  • 典型错误及其恢复方式

    stop reason solution
    输出被截断 注入续写提示 + retry
    Ctx 过长 压缩上下文 + retry
    临时连接失败 等一会儿 + retry
  • 数据结构

    • (Meta)Recovery State:(分别)统计每种恢复路径重试次数,避免死循环

      {
          "continuation_attempts": 0,
          "compact_attempts": 0,
          "transport_attempts": 0,
      }
      
    • 恢复决策:

      {
          "kind": "continue" | "compact" | "backoff" | "fail",
          "reason": "why this branch was chosen",
      }
      
    • 恢复选择器:类似于 Mapper

      def choose_recovery(stop_reason: str | None, error_text: str | None) -> dict:
          if stop_reason == "max_tokens":
              return {"kind": "continue", "reason": "output truncated"}
      
          if error_text and "prompt" in error_text and "long" in error_text:
              return {"kind": "compact", "reason": "context too large"}
      
          if error_text and any(word in error_text for word in [
              "timeout", "rate", "unavailable", "connection"
          ]):
              return {"kind": "backoff", "reason": "transient transport failure"}
      
          return {"kind": "fail", "reason": "unknown or non-recoverable error"}
      
  • 接入 Main Loop

    • 在 LLM Call 外层处理:API + 网络错误
    • 在拿到 Response 后处理:超出 max_token
    while True:
        response = None
        for attempt in range(MAX_RECOVERY_ATTEMPTS + 1):
            try:
                # LLM 请求
                break  # success
            except APIError as e:
                error_body = str(e).lower()
                # 压缩 Ctx
                if "overlong_prompt" in error_body or ("prompt" in error_body and "long" in error_body):
                    messages[:] = auto_compact(messages)
                    continue
                # 请求错误
                if attempt < MAX_RECOVERY_ATTEMPTS:
                    delay = backoff_delay(attempt)
                    time.sleep(delay)
                    continue
                return # 超出最大重试
            except (ConnectionError, TimeoutError, OSError) as e:
                # 连接错误
                if attempt < MAX_RECOVERY_ATTEMPTS:
                    delay = backoff_delay(attempt)
                    time.sleep(delay)
                    continue
                return # 超出最大重试
    
        if response is None: return
        messages.append({"role": "assistant", "content": response.content})
    
        # 输出被截断
        if response.stop_reason == "max_tokens":
            max_output_recovery_count += 1
            if max_output_recovery_count <= MAX_RECOVERY_ATTEMPTS:
                messages.append({"role": "user", "content": CONTINUATION_MESSAGE})
                continue
            else: return
    
        # 重置对应 counter
        max_output_recovery_count = 0
    
        # 正常工具处理
    

3 Task Runtime

3.1 Task System

Todo 只能提供 '有序列表' 形式的任务拆分,无法提供任务依赖拓扑

  • Todo 对于 简单的、可被顺序拆分的 目标已经足够
  • Task 是一个可以被跟踪、被分配、被完成、被阻塞的工作目标(不是某个正在执行的线程)
  • 让 Task System 帮你把 Todos 升级成持久化的 Task Graph
  • 任务状态

    • pending => in_progress => completed
    • deleted:逻辑删除
    • 仅当 task["status"] == "pending" && not task["blockedBy"] 时,可被执行(Ready)
  • 任务持久化:不止是塞在 messages

    一个任务存一个文件 => tasks/task_idx.json

    class TaskManager:
    def create(self, subject: str, description: str = "") -> dict:
        task = {
            "id": self._next_id(),
            "subject": subject,         # 任务概括
            "description": description, # 补充说明
            "status": "pending",
            "blockedBy": [],            # 前置依赖
            "blocks": [],               # 完成后解锁哪些任务
            "owner": "",                # ?谁来做
        }
        self._save(task)
        return task
    
  • 依赖关系维护

    • 添加依赖关系:同时维护 A => B + B => A

      def add_dependency(self, task_id: int, blocks_id: int):
          task = self._load(task_id)
          blocked = self._load(blocks_id)
      
          if blocks_id not in task["blocks"]:
              task["blocks"].append(blocks_id)
          if task_id not in blocked["blockedBy"]:
              blocked["blockedBy"].append(task_id)
      
          self._save(task)
          self._save(blocked)
      
    • 解锁依赖关系:从所有直接后继任务的 blockedBy 中移除

      def complete(self, task_id: int):
          task = self._load(task_id)
          task["status"] = "completed"
          self._save(task)
      
          for other in self._all_tasks():
              if task_id in other["blockedBy"]:
                  other["blockedBy"].remove(task_id)
                  self._save(other)
      
  • 接入主循环:提供以下工具

    tool_name desc tool_name desc
    task_create 新建任务 task_update 更新任务状态
    task_get 查看单一任务 task_list 查看完整任务板

3.2 后台任务

把慢命令移到后台,至少让主循环看上去没在傻等

主循环仍然只有一条,并行的是等待,不是主循环本身

Main Loop                            已在后台
  |                                     |
  +-- background_run("pytest") ---> 执行 pytest
  |      -> 立刻返回 task_id              -> 完成后 notification
  |
  +-- 继续别的工作
  |
  v
下一轮 LLM call 查看所有 notifications把摘要注入 messages
  • 序列化

    • runtime-tasks/tid.json:运行状态(只有 prview 会被写回 msgs)

      {
          "id": "a1b2c3d4",
          "command": "pytest",
          "status": "running",
          "started_at": 1710000000.0, # 开始执行时间戳
          "result_preview": "",       # 给 LLM 看的摘要
          "output_file": "",          # 完整输出路径
      }
      
    • runtime-tasks/tid.log:完整输出

  • Notification:只是告诉主循环有结果了(不是完整的日志)

    {
        "type": "background_completed",
        "task_id": "a1b2c3d4",
        "status": "completed",
        "preview": "tests passed",
    }
    
  • 接入主循环:提供两个 Tool

    tool_name desc
    background_run 在后台执行 runtime task
    background_check 检查特定 runtime task / 罗列所有 runtime task 状态
class BackgroundManager:
    def __init__(self):
        self.tasks = {}                # 所有的后台任务
        self._notification_queue = []  # 已经有返回结果的任务
        self._lock = threading.Lock()  # 并发控制

    def run(self, command: str) -> str:
        """启动后台执行线程"""
        task_id = str(uuid.uuid4())[:8]
        self.tasks[task_id] = {
            "id": task_id,
            "status": "running",
            "result": None,
            "command": command,
            "started_at": time.time(),
            "finished_at": None,
            "result_preview": "",
            "output_file": str(self._output_path(task_id)),
        }
        thread = threading.Thread(
            target=self._execute, args=(task_id, command), daemon=True
        )
        thread.start()
        return task_id

    def _execute(self, task_id: str, command: str):
        """运行,完成后写 notification(自行负责)"""
        try:
            r = subprocess.run(
                command, shell=True, cwd=WORKDIR,
                capture_output=True, text=True, timeout=300
            )
            output = (r.stdout + r.stderr).strip()[:50000]
            status = "completed"
        except subprocess.TimeoutExpired:
            output = "Error: Timeout (300s)"
            status = "timeout"

        # 记录完整输出
        output_path = self._output_path(task_id)
        output_path.write_text(output)

        # 更新状态 + 记录 preview
        self.tasks[task_id]["status"] = status
        self.tasks[task_id]["result"] = final_output
        self.tasks[task_id]["finished_at"] = time.time()
        self.tasks[task_id]["result_preview"] = self._preview(output)

        with self._lock:
            self._notification_queue.append({
                "task_id": task_id,
                "status": status,
                "command": command[:80],
                "preview": preview,
                "output_file": str(output_path.relative_to(WORKDIR)),
            })

    def drain_notifications(self) -> list:
        """下一次 LLM Call 前,处理所有 notifications"""
        with self._lock:
            notifs = list(self._notification_queue)
            self._notification_queue.clear()
        return notifs

3.3 定时任务

等待开始:在未来的特定时间做某事

- schedule_create => 把定时调度记录写到列表或文件里

- 后台定时检查器每分钟 check 一次“现在是否匹配”
    -> 匹配,把 prompt 放进 nofitication
    -> 下一轮 当成 User Msg 喂给 LLM
  • 从创建一条新的调度记录开始:durable 需要 dump,session only 可以不管

    def create(self, cron_expr: str, prompt: str, recurring: bool = True):
        job = {
            "id": new_id(),
            "cron": cron_expr,          # 定时规则
            "prompt": prompt,           # 到点后需要注入 Main Loop 的 prompt
            "recurring": recurring,     # 是否需要重复触发
            "created_at": time.time(),
            "last_fired_at": None,      # 上次触发时间
        }
        self.jobs.append(job)
        return job
    
  • 定时检查 Loop:需要开一条线程持续跑,到点就写 notification

    def check_loop(self):
        while True:
            now = datetime.now()
            self.check_jobs(now)
            time.sleep(60)
    
    def check_jobs(self, now):
        for job in self.jobs:
            if cron_matches(job["cron"], now):
                self.queue.put({
                    "type": "scheduled_prompt",
                    "schedule_id": job["id"],
                    "prompt": job["prompt"],
                })
                job["last_fired_at"] = now.timestamp()
    
  • 接入主循环:最终还是让 LLM 来判断定时任务怎么执行

    处理所有定时任务通知
    notifications = scheduler.drain()
    for item in notifications:
        messages.append({
            "role": "user",
            "content": f"[scheduled:{item['schedule_id']}] {item['prompt']}",
        })
    

4 Multi Agents

4.1 Agents Team

SubAgent 用完一次就丢,Team 支持长期在线接活 + 协作

  • 团队名册:队伍里有谁、分别担任什么角色、目前状态(空闲 / 工作中 / 下线)

    .team/config.json,系统重启后仍能保留
    config = {
        "team_name": "default",
        "members": [{
            "name": "alice",
            "role": "coder",
            "status": "working",
        }, ...],
    }
    
  • teammate

    • 一个拥有独立名字、角色、消息入口和生命周期的持久 agent

    • 新锅收取:邮箱(在下一轮工作前整理信息)

      最简单的实现是一人一个 jsonl 文件(.team/inbox/NAME.jsonl)、来锅直接 append

      嗨嗨嗨,又有新锅了、快起来干活!
      message = {
          "type": "message",
          "from": "lead",
          "content": "Please review auth module.",
          "timestamp": 1710000000.0,
      }
      
    • 上下文压缩后,队友有时会忘记自己是谁。最小补法是重新注入一段身份提示:

      identity = {
          "role": "user",
          "content": "<identity>You are 'alice', role: frontend, team: default. Continue your work.</identity>",
      }
      

目前的实现仍由 leader 手动进行(创建队友、分派任务)

是的,我们还没实现自主找活、自动恢复工作的全自治牛马

class TeammateManager:
    # 1 老大说,首先要有一份名册
    def __init__(self, team_dir: Path):
        self.dir = team_dir
        self.config_path = self.dir / "config.json"
        self.config = self._load_config()
    def _load_config(self) -> dict:
        if self.config_path.exists():
            return json.loads(self.config_path.read_text())
        return {"team_name": "default", "members": []}

    # 2 捏一个新的牛马来干活吧
    def spawn(self, name: str, role: str, prompt: str) -> str:
        member = {"name": name, "role": role, "status": "working"}
        self.config["members"].append(member)
        self._save_config()

        thread = threading.Thread(
            target=self._teammate_loop, # 略(和 main loop 差不多、但需要 check 邮箱)
            args=(name, role, prompt),
            daemon=True,
        )
        thread.start()

    # 3 谁家牛马天天看邮箱找活干
    def _teammate_loop(self, name: str, role: str, prompt: str):
        messages = [{"role": "user", "content": prompt}]
        while True:
        inbox = bus.read_inbox(name) # 读全部信息 => 解析为列表 => 清空邮箱
        for item in inbox:
            messages.append({"role": "user", "content": json.dumps(item)})

        response = client.messages.create(...)
        ...

4.2 团队协议

Protocol 只负责协作

协议请求不是任务本身,请求状态表也不是任务板,任务系统才负责 “工作推进”

  • 协议消息

    message = {
        "from": "lead",
        "to": "alice",
        "timestamp": 1710000000.0,
        # NEW
        "type": "shutdown_request",
        "request_id": "req_001",
        "payload": {},
    }
    
  • 请求追踪:最好还是一个个 dump 为 .team/requests/req_xxx.json

    {
        "request_id": "req_001",
        "kind": "shutdown",
        "from": "lead",
        "to": "alice",
        "status": "pending" | "approved" | "rejected" | "expiered",
    }
    
  • 运行协议

    队友 / lead 发起请求
    -> 写入 RequestRecord -> 把 ProtocolEnvelope 投递进对方 inbox
    
    接收方下一轮 drain inbox
    -> 按 request_id 更新请求状态 -> 必要时再回一条 response
    
    请求方
    -> 根据 approved / rejected 继续后续动作
    

4.3 自主代理

嗨嗨嗨,两眼一睁就是自己找活干的全自动牛马来辣!!!

当可汗点不动兵成为瓶颈,牛就要学会自己认领磨拉

私戳和公屏都要看捏
活跃牛马
  |
  | 当前轮工作做完,或者主动进入 idle
  v
绝赞摸鱼
  |
  +-- 看邮箱,有新消息 -> 回到 WORK
  |
  +-- 看任务板,有 ready task -> 认领 -> 回到 WORK
  |
  +-- 长时间什么都没有 -> shutdown
  • 如何判断一个锅能不能被自主认领

    def is_claimable_task(task: dict, role: str | None = None) -> bool:
        return (
            task.get("status") == "pending"
            and not task.get("owner")
            and not task.get("blockedBy")
            and _task_allows_role(task, role) # 发布者可以指定接活牛马组别
        )
    
  • 认领后需要修改

    • 任务状态

      "status": "in_progress"
      "claimed_at": 1710000000.0         # 被认领的时间戳
      "claim_source": "auto" | "manual"  # 被点名 / 自己捡走
      
    • 需要落盘的自主拿锅记录

      .tasks/claim_events.jsonl
      {
          "event": "task.claimed",
          "task_id": 7,
          "owner": "alice",
          "role": "frontend",
          "source": "auto",
          "ts": 1710000000.0,
      }
      
  • 接入 teammate_loop

    # 支持 work -> idle 状态流转
    while True:
        run_work_phase(...)
        should_resume = run_idle_phase(...)
        if not should_resume:
            break
    
    # idle:从 邮箱 + 告示板 找活干,循环若干轮后关闭自己
    def idle_phase(name: str, messages: list) -> bool:
        # 邮箱被点名,有活干了
        inbox = bus.read_inbox(name)
        if inbox:
            messages.append({
                "role": "user",
                "content": json.dumps(inbox),
            })
            return True
    
        # 没人找我?看看公告栏
        unclaimed = scan_unclaimed_tasks(role) # 支持接收角色过滤
        if unclaimed:
        task = unclaimed[0]
        claim_result = claim_task(             # clain_task 通过锁实现原子操作
            task["id"],
            name,
            role=role,
            source="auto",
        )
    
        # 默念自己的角色再开始干活
        ensure_identity_context(messages, name, role, team_name)
        messages.append({
            "role": "user",
            "content": f"<auto-claimed>Task #{task['id']}: {task['subject']}</auto-claimed>",
        })
        messages.append({
            "role": "assistant",
            "content": f"{claim_result}. Working on it.",
        })
        return True
    

4.4 💀 Worktree

让两个 teammate 在同一个 code 版本上独立修改、互不干扰(不共享未 commit 的改动)

任务被创建 -> 队友认领任务
    -> 系统为任务分配 worktree
    -> 在对应目录中执行命令(切换 cwd)
    -> 任务完成时:保留 / 删除 worktree
  • worktree 注册表:你这个任务到底在哪条车道上创人啊

    .worktrees/index.json
    {"worktrees": [
        {
            "name": "auth-refactor",
            "path": ".worktrees/auth-refactor",
            "branch": "wt/auth-refactor",
            "task_id": 12,                      # 关联具体 task
            "status": "active"
        }
    ]}
    
  • TaskRecord:新增与 worktree 相关字段

    task = {
        ...
        "worktree": "auth-refactor",      # 目前绑定的 wktree
        "worktree_state": "active" | "kept" | "removed" | "unbound",
        "last_worktree": "auth-refactor", # 最近用过的 wktree
        "closeout": None,                 # 最近一次收尾操作
    }
    
  • WorkTreeRecord:路径映射 + 最近操作 + 收尾操作

    worktree = {
        "name": "auth-refactor",
        "path": ".worktrees/auth-refactor",
        "branch": "wt/auth-refactor",
        "task_id": 12,
        "status": "active",
        "last_entered_at": 1710000000.0,
        "last_command_at": 1710000012.0,
        "last_command_preview": "pytest tests/auth -q",
        "closeout": None,
    }
    
  • CloseoutRecord:收尾行为 => 保留 / 回收

    closeout = {
        "action": "keep",
        "reason": "Need follow-up review",
        "at": 1710000100.0,
    }
    
  • EventRecord:worktree 具有复杂的生命周期

    创建 => 进入 => 执行命令 => 保留 / 删除 => 删除失败 ...

    event = {
        "event": "worktree.closeout.keep",
        "task_id": 12,
        "worktree": "auth-refactor",
        "reason": "Need follow-up review",
        "ts": 1710000100.0,
    }
    
  • 接入思路:先开任务、再指定 worktree

    1. 创建任务
    2. 分配 worktree、写入注册表
    3. 更新 TaskRecord 关于 worktree 的字段
    4. 显示切换工作目录、在对应目录下执行命令
    5. 收尾:显式通过 worktree_closeout 处理,同意兜住 keep / remove

删除 worktree 前应该检查 是否有脏改动

4.5 MCP

不是所有活都需要本机干,搞一些可插拔的外包员工进来吧

启动时
  -> PluginLoader 找到 server 配置
  -> MCP client 连接 server
    -> list_tools 并标准化 tool_name
    -> 与 native tools 一起合并进同一个工具池

运行时
  -> LLM 产出 tool_use
  -> 统一权限处理
  -> 工具路由:native / mcp
  -> 标准化 tool_result
  -> 回到主循环
  • MCP:让 agent 和外部工具程序对话的统一协议

    • MCP Server 会首先告诉你 "我能提供什么工具"(还能提供一些 resource)

    • 当你想要借用工具时、把请求转发给它,它会向你返回执行结果

  • Plugin 配置:有哪些可用的外部工具、通过什么命令调用

    .claud-plugin/plugin.json
    {
        "name": "my-db-tools",
        "version": "1.0.0",
        "mcpServers": {
            "postgres": {
                "command": "npx",
                "args": ["-y", "@modelcontextprotocol/server-postgres"]
            }
        }
    }
    
  • MCP Client:支持连接 MCP Server、列出可用工具、调用特定工具