1. Agent Loop — 最小可运行的 Agent 内核 #

"一个循环 + 一个工具 = 一个 Agent" — 模型负责决策,Harness 负责把工具结果喂回去,直到模型说「做完了」。

本节对应教程 s01,是整个项目的起点:一个能读用户指令、调用大模型、执行 bash 命令、并把结果继续交回模型的最小 Agent。后续章节(工具扩展、权限、Hooks、记忆……)都挂在这个循环上,循环本身始终不变。

本节要解决什么

你问模型「列出当前目录下的 Python 文件」,它能写出一条 dir *.py 命令,但输出完就停了——不会自己执行,也看不到执行结果。

手动跑命令、把输出贴回对话框,就是在当「中间层」。本节用代码把这件事自动化:

信号 含义 循环动作
assistant.tool_calls 非空 模型要调用工具 执行 → 结果写入 messages → 继续下一轮
assistant.tool_calls 为空 模型认为任务完成 return,退出循环

模块分工

文件 功能描述
main.py CLI 入口,收集用户输入,驱动 agent_loop
agent.py 核心 while True 循环(Harness 内核)
llm.py 封装 OpenAI Chat Completions 调用
prompt.py 系统提示词(当前只有 identity 段)
config.py 环境变量、模型名、API 客户端
tools/schema.py 工具 JSON Schema 定义(当前仅 bash)
tools/handlers.py 工具实现(run_bash)
tools/executor.py 按名称分发到 TOOL_HANDLERS
utils.py 消息格式转换、Windows 编码解码
.env API 地址、密钥、模型名

核心逻辑集中在 agent.py 的 agent_loop:不到 40 行,却已经是生产级 Agent 的骨架。

试试这些 prompt:

  1. 创建一个名为 hello.py 的文件,内容为打印 "Hello, World!"
  2. 列出当前目录下所有的 Python 文件
  3. 当前的 git 分支是什么?

观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?

时序图

一次完整的用户问答(模型需要执行 1 次 bash 命令)如下:

sequenceDiagram participant User as 用户 participant Main as main.py participant Agent as agent_loop participant LLM as llm.py participant Executor as tools.executor.py participant Bash as run_bash User->>Main: 输入指令 (如: 列出当前目录的Python文件) Main->>Agent: 构造 messages, 调用 agent_loop(messages) loop 循环(每轮一次tool_call) Agent->>LLM: call_llm(system, messages) LLM-->>Agent: assistant.reply (含tool_calls: 如 run_bash) alt 有 tool_calls Agent->>Executor: execute_tool("run_bash", args) Executor->>Bash: 执行bash命令 Bash-->>Executor: 输出结果(stdout/stderr) Executor-->>Agent: 工具结果 Agent->>Agent: 消息加入工具结果\n进入下一轮 else 没有 tool_calls Agent-->>Main: 回复最终messages Main-->>User: 展示最终答案 end end

说明:每轮循环中大模型(LLM)决定是否要调用工具(如 run_bash)。如需调用,则由 executor 派发处理,结果送回 messages,继续下一轮直到模型不再 tool_call,循环结束。

安装依赖

mkdir harness
cd harness
uv init
uv add openai python-dotenv

启动脚本

cd ..
mkdir project
cd project
uv init
uv add openai python-dotenv
uv run  ../harness/main.py

1.1. .env #

.env

# 设置OpenAI的基础URL
OPENAI_BASE_URL=https://api.deepseek.com
# 设置OpenAI的API密钥
OPENAI_API_KEY=sk-a4ff302bcdb44c73a3a689e340102efa
# 设置主要使用的模型
MODEL_ID=deepseek-v4-pro

1.2. agent.py #

agent.py


# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
from config import (
    DEFAULT_MAX_TOKENS,
    MODEL_ID
)
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict
# 从llm模块导入call_llm函数
from llm import call_llm
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool

# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 获取系统提示词
        system = get_system_prompt()
        # 调用大模型获取回复
        response = call_llm(system, messages, max_tokens,model)
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            return
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or '{}')
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')  
            # 执行工具,获取输出结果
            output = execute_tool(name, args) 
            # 把工具执行结果以特定格式加入消息列表
            messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': output})

1.3. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)

1.4. llm.py #

llm.py

# 从config模块中导入client对象
from config import (
    client
)
# 从tools.schema模块中导入TOOLS常量
from tools.schema import TOOLS

# 定义call_llm函数,参数包括system(系统消息)、messages(消息列表)、max_tokens(最大token数)、model(模型名)
def call_llm(system: str, messages: list, max_tokens: int, model: str):
    # 调用client.chat.completions.create方法生成响应,传入模型名、拼接的消息、工具集合和最大token数
    return client.chat.completions.create(
        model=model,
        # 将系统提示和传入的消息列表组合成messages参数
        messages=[{'role': 'system', 'content': system}, *messages],
        # 传入工具集合
        tools=TOOLS,
        # 传入最大允许的token数
        max_tokens=max_tokens,
    )

1.5. prompt.py #

prompt.py

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
    )
}

# 定义一个函数,返回系统提示语
def get_system_prompt() -> str:
    # 返回字典中'identity'键对应的提示语
    return PROMPT_SECTIONS['identity']

1.6. executor.py #

tools/executor.py


# 导入inspect模块,用于获取函数签名信息
import inspect
# 从tools.handlers模块导入TOOL_HANDLERS字典
from tools.handlers import TOOL_HANDLERS
# 定义execute_tool函数,接收工具名称和参数字典,返回字符串
def execute_tool(name: str, args: dict) -> str:
    # 根据工具名称从TOOL_HANDLERS字典中获取对应的处理函数
    handler = TOOL_HANDLERS.get(name)
    # 如果没有找到处理函数,则返回未知工具提示
    if not handler:
        return f'未知工具:{name}'
    # 获取处理函数的参数签名
    sig = inspect.signature(handler)
    # 从输入参数中筛选出处理函数所需的有效参数
    valid = {k: v for k, v in args.items() if k in sig.parameters}
    # 调用处理函数并返回结果
    return handler(**valid)

1.7. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入subprocess模块,用于执行子进程
import subprocess
# 导入操作系统相关模块
import glob as g
# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == 'nt' and command.strip().lower() == 'date':
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = 'date /t & time /t'
    # 定义危险命令的列表
    dangerous = ['rm -rf /', 'sudo', 'shutdown', 'reboot', '> /dev/']
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return '错误:危险命令已被拦截'
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,            # 要执行的命令
            shell=True,         # 在shell中执行
            cwd=os.getcwd(),    # 当前工作目录设置为当前路径
            capture_output=True,# 捕获标准输出和标准错误
            timeout=120,        # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else '(无输出)'
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return '错误:超时(120 秒)'
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f'错误:{e}'

# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    'bash': run_bash
}

1.8. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
TOOLS = [
    _fn_tool(
        'bash',
        '执行一条 shell 命令。',
        {'command': {'type': 'string'}},
        ['command'],
    )
]

1.9. utils.py #

utils.py

# 定义一个函数assistant_message_dict,参数为message,返回一个字典
def assistant_message_dict(message) -> dict:
    # 使用model_dump方法转换message对象为字典,排除值为None的项
    data = message.model_dump(exclude_none=True)
    # 将字典中的'role'字段设置为'assistant'
    data['role'] = 'assistant'
    # 返回处理后的字典
    return data

# 定义一个函数decode_subprocess_output,参数为data(字节类型或None),返回字符串类型
def decode_subprocess_output(data: bytes | None) -> str:
    # 如果data为None或者为空字节,则返回空字符串
    if not data:
        return ''
    # 依次尝试三种编码方式进行解码
    for encoding in ('utf-8', 'gbk', 'cp936'):
        try:
            # 使用当前编码方式尝试解码,成功则返回结果
            return data.decode(encoding)
        # 如果解码时出现UnicodeDecodeError,则继续尝试下一个编码
        except UnicodeDecodeError:
            continue
    # 如果以上编码都无法解码,则使用utf-8编码并使用replace策略处理错误,并返回结果
    return data.decode('utf-8', errors='replace')

1.10. main.py #

main.py

# 导入 agent_loop 函数从 agent 模块
+from agent import agent_loop

# 定义主函数
def main():
    # 打印提示信息,告诉用户如何退出
+   print('输入问题,回车发送。输入 q 退出。\n')
    # 初始化历史消息列表
+   history = []
    # 进入无限循环,不断接收用户输入
+   while True:
+       try:
            # 获取用户输入,带有提示符
+           query = input('\x1b[36m>> \x1b[0m')
        # 捕获 EOFError 或 KeyboardInterrupt 异常(例如 Ctrl+D 或 Ctrl+C)
+       except (EOFError, KeyboardInterrupt):
            # 异常时退出循环
+           break
+       if not query.strip():
+           continue
        # 如果输入为空,或者用户输入了 'q' 或 'exit',则退出循环
+       if query.strip().lower() in ('q', 'exit', ''):
+           break
        # 将用户输入添加到历史消息列表
+       history.append({'role': 'user', 'content': query})
        # 调用代理循环处理用户和历史消息
+       agent_loop(history)
        # 获取历史列表最后一条消息
+       final = history[-1]
        # 如果最后一条消息是助手回复且有内容则输出该内容
+       if final.get('role') == 'assistant' and final.get('content'):
+           print(final['content'])


# 如果当前脚本作为主程序运行,则调用 main 函数
+if __name__ == '__main__':
+   main()

2. Tool Use — 加一个工具,只加一行 #

"加一个工具,只加一个 handler" — 循环不用动,新工具注册进 TOOLS 和 TOOL_HANDLERS 就行。

本节对应教程 s02,在 s01 的 agent_loop 基础上扩展工具能力:从只有 bash 一个工具,增加到 5 个专用工具。agent.py 和 executor.py 一行不改——模型想读文件时可以直接调 read_file,不必再拼 type xxx.py 这样的 shell 命令。

本节要解决什么

s01 里 Agent 只有 bash。读文件要 type,写文件要 echo ... >,找文件要 dir /s /b,模型多了一层「把意图翻译成 shell 命令」的翻译工作,浪费 token,还容易拼错。

专用工具让模型直接表达意图:

工具 作用 替代 bash 的什么
read_file 读取文件内容,支持 limit 截断 type / cat
write_file 写入文件,自动创建父目录 echo ... >
edit_file 精确替换一段文本(仅一次) sed / 手动编辑
glob 按模式查找文件 dir /s /b / find
bash 保留,处理上述工具覆盖不了的场景 —

相对 s01 的变化

文件 变化 循环是否改动
config.py 新增 WORKDIR、TEXT_ENCODING,启动时 chcp 65001 否
utils.py 新增 safe_path(),限制文件操作在工作区内 否
tools/schema.py TOOLS 从 1 个扩到 5 个 否
tools/handlers.py 新增 4 个 handler,扩充 TOOL_HANDLERS 否
agent.py 无变化 —

工具分发的核心仍是 execute_tool 里的查表逻辑:

handler = TOOL_HANDLERS.get(name)   # 按名称查找
return handler(**valid)             # 调用对应实现

加工具只需两步:在 TOOLS 里加 Schema 定义,在 TOOL_HANDLERS 里加处理函数。agent_loop 的 while True 结构完全不变。

路径安全

read_file、write_file、edit_file 都经过 safe_path() 校验:路径必须落在 WORKDIR 内,防止 ../../etc/passwd 这类越界访问。glob 在匹配后也会二次过滤,只返回工作区内的结果。

试试这些 prompt:

  1. 读取 README.md 文件,并告诉我这个项目是做什么的
  2. 创建一个名为 test.py 的文件,内容为打印 "hello",然后再读取该文件
  3. 查找当前目录下所有的 Python 文件
  4. 同时读取 README.md 和 pyproject.toml,然后生成一个总结文件

观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?\

时序图

以「找到所有 Python 文件并读取 main.py 前 20 行」为例,模型可能连续调用 glob 和 read_file:

sequenceDiagram participant User as 用户 participant Agent as agent_loop participant LLM as call_llm participant Exec as execute_tool participant Map as TOOL_HANDLERS participant Glob as run_glob participant Read as run_read participant Safe as safe_path User->>Agent: 查找 .py 文件并读取 main.py 前 20 行 Agent->>LLM: messages + TOOLS(5个) LLM-->>Agent: tool_calls: glob(pattern="**/*.py") Agent->>Exec: execute_tool("glob", {pattern}) Exec->>Map: TOOL_HANDLERS["glob"] Map->>Glob: run_glob(pattern) Glob-->>Exec: 匹配文件列表 Exec-->>Agent: tool_result Agent->>Agent: append tool result Agent->>LLM: 带上 glob 结果,再次请求 LLM-->>Agent: tool_calls: read_file(path="main.py", limit=20) Agent->>Exec: execute_tool("read_file", {path, limit}) Exec->>Map: TOOL_HANDLERS["read_file"] Map->>Read: run_read(path, limit) Read->>Safe: safe_path(path) Safe-->>Read: 校验通过,返回 Path Read-->>Exec: 文件内容(前 20 行) Exec-->>Agent: tool_result Agent->>Agent: append tool result Agent->>LLM: 带上文件内容,再次请求 LLM-->>Agent: 无 tool_calls,返回最终回答 Agent-->>User: 展示结果

说明:每轮 tool_calls 中的工具名不同,execute_tool 通过 TOOL_HANDLERS 字典分发到不同 handler。文件类工具在执行前必经 safe_path 校验;bash 仍走 subprocess,保留危险命令拦截。

2.1. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 
# 设置工作目录为当前目录
+WORKDIR = Path.cwd()
# Change Code Page 设置命令行编码为UTF-8,UTF-8对应的代码页编号是65001,GBK 对应的代码页编号是 936 
+os.system('chcp 65001')
# 设置文本编码为UTF-8
+TEXT_ENCODING = 'utf-8'
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)

2.2. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入操作系统相关模块
import glob as g
# 导入subprocess模块,用于执行子进程
import subprocess
# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
+from utils import decode_subprocess_output,safe_path
# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
+from config import TEXT_ENCODING,WORKDIR
# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == 'nt' and command.strip().lower() == 'date':
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = 'date /t & time /t'
    # 定义危险命令的列表
    dangerous = ['rm -rf /', 'sudo', 'shutdown', 'reboot', '> /dev/']
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return '错误:危险命令已被拦截'
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,            # 要执行的命令
            shell=True,         # 在shell中执行
            cwd=os.getcwd(),    # 当前工作目录设置为当前路径
            capture_output=True,# 捕获标准输出和标准错误
            timeout=120,        # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else '(无输出)'
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return '错误:超时(120 秒)'
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f'错误:{e}'

# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
+def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
+   try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
+       lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
+       if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
+           lines = lines[:limit] + [f'...(还有 {len(lines) - limit} 行)']
        # 将行列表拼接为字符串并返回
+       return '\n'.join(lines)
    # 捕获所有异常并返回错误信息
+   except Exception as e:
+       return f'错误:{e}'


# 定义写文件函数,参数为路径和内容
+def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
+   try:
        # 使用safe_path校验并获取目标文件路径
+       file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
+       file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
+       file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
+       return f'已写入 {len(content)} 字节到 {path}'
    # 捕获所有异常并返回错误信息
+   except Exception as e:
+       return f'错误:{e}'


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
+def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
+   try:
        # 使用safe_path获取文件路径
+       file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
+       text = file_path.read_text()
        # 如果旧文本不在内容中
+       if old_text not in text:
            # 返回错误提示,未找到指定文本
+           return f'错误:在 {path} 中未找到指定文本'
        # 替换第一次出现的旧文本为新文本,并写回文件
+       file_path.write_text(text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING)
        # 返回编辑成功的提示
+       return f'已编辑 {path}'
    # 捕获所有异常并返回错误信息
+   except Exception as e:
+       return f'错误:{e}'


# 定义glob通配符路径匹配函数,参数为模式
+def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
+   try:
        # 初始化结果列表
+       results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
+       for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
+           if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
+               results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
+       return '\n'.join(results) if results else '(无匹配)'
    # 捕获所有异常并返回错误信息
+   except Exception as e:
+       return f'错误:{e}'

# 定义TOOL_HANDLERS字典,映射工具名到各自处理函数
TOOL_HANDLERS = {
+   'bash': run_bash,
+   'read_file': run_read,
+   'write_file': run_write,
+   'edit_file': run_edit,
+   'glob': run_glob,
}

2.3. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)
    _fn_tool('bash', '执行一条 shell 命令。', {'command': {'type': 'string'}}, ['command']),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
+   _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
+   _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
+   _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
+   _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),
]

2.4. utils.py #

utils.py

# 从 pathlib 库中导入 Path 类,用于管理和操作文件路径
+from pathlib import Path

# 从 config 模块中导入 WORKDIR 变量,表示工作目录路径
+from config import WORKDIR

# 定义一个函数assistant_message_dict,参数为message,返回一个字典
def assistant_message_dict(message) -> dict:
    # 使用model_dump方法转换message对象为字典,排除值为None的项
    data = message.model_dump(exclude_none=True)
    # 将字典中的'role'字段设置为'assistant'
    data['role'] = 'assistant'
    # 返回处理后的字典
    return data

# 定义一个函数decode_subprocess_output,参数为data(字节类型或None),返回字符串类型
def decode_subprocess_output(data: bytes | None) -> str:
    # 如果data为None或者为空字节,则返回空字符串
    if not data:
        return ''
    # 依次尝试三种编码方式进行解码
    for encoding in ('utf-8', 'gbk', 'cp936'):
        try:
            # 使用当前编码方式尝试解码,成功则返回结果
            return data.decode(encoding)
        # 如果解码时出现UnicodeDecodeError,则继续尝试下一个编码
        except UnicodeDecodeError:
            continue
    # 如果以上编码都无法解码,则使用utf-8编码并使用replace策略处理错误,并返回结果
    return data.decode('utf-8', errors='replace')

# 定义一个名为safe_path的函数,接收一个字符串参数p,返回值类型为Path
+def safe_path(p: str) -> Path:
    # 通过将WORKDIR与p拼接,并调用resolve方法,获得绝对路径对象
+   path = (WORKDIR / p).resolve()
    # 判断path路径是否在WORKDIR工作区内,如果不是则抛出异常
+   if not path.is_relative_to(WORKDIR):
        # 抛出ValueError异常,提示路径超出工作区
+       raise ValueError(f'路径超出工作区:{p}')
    # 返回最终安全生成的路径对象
+   return path

3. Permission — 执行前做权限判断 #

"工具执行前先做权限判断" — 安全不能靠信任模型,要靠代码在 execute_tool 之前加一道门。

本节对应教程 s03,在 s02 的五工具基础上新增权限管线。agent_loop 结构不变,唯一变动是:每个 tool_call 执行前先走 check_permission(),未通过则把拒绝原因作为 tool_result 喂回模型,不执行工具。

本节要解决什么

s02 的 safe_path 能拦住越界读写的直接路径,但 bash 仍可执行任意命令。让 Agent「清理一下项目」,它可能生成 del /s /q * 或 rm -rf。

安全不能靠信任模型,要靠代码——在工具执行之前做判断。

三道闸门

check_permission() 按固定顺序串联三道闸门,硬拒绝优先,软询问次之,都没命中才放行:

闸门 触发条件 行为 返回值
1. 禁止列表 bash 命令命中 DENY_LIST(如 sudo、shutdown) 直接拦截,不询问用户 '禁止列表拒绝权限'
2. 破坏性命令 bash 命令命中 DESTRUCTIVE(如 del、rm) 终端提示,等待用户确认 拒绝 → '用户拒绝权限';允许 → 继续
3. 工作区外写入 write_file / edit_file 目标路径超出 WORKDIR 终端提示,等待用户确认 拒绝 → '用户拒绝权限';允许 → 继续
放行 以上均未命中 执行 execute_tool None

s02 的 safe_path() 在 handler 层硬拦截越界路径;本节在权限层对 write_file / edit_file 额外增加用户确认——即使路径校验有漏洞,也还有人工审批兜底。

相对 s02 的变化

文件 变化
permission.py 新增,实现 check_permission() 三道闸门
agent.py 工具执行前调用 check_permission;拒绝时写入 tool_result 并 continue
prompt.py identity 段补充「所有破坏性操作需要用户批准」

拒绝后的处理

权限被拒绝时,不会抛异常、不会中断循环,而是把拒绝原因当作工具输出返回给模型:

reason = check_permission(name, args)
if reason is not None:
    messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": reason + '。'})
    continue   # 跳过 execute_tool,处理下一个 tool_call

模型收到「用户拒绝权限。」后,可以换方案或向用户说明,循环继续。

试试这些 prompt:

  1. 在当前目录创建一个名为 test.txt 的文件(应该直接通过)
  2. 删除 tmp 目录下所有临时文件(bash + rm 会触发闸门 2)
  3. 当前目录下有哪些文件?(只读,全部通过)
  4. 使用write_file工具向c:/目录写入一个名为hello.txt的空文本文件(写工作区外,触发闸门 2)

观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?

时序图

以模型尝试执行 bash: del temp.log 为例,展示权限闸门与执行分支:

sequenceDiagram participant Agent as agent_loop participant LLM as call_llm participant Perm as check_permission participant User as 用户 participant Exec as execute_tool Agent->>LLM: messages + tools LLM-->>Agent: tool_calls: bash(command="del temp.log") Agent->>Agent: print > bash Agent->>Perm: check_permission("bash", {command}) alt 闸门1:命中 DENY_LIST(如 sudo) Perm-->>Agent: "禁止列表拒绝权限" Agent->>Agent: append tool_result,continue Note over Agent,Exec: 不调用 execute_tool else 闸门2:命中 DESTRUCTIVE(如 del ) Perm->>User: ⚠ 可能破坏性的命令,允许?[y/N] alt 用户拒绝 User-->>Perm: N / 回车 Perm-->>Agent: "用户拒绝权限" Agent->>Agent: append tool_result,continue else 用户允许 User-->>Perm: y / yes Perm-->>Agent: None(放行) Agent->>Exec: execute_tool("bash", args) Exec-->>Agent: 命令输出 Agent->>Agent: append tool_result end else 闸门3:write_file 超出 WORKDIR Perm->>User: ⚠ 在工作区外写入,允许?[y/N] User-->>Perm: 选择 y 或拒绝 Perm-->>Agent: None 或 "用户拒绝权限" else 全部通过 Perm-->>Agent: None(放行) Agent->>Exec: execute_tool(name, args) Exec-->>Agent: 工具输出 Agent->>Agent: append tool_result end Agent->>LLM: 带上 tool_result,进入下一轮

说明:拒绝路径与放行路径都会生成 tool_result,保证每个 tool_call 都有对应回复。模型据此调整策略,而不是让循环因异常中断。s04 会把 check_permission 从循环体内移到 Hook 上,循环进一步瘦身。

3.1. permission.py #

permission.py

# 从config模块导入WORKDIR变量
from config import WORKDIR

# 定义禁止执行的命令列表
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]
# 定义需要用户确认的危险命令关键字列表(增加cmd的删除命令)
DESTRUCTIVE = ['rm ', '> /etc/', 'chmod 777', 'del ', 'erase ']

# 三道门禁串联:执行前依次检查每一关卡
def check_permission(tool_name: str, args: dict) -> bool:
    # 如果工具名称是bash,说明要执行shell命令
    if tool_name == 'bash':
        # 遍历每一条禁止执行的命令模式
        for pattern in DENY_LIST:
            # 如果命令行参数中包含禁止模式
            if pattern in args.get('command', ''):
                # 打印红色警告信息,显示被拦截内容
                print(f"\n\x1b[31m⛔ 已拦截:'{pattern}'\x1b[0m")
                # 返回禁止权限的原因(被禁止列表拦截)
                return '禁止列表拒绝权限'
        # 遍历每一条危险关键字
        for kw in DESTRUCTIVE:
            # 如果命令参数中包含危险关键字
            if kw in args.get('command', ''):
                # 打印黄色警告,告知是可能破坏性的命令
                print(f'\n\x1b[33m⚠  可能破坏性的命令\x1b[0m')
                # 显示实际调用的工具和参数
                print(f'   工具: {tool_name}({args})')
                # 提示用户是否允许执行,输入'y'或'yes'才继续
                choice = input('   允许?[y/N] ').strip().lower()
                # 如果用户不是输入“y”或"yes"
                if choice not in ('y', 'yes'):
                    # 返回用户拒绝权限
                    return '用户拒绝权限'
    # 如果工具名称为write_file或edit_file,说明要操作文件
    if tool_name in ('write_file', 'edit_file'):
        # 获取目标文件路径
        path = args.get('path', '')
        # 检查文件是否在WORKDIR工作目录下
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            # 如果不在工作区,提示警告
            print(f'\n\x1b[33m⚠  在工作区外写入\x1b[0m')
            # 显示尝试操作的工具及参数
            print(f'   工具: {tool_name}({args})')
            # 询问用户是否允许
            choice = input('   允许?[y/N] ').strip().lower()
            # 如果不是允许,拒绝权限
            if choice not in ('y', 'yes'):
                # 返回用户拒绝权限
                return '用户拒绝权限'
    # 如果所有检查都通过,则放行,返回None
    return None

3.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
from config import DEFAULT_MAX_TOKENS, MODEL_ID
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict
# 从llm模块导入call_llm函数
from llm import call_llm
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从permission模块导入check_permission函数
+from permission import check_permission


# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 获取系统提示词
        system = get_system_prompt()
        # 调用大模型获取回复
        response = call_llm(system, messages, max_tokens, model)
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            return
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 如果没有通过权限检查
+           reason =  check_permission(name, args)
+           if reason is not None:
                # 将权限被拒绝的信息添加到消息历史中
+               messages.append(
+                   {
                        # 设置消息角色为tool
+                       "role": "tool",
                        # 关联具体的tool_call的id
+                       "tool_call_id": tool_call.id,
                        # 消息内容为“权限被拒绝。”
+                       "content": reason + '。',
+                   }
+               )
                # 跳过本次工具调用,继续下一个
+               continue
            # 执行工具,获取输出结果
            output = execute_tool(name, args)
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )

3.3. prompt.py #

prompt.py

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
+       f'所有破坏性操作需要用户批准。'
    )
}

# 定义一个函数,返回系统提示语
def get_system_prompt() -> str:
    # 返回字典中'identity'键对应的提示语
    return PROMPT_SECTIONS['identity']

4. Hooks — 挂在循环上,不写进循环里 #

"挂在循环上,不写进循环里" — 扩展逻辑通过 Hook 注册,循环只调用 trigger_hooks(),不再直接耦合权限、日志等细节。

本节对应教程 s04,在 s03 权限管线的基础上引入 Hook 扩展点。check_permission() 从 agent_loop 体内移出,改写为 PreToolUse 上的 permission_hook;循环本身只保留三个触发点,新增能力一律 register_hook() 注册,不改循环代码。

本节要解决什么

s03 每加一种检查(记日志、检测大输出、注入工作目录),就要改 agent_loop。循环很快变成:

log_to_file(block)       # 加一行
check_permission(block)  # 加一行
notify_slack(block)      # 又加一行
output = execute(block)
auto_git_add(block)      # 再加一行

你想扩展的是 Agent 的行为,但你改的却是循环本身。Hook 把循环变成稳定核心,扩展挂在外面。

四个事件

事件 触发时机 本节注册的 Hook 返回值语义
UserPromptSubmit 用户输入后、写入 messages 前 workspace_inject_hook 返回 str 则替换 query(可链式叠加)
PreToolUse execute_tool 之前 permission_hook → log_hook 首个非 None 即阻断,作为 tool_result
PostToolUse execute_tool 之后 large_output_hook 仅副作用(打印告警),不阻断
Stop 模型无 tool_calls、即将退出前 summary_hook 返回 str 则注入为 user 消息,强制续跑

PreToolUse 按注册顺序执行:permission_hook 先跑,通过后才到 log_hook。任一返回非 None,后续 Hook 不再执行。

相对 s03 的变化

文件 变化
hooks.py 新增,Hook 注册表 + 5 个内置 Hook + trigger_hooks / trigger_user_prompt_hooks
agent.py check_permission → trigger_hooks('PreToolUse');新增 PostToolUse、Stop 触发点
main.py 用户输入经 trigger_user_prompt_hooks 预处理后再入 history
permission.py 逻辑迁入 permission_hook,循环不再直接引用

Hook 触发规则

# PreToolUse / PostToolUse / Stop:首个非 None 即短路返回
def trigger_hooks(event, *args):
    for callback in HOOKS[event]:
        result = callback(*args)
        if result is not None:
            return result
    return None

# UserPromptSubmit:可链式改写 query
def trigger_user_prompt_hooks(query):
    for callback in HOOKS['UserPromptSubmit']:
        result = callback(current)
        if isinstance(result, str):
            current = result
    return current

Stop Hook 若返回字符串,会作为新的 user 消息注入并 continue 循环——这是「强制续跑」的扩展点(教学版 summary_hook 只打印统计,返回 None)。

试试这些 prompt:

  1. 读取文件 README.md(应直接通过,注意观察 hook 日志)
  2. 创建一个名为 test.txt 的文件(通过后观察是否触发了 PostToolUse)
  3. 删除tmp目录下的所有文件(bash + rm 操作会触发权限 hook)

观察重点:每次工具执行前,是否出现了 [HOOK] 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?

时序图

一次完整问答中,四个 Hook 事件在循环中的挂载位置:

sequenceDiagram participant User as 用户 participant Main as main.py participant UPH as UserPromptSubmit participant Agent as agent_loop participant LLM as call_llm participant Pre as PreToolUse participant Exec as execute_tool participant Post as PostToolUse participant Stop as Stop User->>Main: 输入问题 Main->>UPH: trigger_user_prompt_hooks(query) UPH->>UPH: workspace_inject_hook<br/>注入 <workspace> 目录信息 UPH-->>Main: 改写后的 query Main->>Agent: history.append(user) → agent_loop loop 直到无 tool_calls Agent->>LLM: call_llm(system, messages) LLM-->>Agent: assistant + tool_calls alt 有 tool_calls Agent->>Pre: trigger_hooks("PreToolUse", name, args) Pre->>Pre: permission_hook(权限闸门) alt 被阻断 Pre-->>Agent: "禁止列表拒绝权限" 等 Agent->>Agent: append tool_result,continue else 放行 Pre->>Pre: log_hook(打印调用预览) Pre-->>Agent: None Agent->>Exec: execute_tool(name, args) Exec-->>Agent: output Agent->>Post: trigger_hooks("PostToolUse", name, args, output) Post->>Post: large_output_hook(输出过大告警) Agent->>Agent: append tool_result end else 无 tool_calls Agent->>Stop: trigger_hooks("Stop", messages) Stop->>Stop: summary_hook(统计工具调用次数) alt Stop 返回 force 消息 Stop-->>Agent: force 字符串 Agent->>Agent: append user(force),continue else Stop 返回 None Stop-->>Agent: None Agent-->>Main: return end end end Main-->>User: 打印最终回答

说明:循环体内不再出现 check_permission 等业务函数,只有 trigger_hooks(event, ...) 三个调用点。新增能力(审计、自动 git add、Slack 通知)只需 register_hook('PreToolUse', my_hook),s05 将在此基础上加入 todo_write 计划工具。

4.1. hooks.py #

hooks.py


# 从config模块导入工作目录变量
from config import WORKDIR

# 定义一个钩子字典,每个事件对应一个回调函数列表
HOOKS = {'UserPromptSubmit': [], 'PreToolUse': [], 'PostToolUse': [], 'Stop': []}

# 定义禁止执行的命令列表
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]

# 定义需要用户确认的危险命令关键字列表(增加cmd的删除命令)
DESTRUCTIVE = ['rm ', '> /etc/', 'chmod 777', 'del ', 'erase ']

# 注册钩子函数,将回调添加到对应事件的钩子列表
def register_hook(event: str, callback):
    HOOKS[event].append(callback)

# 注入当前工作目录信息到用户查询
def workspace_inject_hook(query: str) -> str | None:
    # 打印注入工作目录的钩子信息
    print(f'\x1b[90m[HOOK] UserPromptSubmit:注入工作目录 {WORKDIR}\x1b[0m')
    # 返回带有工作目录信息的查询字符串
    return f'<workspace>\n当前工作目录:{WORKDIR}\n</workspace>\n\n{query}'

# 权限控制钩子函数,对命令执行进行校验
def permission_hook(name: str, args: dict):
    # 如果工具类型是bash命令
    if name == 'bash':
        # 检查是否包含禁止列表中的命令
        for pattern in DENY_LIST:
            if pattern in args.get('command', ''):
                # 打印拦截信息
                print(f"\n\x1b[31m⛔ 已拦截:'{pattern}'\x1b[0m")
                # 返回拒绝权限的提示
                return '禁止列表拒绝权限'
        # 检查是否包含破坏性关键字
        for kw in DESTRUCTIVE:
            if kw in args.get('command', ''):
                # 打印警告信息
                print(f'\n\x1b[33m⚠  可能破坏性的命令\x1b[0m')
                print(f'   工具: {name}({args})')
                # 询问用户是否允许
                choice = input('   允许?[y/N] ').strip().lower()
                # 如果用户未确认,拒绝操作
                if choice not in ('y', 'yes'):
                    return '用户拒绝权限'
    # 如果是写文件或编辑文件操作
    if name in ('write_file', 'edit_file'):
        # 获取目标路径
        path = args.get('path', '')
        # 校验路径是否在工作目录下
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            # 警告工作区外写入
            print(f'\n\x1b[33m⚠  在工作区外写入\x1b[0m')
            print(f'   工具: {name}({args})')
            # 询问用户是否允许
            choice = input('   允许?[y/N] ').strip().lower()
            # 如果用户未确认,拒绝操作
            if choice not in ('y', 'yes'):
                return '用户拒绝权限'
    # 返回None表示通过检查
    return None  

# 日志钩子函数,记录调用信息
def log_hook(name: str, args: dict):
    # 取参数前两项并转换为字符串用于预览
    args_preview = str(list(args.values())[:2])[:60]
    # 打印钩子触发信息
    print(f'\x1b[90m[HOOK] {name}({args_preview})\x1b[0m')
    # 无特殊行为,直接返回None
    return None 

# 钩子,处理工具输出过大的情况
def large_output_hook(name: str, args: dict, output):
    # 判断输出长度是否超过10万字符
    if len(str(output)) > 10:
        # 打印输出过大警告
        print(f'\x1b[33m[HOOK] ⚠ {name} 输出过大:{len(str(output))} 字符\x1b[0m')
    # 返回None
    return None  

# 会话统计钩子函数
def summary_hook(messages: list):
    # 统计工具调用的次数
    tool_count = sum(1 for m in messages if m.get('role') == 'tool')
    # 打印工具调用次数信息
    print(f'\x1b[90m[HOOK] Stop:本次会话共使用 {tool_count} 次工具调用\x1b[0m')
    # 无特殊返回,直接None
    return None       

# 注册“用户提交”事件的钩子
register_hook('UserPromptSubmit', workspace_inject_hook)
# 注册“工具使用前”权限检查钩子
register_hook('PreToolUse', permission_hook)
# 注册“工具使用前”日志记录钩子
register_hook('PreToolUse', log_hook)
# 注册“工具使用后”大输出检测钩子
register_hook('PostToolUse', large_output_hook)
# 注册停止事件的会话总结钩子
register_hook('Stop', summary_hook)

# 触发用户输入相关的钩子链
def trigger_user_prompt_hooks(query: str) -> str:
    # 当前待处理的查询
    current = query
    # 依次触发钩子
    for callback in HOOKS['UserPromptSubmit']:
        # 调用每个钩子获取结果
        result = callback(current)
        # 如果返回字符串则更新current
        if isinstance(result, str):
            current = result
    # 返回处理后的查询
    return current

# 通用钩子触发函数
def trigger_hooks(event: str, *args):
    # 按注册顺序依次触发对应事件下的钩子
    for callback in HOOKS[event]:
        # 调用钩子并获取返回值
        result = callback(*args)
        # 如果返回非None则终止并返回
        if result is not None:
            return result
    # 所有钩子都返回None则返回None
    return None

4.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
from config import DEFAULT_MAX_TOKENS, MODEL_ID
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict
# 从llm模块导入call_llm函数
from llm import call_llm
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从hooks模块导入trigger_hooks函数
+from hooks import trigger_hooks


# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 获取系统提示词
        system = get_system_prompt()
        # 调用大模型获取回复
        response = call_llm(system, messages, max_tokens, model)
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
+           force = trigger_hooks('Stop', messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
+           if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
+               messages.append({'role': 'user', 'content': force})
                # 继续while循环,重新进入agent_loop流程
+               continue
            return
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 触发'PreToolUse'钩子,判断是否允许工具执行
+           blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
+           if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
+               messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 执行工具函数,返回输出结果
            output = execute_tool(name, args)
            # 触发'PostToolUse'钩子,进行后置处理
+           trigger_hooks('PostToolUse', name, args, output)
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )

4.3. main.py #

main.py

# 导入 agent_loop 函数从 agent 模块
from agent import agent_loop
# 导入trigger_user_prompt_hooks函数
+from hooks import trigger_user_prompt_hooks
# 定义主函数
def main():
    # 打印提示信息,告诉用户如何退出
    print('输入问题,回车发送。输入 q 退出。\n')
    # 初始化历史消息列表
    history = []
    # 进入无限循环,不断接收用户输入
    while True:
        try:
            # 获取用户输入,带有提示符
            query = input('\x1b[36m>> \x1b[0m')
        # 捕获 EOFError 或 KeyboardInterrupt 异常(例如 Ctrl+D 或 Ctrl+C)
        except (EOFError, KeyboardInterrupt):
            # 异常时退出循环
            break
        # 如果输入为空,或者用户输入了 'q' 或 'exit',则退出循环
        if query.strip().lower() in ('q', 'exit', ''):
            break
        # 触发'UserPromptSubmit'钩子,进行前置处理,返回处理后的用户输入
+       query = trigger_user_prompt_hooks(query)
        # 将用户输入添加到历史消息列表
        history.append({'role': 'user', 'content': query})
        # 调用代理循环处理用户和历史消息
        agent_loop(history)
        # 获取历史列表最后一条消息
        final = history[-1]
        # 如果最后一条消息是助手回复且有内容则输出该内容
        if final.get('role') == 'assistant' and final.get('content'):
            print(final['content'])


# 如果当前脚本作为主程序运行,则调用 main 函数
if __name__ == '__main__':
    main()

5. TodoWrite — 没有计划的 Agent,做着做着就偏了 #

"没有计划的 agent 走哪算哪" — 先列步骤再动手,长任务更不容易漏项。

本节对应教程 s05,在 s04 Hook 体系上新增 todo_write 规划工具,并在循环里加入 reminder 机制:连续 3 轮未更新 todo 时,自动向 messages 注入提醒,防止 Agent 在长任务中偏离原计划。

本节要解决什么

给 Agent 一个复杂任务:「把所有 Python 文件改成 snake_case,然后跑测试,修好失败。」

它改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,忘了最初是「改成 snake_case」——测试失败把注意力全吸走了。

对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。todo_write 让 Agent 在动手前先理清步骤,执行中持续更新状态,把计划锚定在对话里。

todo_write 工具

todo_write 本身不做实际工作——不能读文件、不能跑命令,只管理当前会话内的任务列表:

字段 取值 含义
content 字符串 任务描述
status pending / in_progress / completed 等待中 / 处理中 / 已完成

列表保存在进程内存 CURRENT_TODOS 中,调用时在终端彩色打印进度,并返回 已更新 N 个任务 作为 tool_result。

Reminder 机制

仅靠 system prompt 不够——模型可能连续多轮埋头干活而忘记更新 todo。循环里加了计数器 rounds_since_todo:

# LLM 调用前:连续 3 轮没调 todo_write → 注入提醒
if rounds_since_todo >= 3 and messages:
    messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
    rounds_since_todo = 0

# 每轮有 tool_calls 时 +1
rounds_since_todo += 1

# 调了 todo_write → 归零
if name == 'todo_write':
    rounds_since_todo = 0

提醒以 <reminder> 标签注入,系统催促,不中断循环。

相对 s04 的变化

文件 变化
tools/handlers.py 新增 run_todo_write、CURRENT_TODOS,注册到 TOOL_HANDLERS
tools/schema.py TOOLS 新增 todo_write 定义
agent.py 新增 rounds_since_todo 计数器与 reminder 注入逻辑
prompt.py identity 段补充「多步骤任务前先用 todo_write 规划」

工具分发仍走 s02 的 TOOL_HANDLERS 查表,Hook 管线(PreToolUse / PostToolUse)完全保留,todo_write 同样经过权限和日志 Hook。

试试这些 prompt:`

  1. 重构example/hello.py:添加类型注解、文档字符串和 main 保护(先列出 3 个步骤再执行)
  2. 在example/demo_pkg 下创建一个 Python 包,包含 __init__.py、utils.py 和 tests/test_utils.py
  3. 检查example 下的所有 Python 文件并修复代码风格问题

观察重点:第一次工具调用是不是 todo_write?TODO 列了几步?执行过程中状态有没有从 pending 变成 in_progress / completed?

时序图

以「重构 3 个文件并跑测试」为例,展示规划、执行、提醒、更新的完整流程:

sequenceDiagram participant User as 用户 participant Agent as agent_loop participant LLM as call_llm participant Todo as run_todo_write participant Exec as 其他工具 User->>Agent: 重构 3 个文件并跑测试 Agent->>LLM: call_llm(prompt 含 todo_write 指引) LLM-->>Agent: tool_calls: todo_write(列出 4 步计划) Agent->>Todo: execute_tool("todo_write", todos) Todo->>Todo: 校验 status,更新 CURRENT_TODOS Todo-->>Agent: 终端打印彩色任务列表 Note over Agent: rounds_since_todo = 0 loop 执行各步骤(read / edit / bash) Agent->>Agent: rounds_since_todo += 1 Agent->>LLM: call_llm LLM-->>Agent: tool_calls: read_file / edit_file / bash Agent->>Exec: execute_tool Exec-->>Agent: tool_result end Note over Agent: 连续 3 轮未调 todo_write<br/>rounds_since_todo >= 3 Agent->>Agent: 注入 <reminder>请更新你的 todo 列表。</reminder> Note over Agent: rounds_since_todo = 0 Agent->>LLM: call_llm(含 reminder) LLM-->>Agent: tool_calls: todo_write(标记已完成项) Agent->>Todo: execute_tool("todo_write", 更新后列表) Todo-->>Agent: 已更新 N 个任务 Note over Agent: rounds_since_todo = 0 Agent->>LLM: 继续执行剩余步骤… LLM-->>Agent: 无 tool_calls,任务完成 Agent-->>User: 返回最终回答

说明:todo_write 是会话内的轻量计划(内存存储),与 s12 的持久化任务系统(.tasks/ 文件 + 依赖图)不同。前者防单个 Agent 漂移,后者支撑跨会话协作。s06 将在此基础上引入 task 子 Agent,实现上下文隔离。

5.1. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
from config import DEFAULT_MAX_TOKENS, MODEL_ID
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict
# 从llm模块导入call_llm函数
from llm import call_llm
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
+rounds_since_todo = 0
# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
+   global rounds_since_todo
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 获取系统提示词
        system = get_system_prompt()
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
+       if rounds_since_todo >= 3 and messages:
            # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
+           messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
+           print(f'\x1b[33m> 请更新你的 todo 列表。\x1b[0m')
            # 轮数计数器 rounds_since_todo 复位为 0
+           rounds_since_todo = 0

        # 调用大模型获取回复
        response = call_llm(system, messages, max_tokens, model)
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks('Stop', messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({'role': 'user', 'content': force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
+       rounds_since_todo += 1    
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 执行工具函数,返回输出结果
            output = execute_tool(name, args)
            # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks('PostToolUse', name, args, output)
            # 如果工具名称是 todo_write,则重置轮数计数器
+           if name == 'todo_write':
                # 重置轮数计数器为 0
+               rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )

5.2. hooks.py #

hooks.py


# 从config模块导入工作目录变量
from config import WORKDIR

# 定义一个钩子字典,每个事件对应一个回调函数列表
HOOKS = {'UserPromptSubmit': [], 'PreToolUse': [], 'PostToolUse': [], 'Stop': []}

# 定义禁止执行的命令列表
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]

# 定义需要用户确认的危险命令关键字列表(增加cmd的删除命令)
DESTRUCTIVE = ['rm ', '> /etc/', 'chmod 777', 'del ', 'erase ']

# 注册钩子函数,将回调添加到对应事件的钩子列表
def register_hook(event: str, callback):
    HOOKS[event].append(callback)

# 注入当前工作目录信息到用户查询
def workspace_inject_hook(query: str) -> str | None:
    # 打印注入工作目录的钩子信息
    print(f'\x1b[90m[HOOK] UserPromptSubmit:注入工作目录 {WORKDIR}\x1b[0m')
    # 返回带有工作目录信息的查询字符串
    return f'<workspace>\n当前工作目录:{WORKDIR}\n</workspace>\n\n{query}'

# 权限控制钩子函数,对命令执行进行校验
def permission_hook(name: str, args: dict):
    # 如果工具类型是bash命令
    if name == 'bash':
        # 检查是否包含禁止列表中的命令
        for pattern in DENY_LIST:
            if pattern in args.get('command', ''):
                # 打印拦截信息
                print(f"\n\x1b[31m⛔ 已拦截:'{pattern}'\x1b[0m")
                # 返回拒绝权限的提示
                return '禁止列表拒绝权限'
        # 检查是否包含破坏性关键字
        for kw in DESTRUCTIVE:
            if kw in args.get('command', ''):
                # 打印警告信息
                print(f'\n\x1b[33m⚠  可能破坏性的命令\x1b[0m')
                print(f'   工具: {name}({args})')
                # 询问用户是否允许
                choice = input('   允许?[y/N] ').strip().lower()
                # 如果用户未确认,拒绝操作
                if choice not in ('y', 'yes'):
                    return '用户拒绝权限'
    # 如果是写文件或编辑文件操作
    if name in ('write_file', 'edit_file'):
        # 获取目标路径
        path = args.get('path', '')
        # 校验路径是否在工作目录下
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            # 警告工作区外写入
            print(f'\n\x1b[33m⚠  在工作区外写入\x1b[0m')
            print(f'   工具: {name}({args})')
            # 询问用户是否允许
            choice = input('   允许?[y/N] ').strip().lower()
            # 如果用户未确认,拒绝操作
            if choice not in ('y', 'yes'):
                return '用户拒绝权限'
    # 返回None表示通过检查
    return None  

# 日志钩子函数,记录调用信息
def log_hook(name: str, args: dict):
    # 取参数前两项并转换为字符串用于预览
    args_preview = str(list(args.values())[:2])[:60]
    # 打印钩子触发信息
    print(f'\x1b[90m[HOOK] {name}({args_preview})\x1b[0m')
    # 无特殊行为,直接返回None
    return None 

# 钩子,处理工具输出过大的情况
def large_output_hook(name: str, args: dict, output):
    # 判断输出长度是否超过10万字符
+   if len(str(output)) > 100000:
        # 打印输出过大警告
        print(f'\x1b[33m[HOOK] ⚠ {name} 输出过大:{len(str(output))} 字符\x1b[0m')
    # 返回None
    return None  

# 会话统计钩子函数
def summary_hook(messages: list):
    # 统计工具调用的次数
    tool_count = sum(1 for m in messages if m.get('role') == 'tool')
    # 打印工具调用次数信息
    print(f'\x1b[90m[HOOK] Stop:本次会话共使用 {tool_count} 次工具调用\x1b[0m')
    # 无特殊返回,直接None
    return None       

# 注册“用户提交”事件的钩子
register_hook('UserPromptSubmit', workspace_inject_hook)
# 注册“工具使用前”权限检查钩子
register_hook('PreToolUse', permission_hook)
# 注册“工具使用前”日志记录钩子
register_hook('PreToolUse', log_hook)
# 注册“工具使用后”大输出检测钩子
register_hook('PostToolUse', large_output_hook)
# 注册停止事件的会话总结钩子
register_hook('Stop', summary_hook)

# 触发用户输入相关的钩子链
def trigger_user_prompt_hooks(query: str) -> str:
    # 当前待处理的查询
    current = query
    # 依次触发钩子
    for callback in HOOKS['UserPromptSubmit']:
        # 调用每个钩子获取结果
        result = callback(current)
        # 如果返回字符串则更新current
        if isinstance(result, str):
            current = result
    # 返回处理后的查询
    return current

# 通用钩子触发函数
def trigger_hooks(event: str, *args):
    # 按注册顺序依次触发对应事件下的钩子
    for callback in HOOKS[event]:
        # 调用钩子并获取返回值
        result = callback(*args)
        # 如果返回非None则终止并返回
        if result is not None:
            return result
    # 所有钩子都返回None则返回None
    return None

5.3. prompt.py #

prompt.py

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
+       f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
    )
}

# 定义一个函数,返回系统提示语
def get_system_prompt() -> str:
    # 返回字典中'identity'键对应的提示语
    return PROMPT_SECTIONS['identity']

5.4. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入操作系统相关模块
import glob as g
# 导入subprocess模块,用于执行子进程
import subprocess
# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output,safe_path
# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING,WORKDIR
# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == 'nt' and command.strip().lower() == 'date':
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = 'date /t & time /t'
    # 定义危险命令的列表
    dangerous = ['rm -rf /', 'sudo', 'shutdown', 'reboot', '> /dev/']
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return '错误:危险命令已被拦截'
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,            # 要执行的命令
            shell=True,         # 在shell中执行
            cwd=os.getcwd(),    # 当前工作目录设置为当前路径
            capture_output=True,# 捕获标准输出和标准错误
            timeout=120,        # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else '(无输出)'
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return '错误:超时(120 秒)'
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f'错误:{e}'

# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f'...(还有 {len(lines) - limit} 行)']
        # 将行列表拼接为字符串并返回
        return '\n'.join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f'已写入 {len(content)} 字节到 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f'错误:在 {path} 中未找到指定文本'
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING)
        # 返回编辑成功的提示
        return f'已编辑 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return '\n'.join(results) if results else '(无匹配)'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'

# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
+CURRENT_TODOS: list[dict] = []

# 定义run_todo_write函数,参数为todos列表,返回字符串
+def run_todo_write(todos: list) -> str:
     # 声明使用全局变量CURRENT_TODOS
+    global CURRENT_TODOS
     # 遍历todos列表,获取每个任务及其索引
+    for i, t in enumerate(todos):
         # 如果任务中缺少content或status字段
+        if 'content' not in t or 'status' not in t:
+            # 返回错误提示,指出缺少字段的位置
+            return f'错误:todos[{i}] 缺少 content 或 status'
         # 如果任务的status不是允许的三种状态
+        if t['status'] not in ('pending', 'in_progress', 'completed'):
+            # 返回错误提示,指出状态无效
+            return f"错误:todos[{i}] 的状态无效:{t['status']}"
     # 校验全部通过后,更新全局任务列表
+    CURRENT_TODOS = todos
     # 初始化显示用的lines列表,第一行为标题,并加黄颜色
+    lines = ['\n\x1b[33m## 当前任务\x1b[0m']
     # 遍历所有当前任务
+    for t in CURRENT_TODOS:
         # 根据任务状态,选择不同的彩色标签
+        icon = {'pending': '\x1b[33m等待中\x1b[0m', 'in_progress': '\x1b[36m处理中\x1b[0m', 'completed': '\x1b[32m已完成\x1b[0m'}[t['status']]
         # 将格式化后的任务内容和标签加入lines
+        lines.append(f"  [{icon}] {t['content']}")
     # 将所有内容组合成字符串打印到标准输出
+    print('\n'.join(lines))
     # 返回已更新任务数的字符串提示
+    return f'已更新 {len(CURRENT_TODOS)} 个任务'

# 定义TOOL_HANDLERS字典,映射工具名到各自处理函数
TOOL_HANDLERS = {
    'bash': run_bash,
    'read_file': run_read,
    'write_file': run_write,
    'edit_file': run_edit,
    'glob': run_glob,
+   'todo_write': run_todo_write,
}

5.5. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)
    _fn_tool('bash', '执行一条 shell 命令。', {'command': {'type': 'string'}}, ['command']),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
+   _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),
]

6. Subagent — 大任务拆小,每个拿到的都是干净上下文 #

"大任务拆小,每个小任务干净的上下文" — 子 Agent 用独立 messages[],中间过程丢弃,只回传最终摘要。

本节对应教程 s06,在 s05 基础上新增 spawn_subagent 委派工具。主 Agent 的 agent_loop 一行不改——子 Agent 是嵌套在 run_spawn_subagent 内部的第二个循环,拥有全新的消息列表和受限工具集。

本节要解决什么

Agent 在修一个 bug 时读了 30 个文件追踪调用链,中间聊了 60 轮。messages 涨到 120 条,其中大部分是「追踪过程」,和「修 bug」这个最终目标无关——上下文被中间过程占满,Agent 越来越「健忘」。

子 Agent 就像「开一个新终端」:追踪完了,终端关掉,结果写进笔记,回到原终端继续修 bug。

主 Agent vs 子 Agent

主 Agent 子 Agent
消息列表 history(跨轮累积) 全新 messages[],任务结束即销毁
工具集 TOOLS(含 todo_write、spawn_subagent) BASE_TOOLS(5 个基础工具,禁止递归委派)
系统提示 get_system_prompt() SUB_SYSTEM(「不要继续委派」)
返回值 完整对话 仅最终摘要字符串
安全上限 无硬限 最多 30 轮
Hook 完整 Hook 管线 同样走 PreToolUse / PostToolUse

工具集拆分是关键设计:

BASE_TOOLS = [bash, read_file, write_file, edit_file, glob]   # 子 Agent 可用
TOOLS = [*BASE_TOOLS, todo_write, spawn_subagent]              # 仅主 Agent 可用

子 Agent 的文件操作仍作用于同一 WORKDIR,副作用(写文件、改文件)保留在磁盘上,但对话上下文不污染主 Agent。

run_spawn_subagent 流程

messages = [{'role': 'user', 'content': description}]   # 全新上下文
for _ in range(30):                                      # 安全上限
    response = LLM(SUB_SYSTEM, messages, BASE_TOOLS)
    if not tool_calls: break
    execute tools → append tool_result
return extract_text(最终 assistant 回复)                  # 只回传摘要

相对 s05 的变化

文件 变化
tools/executor.py 新增 run_spawn_subagent,内含嵌套 agent 循环
tools/schema.py 拆分 BASE_TOOLS / TOOLS,主 Agent 多 spawn_subagent
prompt.py 新增 SUB_SYSTEM;identity 段补充委派指引
utils.py 新增 extract_text(),从 assistant 消息提取最终文本
agent.py 无变化(委派通过工具触发)

试试这些 prompt:

  1. 使用子任务查找本项目安装了哪些第三方模块(子 Agent 负责读取文件,主 Agent 只接收结论)
  2. 用 spawn_subagent 工具读取 agents/ 目录下所有 .py 文件,并总结每个文件的功能
  3. 用 spawn_subagent 工具创建 example/string_tools.py,内含 slugify(text: str) 函数,然后让主 Agent 验证该文件

观察重点:是否出现 [Subagent spawned] / [Subagent done]?子 Agent 的工具调用是否以 [sub] ... 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?

时序图

以主 Agent 委派「追踪 utils.py 的调用链」为例:

sequenceDiagram participant User as 用户 participant Main as 主 agent_loop participant LLM as call_llm participant Spawn as run_spawn_subagent participant Sub as 子 Agent 循环 participant SubLLM as 子 LLM participant Tools as BASE_TOOLS User->>Main: 修 bug,先追踪 utils.py 调用链 Main->>LLM: messages + TOOLS(含 spawn_subagent) LLM-->>Main: tool_calls: spawn_subagent(description) Main->>Spawn: execute_tool("spawn_subagent", {description}) Note over Spawn: 打印 [子 Agent 已启动] Spawn->>Sub: messages = [user: description] loop 子 Agent 最多 30 轮 Sub->>SubLLM: SUB_SYSTEM + messages + BASE_TOOLS SubLLM-->>Sub: assistant + tool_calls alt 有 tool_calls(glob / read_file 等) Sub->>Tools: execute_tool(经 PreToolUse Hook) Tools-->>Sub: tool_result Note over Sub: 中间过程留在子 messages,不回主 Agent else 无 tool_calls Sub->>Sub: break end end Spawn->>Spawn: extract_text → 调用链摘要 Note over Spawn: 打印 [子 Agent 完成] Spawn-->>Main: "utils.py 被 agent.py 和 executor.py 引用…" Main->>Main: append tool_result(仅摘要) Main->>LLM: 主 Agent 继续,上下文干净 LLM-->>Main: 基于摘要制定修复方案 Main-->>User: 返回最终回答

说明:子 Agent 的中间 tool 调用(读了哪些文件、输出了什么)全部丢弃,主 Agent 只看到一行摘要。文件系统的改动(如子 Agent 写了笔记文件)仍然保留。s07 将在此基础上引入 load_skill,让 Agent 按需加载领域知识而非全塞 prompt。

6.1. prompt.py #

prompt.py

+from config import WORKDIR
# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
        f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
+       f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
    )
}

# 定义一个函数,返回系统提示语
def get_system_prompt() -> str:
    # 返回字典中'identity'键对应的提示语
    return PROMPT_SECTIONS['identity']

# 定义子任务的系统提示语
+SUB_SYSTEM = (
+    f'你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。'
+   '你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
+   '完成分配给你的任务,然后返回简洁摘要。不要继续委派。'
+)

6.2. executor.py #

tools/executor.py

+import json
# 从utils模块导入assistant_message_dict函数
+from utils import assistant_message_dict,extract_text
# 从config模块导入client实例和主模型PRIMARY_MODEL
+from config import client, MODEL_ID
# 从tools.schema模块导入基础工具列表BASE_TOOLS
+from tools.schema import BASE_TOOLS
# 从prompt模块导入子系统提示SUB_SYSTEM
+from prompt import SUB_SYSTEM
# 从hooks模块导入钩子触发函数
+from hooks import trigger_hooks
# 导入inspect模块,用于获取函数签名信息
import inspect
# 从tools.handlers模块导入TOOL_HANDLERS字典
from tools.handlers import TOOL_HANDLERS
# 定义execute_tool函数,接收工具名称和参数字典,返回字符串
def execute_tool(name: str, args: dict) -> str:
    # 根据工具名称从TOOL_HANDLERS字典中获取对应的处理函数
    handler = TOOL_HANDLERS.get(name)
    # 如果没有找到处理函数,则返回未知工具提示
    if not handler:
        return f'未知工具:{name}'
    # 获取处理函数的参数签名
    sig = inspect.signature(handler)
    # 从输入参数中筛选出处理函数所需的有效参数
    valid = {k: v for k, v in args.items() if k in sig.parameters}
    # 调用处理函数并返回结果
    return handler(**valid)


# 定义运行子 Agent 的函数,参数为描述字符串,返回字符串类型
+def run_spawn_subagent(description: str) -> str:
    # 打印子 Agent 已启动的信息
+   print(f'\n\x1b[35m[子 Agent 已启动]\x1b[0m')
    # 初始化消息列表,用户以描述作为第一条消息
+   messages = [{'role': 'user', 'content': description}]
    # 最多进行30轮消息交互
+   for _ in range(30):
        # 调用OpenAI接口创建一次聊天补全
+       response = client.chat.completions.create(
            # 指定主模型
+           model=MODEL_ID,
            # 系统消息和当前消息历史作为上下文传递
+           messages=[{'role': 'system', 'content': SUB_SYSTEM}, *messages],
            # 指定可用工具
+           tools=BASE_TOOLS,
            # 设置最大token数
+           max_tokens=8000,
+       )
        # 取出assistant回复内容
+       assistant = response.choices[0].message
        # 将assistant回复格式化为dict并加入消息历史
+       messages.append(assistant_message_dict(assistant))
        # 如果assistant没有工具调用,跳出循环
+       if not assistant.tool_calls:
+           break
        # 遍历assistant需要调用的所有工具
+       for tool_call in assistant.tool_calls:
            # 获取工具名称
+           name = tool_call.function.name
            # 获取调用的参数,JSON格式
+           args = json.loads(tool_call.function.arguments or '{}')
            # 调用PreToolUse钩子判断是否被阻止
+           blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止,加入一条tool回复,内容为阻止理由
+           if blocked:
+               messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
+               continue
            # 执行工具,如未注册则提示“未知工具”
+           output = execute_tool(name, args) if name in TOOL_HANDLERS else f'未知工具:{name}'
            # 调用PostToolUse钩子
+           trigger_hooks('PostToolUse', name, args, output)
            # 打印子agent的工具调用及输出内容简略
+           print(f'  \x1b[90m[sub] {name}: {str(output)[:100]}\x1b[0m')
            # 将工具返回的内容添加到消息历史
+           messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': output})
    # 从所有消息的最后一条内容中提取文本为最终结果
+   result = extract_text(messages[-1].get('content'))
    # 如果没有提取到,反向查找assistant角色消息提取结果
+   if not result:
+       for msg in reversed(messages):
            # 只检查assistant回复
+           if msg.get('role') == 'assistant':
                # 提取其内容
+               result = extract_text(msg.get('content'))
+               if result:
+                   break
        # 如果还是没有结果,则说明未给出最终答案
+       if not result:
+           result = '子 Agent 在 30 轮内未给出最终答案。'
    # 打印子 Agent 完成的信息
+   print(f'\x1b[35m[子 Agent 完成]\x1b[0m')
    # 返回最终结果
+   return result
+TOOL_HANDLERS["spawn_subagent"] = run_spawn_subagent

6.3. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
+BASE_TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)
    _fn_tool('bash', '执行一条 shell 命令。', {'command': {'type': 'string'}}, ['command']),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),
-   _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),

]
+TOOLS = [
+   *BASE_TOOLS,
     # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
+   _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),
+   _fn_tool('spawn_subagent', '启动子 Agent 处理复杂子任务。仅返回最终结论。', {'description': {'type': 'string'}}, ['description']),
+]

6.4. utils.py #

utils.py

# 从 pathlib 库中导入 Path 类,用于管理和操作文件路径
from pathlib import Path

# 从 config 模块中导入 WORKDIR 变量,表示工作目录路径
from config import WORKDIR

# 定义一个函数assistant_message_dict,参数为message,返回一个字典
def assistant_message_dict(message) -> dict:
    # 使用model_dump方法转换message对象为字典,排除值为None的项
    data = message.model_dump(exclude_none=True)
    # 将字典中的'role'字段设置为'assistant'
    data['role'] = 'assistant'
    # 返回处理后的字典
    return data

# 定义一个函数decode_subprocess_output,参数为data(字节类型或None),返回字符串类型
def decode_subprocess_output(data: bytes | None) -> str:
    # 如果data为None或者为空字节,则返回空字符串
    if not data:
        return ''
    # 依次尝试三种编码方式进行解码
    for encoding in ('utf-8', 'gbk', 'cp936'):
        try:
            # 使用当前编码方式尝试解码,成功则返回结果
            return data.decode(encoding)
        # 如果解码时出现UnicodeDecodeError,则继续尝试下一个编码
        except UnicodeDecodeError:
            continue
    # 如果以上编码都无法解码,则使用utf-8编码并使用replace策略处理错误,并返回结果
    return data.decode('utf-8', errors='replace')

# 定义一个名为safe_path的函数,接收一个字符串参数p,返回值类型为Path
def safe_path(p: str) -> Path:
    # 通过将WORKDIR与p拼接,并调用resolve方法,获得绝对路径对象
    path = (WORKDIR / p).resolve()
    # 判断path路径是否在WORKDIR工作区内,如果不是则抛出异常
    if not path.is_relative_to(WORKDIR):
        # 抛出ValueError异常,提示路径超出工作区
        raise ValueError(f'路径超出工作区:{p}')
    # 返回最终安全生成的路径对象
    return path

# 定义一个extract_text函数,参数为content,返回字符串类型
+def extract_text(content) -> str:
    # 如果content为None,返回空字符串
+   if content is None:
+       return ''
    # 如果content是字符串类型,直接返回
+   if isinstance(content, str):
+       return content
    # 否则,将content转换为字符串后返回
+   return str(content)

7. Skill Loading — 用到的时候才加载 #

"用到时再加载,别全塞 prompt 里" — 目录常驻 system prompt,完整内容经 load_skill 按需注入 tool_result。

本节对应教程 s07,在 s06 基础上引入 Skills 按需加载机制。项目规范、审查流程、提交文案模板等长文档不再硬编码进 system prompt,而是放在 skills/*/SKILL.md;启动时只扫描 目录(name + description),运行时 Agent 调用 load_skill(name) 才把全文拉进对话。

本节要解决什么

若把 React 规范、SQL 风格、API 文档全部塞进 system prompt,可能占数千 token,而 Agent 改 CSS 时 99% 用不到。更合理的做法是:

层 位置 时机 代价
1. 目录 system prompt 启动时 _scan_skills(),每轮 get_system_prompt() 带上 ~几十 token/技能
2. 全文 tool_result Agent 调用 load_skill(name) 整份 SKILL.md,按需一次

skills 目录结构

skills/
  commit/SKILL.md       # name + description + 正文(YAML frontmatter)
  code-review/SKILL.md

每个 SKILL.md 头部为 frontmatter,由 parse_frontmatter() 解析:

---
name: code-review
description: 只读代码审查,仅输出修改建议…
when_to_use: …
---

加载流程

  1. 启动:skills.py 遍历 SKILLS_DIR,填充 SKILL_REGISTRY(name / description / content)
  2. 每轮 LLM 前:get_system_prompt() → _skills_text() 生成「可用技能」列表注入 system
  3. 按需:模型调用 load_skill("code-review") → 返回完整 SKILL.md 作为 tool_result 进入 messages

load_skill 仅注册在 主 Agent 的 TOOLS 中,不在 子 Agent 的 BASE_TOOLS——子任务不需要加载领域技能。

相对 s06 的变化

文件 变化
skills.py 新增,扫描注册表 + load_skill()
config.py 新增 SKILLS_DIR = WORKDIR / 'skills'
prompt.py 分段组装 system prompt,注入技能目录
utils.py 新增 parse_frontmatter()
tools/schema.py TOOLS 新增 load_skill
tools/handlers.py TOOL_HANDLERS 注册 load_skill
agent.py 无变化(仍经 execute_tool 分发)

试试这些 prompt: skills

  1. 有哪些可用的技能?
  2. 加载 code-review 技能并按照其说明操作

观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 [HOOK] load_skill?加载后回答是否使用了对应 skill 的说明?

时序图

以用户请求「按 code-review 技能审查 agent.py」为例:

sequenceDiagram participant Start as 进程启动 participant Scan as _scan_skills participant Agent as agent_loop participant Prompt as get_system_prompt participant LLM as call_llm participant Load as load_skill participant Registry as SKILL_REGISTRY Start->>Scan: 扫描 skills/*/SKILL.md Scan->>Registry: 写入 name / description / content Note over Registry: commit, code-review … Agent->>Prompt: get_system_prompt() Prompt->>Registry: _skills_text() 只取 description Prompt-->>Agent: identity + workspace + 可用技能列表 Note over Prompt: 不含 SKILL.md 全文 Agent->>LLM: system(含目录)+ messages + TOOLS LLM-->>Agent: tool_calls: load_skill(name="code-review") Agent->>Load: execute_tool("load_skill", …) Load->>Registry: 查找 code-review Registry-->>Load: 完整 SKILL.md 正文 Load-->>Agent: tool_result(全文进入 messages) Note over Agent: 此后模型按 skill 约束只读审查 Agent->>LLM: 带上 skill 全文 + 用户任务 LLM-->>Agent: read_file / glob 等(按 skill 白名单) Agent-->>Agent: 输出审查建议,不改代码

说明:目录层让模型「知道有哪些技能可选」;全文层只在任务需要时才占上下文。新增技能只需在 skills/ 下加子目录和 SKILL.md,重启后自动注册,无需改 agent_loop。s08 将处理上下文满时的压缩策略。

7.1. skills.py #

skills.py

# 从config模块导入SKILLS_DIR(技能目录)和TEXT_ENCODING(文本编码格式)
from config import SKILLS_DIR, TEXT_ENCODING
# 从utils模块导入parse_frontmatter方法
from utils import parse_frontmatter
# 定义一个全局字典,用于存放技能信息,键为字符串,值为字典
SKILL_REGISTRY: dict[str, dict] = {}

# 定义一个私有函数,用于扫描技能目录下的所有技能
def _scan_skills():
    # 如果技能目录不存在,则直接返回
    if not SKILLS_DIR.exists():
        return
    # 遍历技能目录下的所有子目录,按名称排序
    for d in sorted(SKILLS_DIR.iterdir()):
        # 如果不是目录,则跳过
        if not d.is_dir():
            continue
        # 构造manifest文件(SKILL.md)的路径
        manifest = d / 'SKILL.md'
        # 如果manifest文件不存在,则跳过
        if not manifest.exists():
            continue
        # 读取manifest文件内容,指定编码方式和错误处理方式
        raw = manifest.read_text(encoding=TEXT_ENCODING, errors='replace')
        # 解析frontmatter,获取元信息和正文内容
        meta, _body = parse_frontmatter(raw)
        # 获取技能名称,优先元信息中的name字段,否则用目录名
        name = meta.get('name', d.name)
        # 获取技能描述,优先元信息中的description字段,否则用第一行内容
        desc = meta.get('description', raw.split('\n')[0].lstrip('#').strip())
        # 将技能信息存入全局注册表
        SKILL_REGISTRY[name] = {'name': name, 'description': desc, 'content': raw}

# 定义一个函数,根据技能名加载技能内容
def load_skill(name: str) -> str:
    # 从注册表中获取技能信息
    skill = SKILL_REGISTRY.get(name)
    # 如果没有找到,返回提示信息
    if not skill:
        return f'未找到技能:{name}'
    # 在控制台打印加载提示(带颜色)
    print(f'\x1b[90m[技能] 已加载 {name}\x1b[0m')
    # 返回技能内容
    return skill['content']

# 调用扫描函数,初始化技能注册表
_scan_skills()

7.2. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# 设置命令行编码为UTF-8
os.system('chcp 65001')
# 设置文本编码为UTF-8
TEXT_ENCODING = 'utf-8'
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)
# 设置技能目录为工作目录下的 skills 目录
+SKILLS_DIR = WORKDIR / 'skills'

7.3. prompt.py #

prompt.py

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR
# 从 skills 模块导入技能注册表 SKILL_REGISTRY
+from skills import SKILL_REGISTRY
# 定义提示片段的字典,包含系统身份/工作目录/技能部分
PROMPT_SECTIONS = {
    # 'identity' 键,存储智能体的系统身份提示,强调直接行动等规则
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
        f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
+   ),
    # 'workspace' 键,对应当前的工作目录描述
+   'workspace': f'工作目录:{WORKDIR}',
    # 'skill' 键,指明需要完整技术文档时的指引
+   'skill': '需要完整技术说明时,使用 load_skill 加载相关文档。'
}

# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
+def _assemble_system_prompt(skills: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
+   sections = [PROMPT_SECTIONS['identity'], PROMPT_SECTIONS['workspace']]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
+   if skills:
+       sections.append(f'可用技能:\n{skills}')
+       sections.append(PROMPT_SECTIONS['skill'])
    # 用两个换行符拼接所有片段并返回完整的系统提示
+   return '\n\n'.join(sections)

# 定义一个私有函数,生成所有注册技能的简介文本
+def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
+   if not SKILL_REGISTRY:
+       return ''
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
+   return '\n'.join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())

# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 内部调用技能文本拼接及总装配函数
+   return _assemble_system_prompt(_skills_text())

# 定义子 Agent 的系统提示,用于子任务调用
SUB_SYSTEM = (
    # 子 Agent 的身份/环境要求/任务完成后要求返回摘要
    f'你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。'
    '你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
    '完成分配给你的任务,然后返回简洁摘要。不要继续委派。'
)

7.4. handlers.py #

tools/handlers.py

# 导入操作系统相关模块
import os
# 导入操作系统相关模块
import glob as g
# 导入子进程处理模块
import subprocess
# 从utils模块导入decode_subprocess_output和safe_path函数
from utils import decode_subprocess_output, safe_path
# 从config模块导入文本编码配置
+from config import TEXT_ENCODING,WORKDIR
# 从skills模块导入load_skill函数
+from skills import load_skill
# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == 'nt' and command.strip().lower() == 'date':
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = 'date /t & time /t'
    # 定义危险命令的列表
    dangerous = ['rm -rf /', 'sudo', 'shutdown', 'reboot', '> /dev/']
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return '错误:危险命令已被拦截'
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,            # 要执行的命令
            shell=True,         # 在shell中执行
            cwd=os.getcwd(),    # 当前工作目录设置为当前路径
            capture_output=True,# 捕获标准输出和标准错误
            timeout=120,        # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else '(无输出)'
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return '错误:超时(120 秒)'
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f'错误:{e}'

# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f'...(还有 {len(lines) - limit} 行)']
        # 将行列表拼接为字符串并返回
        return '\n'.join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f'已写入 {len(content)} 字节到 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f'错误:在 {path} 中未找到指定文本'
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING)
        # 返回编辑成功的提示
        return f'已编辑 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return '\n'.join(results) if results else '(无匹配)'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'

CURRENT_TODOS: list[dict] = []

def run_todo_write(todos: list) -> str:
    global CURRENT_TODOS
    for i, t in enumerate(todos):
        if 'content' not in t or 'status' not in t:
            return f'错误:todos[{i}] 缺少 content 或 status'
        if t['status'] not in ('pending', 'in_progress', 'completed'):
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    CURRENT_TODOS = todos
    lines = ['\n\x1b[33m## 当前任务\x1b[0m']
    for t in CURRENT_TODOS:
        icon = {'pending': '\x1b[33m等待中\x1b[0m', 'in_progress': '\x1b[36m处理中\x1b[0m', 'completed': '\x1b[32m已完成\x1b[0m'}[t['status']]
        lines.append(f"  [{icon}] {t['content']}")
    print('\n'.join(lines))
    return f'已更新 {len(CURRENT_TODOS)} 个任务'


# 定义TOOL_HANDLERS字典,映射工具名到各自处理函数
TOOL_HANDLERS = {
    'bash': run_bash,#执行shell命令
    'read_file': run_read,#读取文件内容
    'write_file': run_write,#写入文件内容
    'edit_file': run_edit,#编辑文件内容
    'glob': run_glob,#通配符路径匹配
    'todo_write': run_todo_write,#创建并管理当前编码会话的任务列表
+   'load_skill': load_skill,#按名称加载技能的完整内容
}

7.5. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)
    _fn_tool('bash', '执行一条 shell 命令。', {'command': {'type': 'string'}}, ['command']),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),

]
TOOLS = [
    *BASE_TOOLS,
     # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),
    _fn_tool('spawn_subagent', '启动子 Agent 处理复杂子任务。仅返回最终结论。', {'description': {'type': 'string'}}, ['description']),
+   _fn_tool('load_skill', '按名称加载技能的完整内容。', {'name': {'type': 'string'}}, ['name']),
]

7.6. utils.py #

utils.py

# 从 pathlib 库中导入 Path 类,用于管理和操作文件路径
from pathlib import Path

# 从 config 模块中导入 WORKDIR 变量,表示工作目录路径
from config import WORKDIR

# 定义一个函数assistant_message_dict,参数为message,返回一个字典
def assistant_message_dict(message) -> dict:
    # 使用model_dump方法转换message对象为字典,排除值为None的项
    data = message.model_dump(exclude_none=True)
    # 将字典中的'role'字段设置为'assistant'
    data['role'] = 'assistant'
    # 返回处理后的字典
    return data

# 定义一个函数decode_subprocess_output,参数为data(字节类型或None),返回字符串类型
def decode_subprocess_output(data: bytes | None) -> str:
    # 如果data为None或者为空字节,则返回空字符串
    if not data:
        return ''
    # 依次尝试三种编码方式进行解码
    for encoding in ('utf-8', 'gbk', 'cp936'):
        try:
            # 使用当前编码方式尝试解码,成功则返回结果
            return data.decode(encoding)
        # 如果解码时出现UnicodeDecodeError,则继续尝试下一个编码
        except UnicodeDecodeError:
            continue
    # 如果以上编码都无法解码,则使用utf-8编码并使用replace策略处理错误,并返回结果
    return data.decode('utf-8', errors='replace')

# 定义一个名为safe_path的函数,接收一个字符串参数p,返回值类型为Path
def safe_path(p: str) -> Path:
    # 通过将WORKDIR与p拼接,并调用resolve方法,获得绝对路径对象
    path = (WORKDIR / p).resolve()
    # 判断path路径是否在WORKDIR工作区内,如果不是则抛出异常
    if not path.is_relative_to(WORKDIR):
        # 抛出ValueError异常,提示路径超出工作区
        raise ValueError(f'路径超出工作区:{p}')
    # 返回最终安全生成的路径对象
    return path

# 定义一个extract_text函数,参数为content,返回字符串类型
def extract_text(content) -> str:
    # 如果content为None,返回空字符串
    if content is None:
        return ''
    # 如果content是字符串类型,直接返回
    if isinstance(content, str):
        return content
    # 否则,将content转换为字符串后返回
    return str(content)

# 定义parse_frontmatter函数,参数为text,返回一个元组(字典,字符串)
+def parse_frontmatter(text: str) -> tuple[dict, str]:
    # 如果text不是以'---'开头,则直接返回空字典和原始文本
+   if not text.startswith('---'):
+       return {}, text
    # 用'---'分割文本,最多分割2次,得到3段内容
+   parts = text.split('---', 2)
    # 如果分割出来的部分不足3个,说明无有效frontmatter,返回空字典和原始文本
+   if len(parts) < 3:
+       return {}, text
    # 新建一个空字典,用于存储frontmatter的键值对
+   meta = {}
    # 遍历frontmatter内容区域的每一行
+   for line in parts[1].strip().splitlines():
        # 如果该行包含冒号,认为是key:value格式
+       if ':' in line:
            # 以冒号分割该行成键和值(只分一次)
+           k, v = line.split(':', 1)
            # 去掉键和值两端空白,并将值两端的引号去除,存入字典
+           meta[k.strip()] = v.strip().strip('"').strip("'")
    # 返回已解析好的meta字典和去除空白后的正文内容
+   return meta, parts[2].strip()

8. Context Compact — 上下文总会满,要有办法腾地方 #

"上下文总会满,要有办法腾地方" — 便宜的先跑,贵的后跑;四层预处理器 + 应急摘要,让 Agent 能在大项目里长跑。

本节对应教程 s08,在 s07 基础上新增 压缩管线。Agent 读大量文件、跑多轮工具后,messages 会膨胀直至 API 拒绝(prompt_too_long)。本节在 每轮 LLM 调用前 插入四层预处理(0 次额外 API),超限时自动 LLM 摘要(1 次 API),API 报错时再 响应式压缩。

本节要解决什么

Agent 读了 30 个文件、跑了 20 条命令,每条输出都堆在 messages 里。上下文窗口有限,满了之后 API 直接拒绝。不压缩,Agent 无法在长会话里持续工作。

四层压缩管线

每轮 call_llm 之前按固定顺序执行(agent.py 内):

层级 函数 触发条件 动作 API 代价
L0 tool_result_budget 所有 tool 内容总字节 > MAX_BYTES 超大结果落盘 .task_outputs/tool-results/,messages 里留路径+预览 0
L1 snip_compact 消息条数 > 50 保留头 3 + 尾 47,中间替换为 [已裁剪 N 条消息] 0
L2 micro_compact tool 消息 > KEEP_RECENT(3) 较早 tool 结果换占位符 0
L3 compact_history estimate_size > CONTEXT_LIMIT 写 transcript → LLM 摘要 → 替换为一条 [已压缩] 1

压缩后统一走 repair_message_chain():补全缺失的 tool 响应、移除孤立的 tool 消息(裁剪后 assistant/tool 配对可能断裂)。

超大输出落盘

read_file 或 bash 返回上万字符时,persist_large_output() 写入 .task_outputs/tool-results/{tool_call_id}.txt,messages 中替换为:

<persisted-output>
完整输出:path/to/file.txt
预览:(前 2000 字符)
</persisted-output>

需要全文时 Agent 可再 read_file 该路径。

响应式压缩 vs 主动压缩

路径 触发 行为
自动 estimate_size > CONTEXT_LIMIT compact_history:摘要替换全部历史
响应式 call_llm 抛出 prompt too long reactive_compact:摘要 + 保留最近 5 条
主动 模型调用 compact 工具 同 compact_history

摘要前都会 write_transcript() 到 .transcripts/transcript_{timestamp}.jsonl,避免信息彻底丢失。

相对 s07 的变化

文件 变化
history.py 新增,压缩管线全部函数
agent.py LLM 前跑四层压缩 + repair_message_chain;捕获 prompt too long;处理 compact 工具
config.py 新增 PERSIST_THRESHOLD、MAX_BYTES、KEEP_RECENT、CONTEXT_LIMIT、目录路径
llm.py 新增 is_prompt_too_long_error()
tools/schema.py TOOLS 新增 compact
prompt.py identity 段补充「上下文过长时可使用 compact」

试试这些 prompt:

  1. 读取当前目录下面的红楼梦.txt并总结故事梗概
  2. 读取 README.md 文件,然后读取 code.py,再读取 example/README.md(连续读取多个文件,观察 L2 压缩旧结果的情况)
  3. 读取 example/ 目录下的所有文件(一次性读取大量内容,观察 L3 将内容落盘)

观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要?

时序图

长会话中一轮循环的压缩与 LLM 调用流程:

sequenceDiagram participant Agent as agent_loop participant Hist as history 压缩管线 participant LLM as call_llm participant Disk as 落盘/转录 Agent->>Agent: while True 新一轮 Agent->>Hist: tool_result_budget(messages) alt tool 输出总量超限 Hist->>Disk: 超大内容 → .task_outputs/tool-results/ Hist-->>Agent: messages 替换为预览+路径 end Agent->>Hist: snip_compact(messages) Note over Hist: >50 条 → 头3+尾47 Agent->>Hist: micro_compact(messages) Note over Hist: 旧 tool 结果 → 占位符 alt estimate_size > CONTEXT_LIMIT Agent->>Hist: compact_history(messages) Hist->>Disk: write_transcript → .transcripts/ Hist->>LLM: summarize_history(1 次 API) Hist-->>Agent: messages = [已压缩] + 摘要 end Agent->>Hist: repair_message_chain(messages) Note over Hist: 修复 assistant/tool 配对 Agent->>LLM: call_llm(system, messages) alt prompt too long 异常 LLM-->>Agent: Exception Agent->>Hist: reactive_compact(messages) Hist->>Disk: write_transcript Hist->>LLM: summarize_history Hist-->>Agent: 摘要 + 最近 5 条 Agent->>Agent: continue 重试 else 正常返回 LLM-->>Agent: assistant + tool_calls alt 模型调用 compact 工具 Agent->>Hist: compact_history(messages) Note over Agent: break,下一轮继续 else 普通工具 Agent->>Agent: execute_tool → append tool_result end end

说明:L0–L2 是纯本地操作,不消耗 API;只有 L3 摘要和 reactive_compact 才调用 LLM。核心循环结构不变,只是在 call_llm 前加了预处理管道。s09 将解决「压缩会丢细节」——引入跨会话 .memory/ 记忆层。

8.1. history.py #

history.py

# 从 config 模块导入配置常量
from config import (MAX_BYTES,PERSIST_THRESHOLD,TOOL_RESULTS_DIR,TEXT_ENCODING,KEEP_RECENT,TRANSCRIPT_DIR,client,MODEL_ID)
import json
import time
# 定义函数,用于持久化较大的输出内容
def persist_large_output(tool_call_id: str, output: str) -> str:
    # 如果输出内容未超过阈值,则直接返回原内容
    if len(output) <= PERSIST_THRESHOLD:
        return output
    # 创建工具输出目录(如果不存在则创建)
    TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)
    # 构建持久化内容的文件路径
    path = TOOL_RESULTS_DIR / f'{tool_call_id}.txt'
    # 如果文件还不存在,则写入输出内容
    if not path.exists():
        path.write_text(output, encoding=TEXT_ENCODING)
    # 返回包含完整输出文件路径和部分预览内容的字符串
    return (
        f'<persisted-output>\n完整输出:{path}\n预览:\n{output[:2000]}\n</persisted-output>'
    )

# 定义函数,用于管理工具类型消息的总内容体积预算
def tool_result_budget(messages: list, max_bytes: int = MAX_BYTES) -> list:
    # 获取所有 role 为 tool 的消息下标列表
    indices = [i for i, m in enumerate(messages) if m.get('role') == 'tool']
    # 如果没有 tool 类型消息,直接返回原消息列表
    if not indices:
        return messages
    # 计算所有 tool 消息内容的总字节数
    total = sum(len(str(messages[i].get('content', ''))) for i in indices)
    # 如果总字节数未超出最大限制,直接返回原消息列表
    if total <= max_bytes:
        return messages
    # 按消息内容长度从大到小对 tool 消息下标进行排序
    ranked = sorted(indices, key=lambda i: len(str(messages[i].get('content', ''))), reverse=True)
    # 遍历排序后的 tool 消息下标
    for i in ranked:
        # 如果总字节数已小于等于最大限制,则停止处理
        if total <= max_bytes:
            break
        # 获取当前消息
        msg = messages[i]
        # 获取消息内容,转为字符串
        content = str(msg.get('content', ''))
        # 如内容长度未超过持久化阈值,跳过
        if len(content) <= PERSIST_THRESHOLD:
            continue
        # 获取工具调用的 id,默认为 unknown
        tid = msg.get('tool_call_id', 'unknown')
        # 将较大的输出内容持久化,并替换为简略信息
        msg['content'] = persist_large_output(tid, content)
        # 重新计算所有 tool 消息内容的总字节数
        total = sum(len(str(messages[j].get('content', ''))) for j in indices)
    # 返回处理后的消息列表
    return messages

# 修复消息链,确保每个assistant的tool_call都能收到tool响应,同时移除孤立的tool消息
def repair_message_chain(messages: list) -> list:
    # 补全缺失的 tool 响应,移除孤立的 tool 消息
    """补全缺失的 tool 响应,移除孤立的 tool 消息。"""
    # 如果消息列表为空,直接返回
    if not messages:
        return messages

    # 初始化用于保存修复后的消息的列表
    repaired: list[dict] = []
    # 记录需要等待tool响应的tool_call_id集合
    pending_ids: set[str] = set()

    # 内部函数:将当前等待的tool_call_id用reason伪造tool消息并清空待完成集合
    def flush_pending(reason: str):
        # 声明要修改外围作用域的pending_ids
        nonlocal pending_ids
        # 遍历所有等待补全的tool_call_id,添加伪造tool响应
        for tool_call_id in pending_ids:
            repaired.append({
                'role': 'tool',
                'tool_call_id': tool_call_id,
                'content': reason,
            })
        # 清空等待集合
        pending_ids = set()

    # 遍历所有消息
    for msg in messages:
        # 获取当前消息的角色role
        role = msg.get('role')

        # 如果是assistant角色的消息
        if role == 'assistant':
            # 为之前等待的tool_call_id补全缺失的工具响应
            flush_pending('[工具响应缺失,已自动补全]')
            # 添加当前assistant消息到结果列表
            repaired.append(msg)
            # 获取辅助消息中的tool_calls字段(可能没有)
            tool_calls = msg.get('tool_calls') or []
            # 提取本assistant消息关联的所有tool调用id
            pending_ids = {
                tc.get('id') for tc in tool_calls
                if isinstance(tc, dict) and tc.get('id')
            }
            # 进入下一个消息
            continue

        # 如果是tool消息
        if role == 'tool':
            # 获取当前tool消息的tool_call_id
            tool_call_id = msg.get('tool_call_id')
            # 如果tool_call_id有效且在待补全集合中
            if tool_call_id and tool_call_id in pending_ids:
                # 添加此tool消息到修复后的结果
                repaired.append(msg)
                # 标记此id已完成,移除出pending
                pending_ids.discard(tool_call_id)
            # 进入下一个消息
            continue

        # 除assistant与tool外(通常为user或system),补全所有pending tool响应
        flush_pending('[工具响应缺失,已自动补全]')
        # 添加当前消息到结果
        repaired.append(msg)

    # 循环结束后,最后再补全一次所有仍待补全的tool响应
    flush_pending('[工具响应缺失,已自动补全]')
    # 返回修复后的消息链
    return repaired

# 裁剪消息列表至最大条数,并插入说明信息,再交由repair_message_chain处理
def snip_compact(messages: list, max_messages: int = 50) -> list:
    # 如果消息总数未超过最大限制,直接返回
    if len(messages) <= max_messages:
        return messages
    # 指定头部和尾部分别保留的消息条数
    keep_head, keep_tail = 3, max_messages - 3
    # 计算被裁剪(省略)的消息条数
    snipped = len(messages) - keep_head - keep_tail
    # 构造裁剪后的消息列表,头+说明+尾
    compacted = (
        messages[:keep_head]
        + [{'role': 'user', 'content': f'[已裁剪 {snipped} 条消息]'}]
        + messages[-keep_tail:]
    )
    # 修复裁剪后的消息链
    return repair_message_chain(compacted)

# 定义一个函数,用于收集所有role为'tool'的消息及其索引
def collect_tool_messages(messages: list) -> list[tuple[int, dict]]:
    # 遍历消息列表,筛选出role为'tool'的消息,并返回索引和消息的元组列表
    return [(i, m) for i, m in enumerate(messages) if m.get('role') == 'tool']

# 定义一个函数,用于对较早的工具消息进行内容压缩
def micro_compact(messages: list) -> list:
    # 收集所有tool类型的消息及其索引
    tool_msgs = collect_tool_messages(messages)
    # 如果tool消息数量不超过设定的保留数,则直接返回原消息列表
    if len(tool_msgs) <= KEEP_RECENT:
        return messages
    # 遍历除最近保留的tool消息以外的其它tool消息
    for _i, msg in tool_msgs[:-KEEP_RECENT]:
        # 获取tool消息的内容字段,并确保为字符串类型
        content = str(msg.get('content', ''))
        # 如果内容长度超过120个字符,则进行内容压缩
        if len(content) > 120:
            # 将内容替换为压缩提示信息
            msg['content'] = '[较早的工具结果已压缩。需要时请重新运行。]'
    # 返回消息列表(已就地修改)
    return messages
# 定义函数estimate_size,计算消息列表的字符串长度
def estimate_size(msgs: list) -> int:
    # 将消息列表转换为字符串后取长度作为大小估算
    return len(str(msgs)) 

# 定义函数write_transcript,用于将消息记录写入jsonl文件
def write_transcript(messages: list):
    # 确保转录文件夹存在,不存在则创建
    TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
    # 拼接生成转录文件的路径(以时间戳命名)
    path = TRANSCRIPT_DIR / f'transcript_{int(time.time())}.jsonl'
    # 以utf-8编码打开文件,准备写入
    with path.open('w', encoding=TEXT_ENCODING) as f:
        # 遍历每条消息
        for msg in messages:
            # 将每条消息转为json字符串并写入文件,每条一行
            f.write(json.dumps(msg, default=str, ensure_ascii=False) + '\n')
    # 返回保存的文件路径
    return path 

# 定义summarize_history函数,用于总结对话历史
def summarize_history(messages: list) -> str:
    # 将消息列表转换为json字符串,并裁剪至最多8万字符
    conversation = json.dumps(messages, default=str, ensure_ascii=False)[:80000]
    # 构造汇总用的prompt,要求对对话进行总结
    prompt = (
        '总结以下编程 Agent 对话,以便继续工作。\n'
        '保留:1. 当前目标 2. 关键发现/决策 3. 读/改过的文件 '
        '4. 剩余工作 5. 用户约束。\n简洁但具体。\n\n'
        + conversation
    )
    # 调用大模型生成摘要
    response = client.chat.completions.create(
        model=MODEL_ID,
        messages=[{'role': 'user', 'content': prompt}],
        max_tokens=2000,
    )
    # 返回摘要文本,去除首尾空白,如为空则返回'(空摘要)'
    return (response.choices[0].message.content or '').strip() or '(空摘要)'          

# 定义compact_history函数,对历史消息进行压缩
def compact_history(messages: list) -> list:
    # 写入转录文件并保存路径
    transcript_path = write_transcript(messages)
    # 打印转录保存的提示
    print(f'[转录已保存: {transcript_path}]')
    # 对历史消息进行摘要压缩
    summary = summarize_history(messages)
    # 返回仅包含摘要文本的新消息列表(以user身份)
    return [{'role': 'user', 'content': f'[已压缩]\n\n{summary}'}]

# 定义一个对历史消息进行响应式压缩的函数
def reactive_compact(messages: list) -> list:
    # 写入历史消息到转录文件
    write_transcript(messages)
    # 对历史消息进行摘要,获得总结内容
    summary = summarize_history(messages)
    # 生成一个压缩后的消息链(带上用户摘要+最近5条原始消息),并进行修正处理
    return repair_message_chain([
        # 构造一条带有响应式压缩标签和摘要内容的用户消息
        {'role': 'user', 'content': f'[响应式压缩]\n\n{summary}'},
        # 保留消息列表中的最后5条历史消息
        *messages[-5:],
    ])

8.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
+from config import DEFAULT_MAX_TOKENS, MODEL_ID,CONTEXT_LIMIT
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict
# 从llm模块导入call_llm函数
+from llm import call_llm,is_prompt_too_long_error
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
+from history import (tool_result_budget,snip_compact,micro_compact,estimate_size,compact_history,repair_message_chain,reactive_compact)
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0
# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 获取系统提示词
        system = get_system_prompt()
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
+       messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
+       messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
+       messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
+       if estimate_size(messages) > CONTEXT_LIMIT:
+           print('[自动压缩]')
+           messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
+       messages[:] = repair_message_chain(messages)    
        if rounds_since_todo >= 3 and messages:
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            rounds_since_todo = 0
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
        if rounds_since_todo >= 3 and messages:
            # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            # 轮数计数器 rounds_since_todo 复位为 0
            rounds_since_todo = 0
        # 尝试执行以下代码块
+       try:
            # 调用大模型获取回复,传入系统提示、消息列表、最大token数和模型名
+           response = call_llm(system, messages, max_tokens, model)
        # 捕获所有异常并命名为e
+       except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
+           if is_prompt_too_long_error(e):
                # 对消息列表进行反应式压缩,减少长度
+               messages[:] = reactive_compact(messages)
                # 跳过本次循环,继续下一次
+               continue
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks('Stop', messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({'role': 'user', 'content': force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1  
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 如果工具名称是'compact'
+           if name == 'compact':
                # 调用compact_history函数,对messages列表进行消息压缩处理
+               messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
+               break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 执行工具函数,返回输出结果
            output = execute_tool(name, args)
            # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks('PostToolUse', name, args, output)
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == 'todo_write':
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )  

8.3. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# 设置命令行编码为UTF-8
os.system('chcp 65001')
# 设置文本编码为UTF-8
TEXT_ENCODING = 'utf-8'
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)
# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / 'skills'
# 设置持久化阈值为30000
+PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
+MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
+TOOL_RESULTS_DIR = WORKDIR / '.task_outputs' / 'tool-results'
# 设置保留最近3条tool消息
+KEEP_RECENT = 3
# 设置上下文限制为100000
+CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
+TRANSCRIPT_DIR = WORKDIR / '.transcripts'

8.4. llm.py #

llm.py

# 从config模块中导入client对象
from config import (
    client
)
# 从tools.schema模块中导入TOOLS常量
from tools.schema import TOOLS

# 定义call_llm函数,参数包括system(系统消息)、messages(消息列表)、max_tokens(最大token数)、model(模型名)
def call_llm(system: str, messages: list, max_tokens: int, model: str):
    # 调用client.chat.completions.create方法生成响应,传入模型名、拼接的消息、工具集合和最大token数
    return client.chat.completions.create(
        model=model,
        # 将系统提示和传入的消息列表组合成messages参数
        messages=[{'role': 'system', 'content': system}, *messages],
        # 传入工具集合
        tools=TOOLS,
        # 传入最大允许的token数
        max_tokens=max_tokens,
    )

# 定义一个函数,用于判断异常是否为提示过长相关错误
+def is_prompt_too_long_error(e: Exception) -> bool:
    # 将异常对象转换为字符串,并转换为小写
+   msg = str(e).lower()
    # 返回一个布尔值,判断是否包含与“提示过长”相关的各种关键字
+   return (
        # 检查字符串中是否有 'prompt' 且有 'long'
+       ('prompt' in msg and 'long' in msg)
        # 检查是否有 'prompt_is_too_long'
+       or 'prompt_is_too_long' in msg
        # 检查是否有 'context_length_exceeded'
+       or 'context_length_exceeded' in msg
        # 检查是否有 'max_context_window'
+       or 'max_context_window' in msg
        # 检查是否有 'context_length'
+       or 'context_length' in msg
        # 检查是否有 'maximum context'
+       or 'maximum context' in msg
    )

8.5. prompt.py #

prompt.py

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR
# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY
# 定义提示片段的字典,包含系统身份/工作目录/技能部分
PROMPT_SECTIONS = {
    # 'identity' 键,存储智能体的系统身份提示,强调直接行动等规则
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
        f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
+       f"上下文过长时可使用 compact 工具。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    'workspace': f'工作目录:{WORKDIR}',
    # 'skill' 键,指明需要完整技术文档时的指引
    'skill': '需要完整技术说明时,使用 load_skill 加载相关文档。'
}

# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS['identity'], PROMPT_SECTIONS['workspace']]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f'可用技能:\n{skills}')
        sections.append(PROMPT_SECTIONS['skill'])
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return '\n\n'.join(sections)

# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ''
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return '\n'.join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())

# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 内部调用技能文本拼接及总装配函数
    return _assemble_system_prompt(_skills_text())

# 定义子 Agent 的系统提示,用于子任务调用
SUB_SYSTEM = (
    # 子 Agent 的身份/环境要求/任务完成后要求返回摘要
    f'你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。'
    '你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
    '完成分配给你的任务,然后返回简洁摘要。不要继续委派。'
)

8.6. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)
    _fn_tool('bash', '执行一条 shell 命令。', {'command': {'type': 'string'}}, ['command']),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),

]
TOOLS = [
    *BASE_TOOLS,
     # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),
    _fn_tool('spawn_subagent', '启动子 Agent 处理复杂子任务。仅返回最终结论。', {'description': {'type': 'string'}}, ['description']),
    _fn_tool('load_skill', '按名称加载技能的完整内容。', {'name': {'type': 'string'}}, ['name']),
+   _fn_tool('compact', '摘要较早对话以释放上下文空间。', {'focus': {'type': 'string'}}, []),
]

9. Memory — 压缩会丢细节,要有一层不丢的 #

"压缩会丢细节,要有一层不丢的" — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。

本节对应教程 s09,在 s08 压缩管线之上新增 持久记忆层。s08 的 compact_history 会把「用 tab 不用空格」这类细节简化成「用户有代码风格偏好」;新开会话连摘要也没有。记忆写在 .memory/ 磁盘上,不参与压缩,跨会话保留。

本节要解决什么

LLM 没有持久状态,一切信息都在 messages 里。上下文满了要压缩,压缩就有损。需要一层:

两层加载(与 Skills 对称)

层 位置 内容 时机
1. 索引 system prompt .memory/MEMORY.md 一行一条链接 每轮 get_system_prompt(),体积小、可缓存
2. 正文 system prompt 追加 load_memories() 选中的 .md 全文 每轮按对话相关性注入 <relevant_memories>

存储结构

.memory/
  MEMORY.md              # 索引(自动重建)
  user-preference-tabs.md
  project-auth-rewrite.md

每个记忆文件带 YAML frontmatter:

---
name: user-preference-tabs
description: 用户偏好 tab 缩进
type: user          # user | feedback | project | reference
---
正文 markdown…

select_relevant_memories 如何选人

根据最近 3 条 user 消息(最多 2000 字符)从记忆目录中筛选最多 5 条:

  1. 把 list_memory_files() 的 name + description 编成编号目录
  2. 调用 LLM,返回 JSON 索引数组,如 [0, 3]
  3. 失败时 关键词降级:从近期对话提取长度 >3 的词,匹配 name/description

选中后 read_memory_file() 读全文,包在 <relevant_memories>…</relevant_memories> 里追加到 system。

写入与整理

函数 触发时机 作用
extract_memories 本轮结束(无 tool_calls) 从 pre_compress(压缩前快照)用 LLM 提取新记忆 → write_memory_file
consolidate_memories 同上,且记忆文件 ≥ CONSOLIDATE_THRESHOLD(10) LLM 去重合并,总数压到约 30 条以内
_rebuild_index 每次写入记忆后 更新 MEMORY.md 索引

pre_compress 在压缩管线运行 之前 快照 messages,避免 extract_memories 只能看到被裁过的残缺对话。

相对 s08 的变化

文件 变化
memory.py 新增,读写、筛选、提取、合并全套记忆逻辑
config.py 新增 MEMORY_DIR、MEMORY_INDEX、CONSOLIDATE_THRESHOLD
prompt.py system 注入记忆索引 + memory 使用说明
agent.py 每轮 load_memories 追加 system;Stop 时 extract_memories + consolidate_memories
utils.py 新增 llm_text()、message_text()

s08 压缩管线 完全保留;记忆与压缩正交——压缩的是 messages,记忆在磁盘。

试试这些 prompt(分多轮输入,观察记忆的累积和加载):

  1. 我更喜欢用 Tab 键缩进,而不是空格。请记住这一点。
  2. 创建一个名为 test.py 的 Python 文件(观察 Agent 是否使用了 Tab)
  3. 我之前告诉过你我的偏好吗?(观察 Agent 是否还记得)
  4. 我也更喜欢字符串用单引号而不是双引号。

观察重点:每轮结束后是否出现 [Memory: extracted N new memories]?.memory/ 目录下是否生成了 .md 文件?MEMORY.md 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆?

时序图

一轮完整循环中的记忆读、写与压缩关系:

sequenceDiagram participant Agent as agent_loop participant Prompt as get_system_prompt participant Mem as memory.py participant LLM as call_llm participant Hist as history 压缩 participant Disk as .memory/ Agent->>Prompt: get_system_prompt() Prompt->>Disk: 读取 MEMORY.md 索引 Prompt-->>Agent: system(identity + 索引 + skills) Agent->>Mem: load_memories(messages) Mem->>Mem: select_relevant_memories<br/>(近期 user 消息 → LLM 选索引) alt LLM 筛选成功 Mem->>Disk: read_memory_file(选中项) else 降级关键词匹配 Mem->>Disk: 按 name/description 匹配 end Mem-->>Agent: <relevant_memories> 正文 Agent->>Agent: system += 相关记忆 Agent->>Agent: pre_compress = 压缩前快照 Agent->>Hist: tool_result_budget → snip → micro → compact Note over Hist: 只动 messages,不动 .memory/ Agent->>LLM: call_llm(system, messages) LLM-->>Agent: assistant 回复 alt 无 tool_calls(本轮结束) Agent->>Mem: extract_memories(pre_compress) Mem->>LLM: 从对话提取 JSON 记忆项 Mem->>Disk: write_memory_file → 重建 MEMORY.md Agent->>Mem: consolidate_memories() alt 记忆数 ≥ 10 Mem->>LLM: 合并去重 Mem->>Disk: 重写 .memory/*.md end Agent-->>Agent: return else 继续工具轮 Agent->>Agent: execute_tool → 下一轮 end

说明:记忆索引常驻 system,正文按对话动态筛选注入;压缩丢掉的细节,若曾被 extract_memories 写入磁盘,下轮仍可通过 select_relevant_memories 找回。s10 将把 identity、workspace、skills、memory 等段落统一为运行时组装的 system prompt 机制。

9.1. memory.py #

memory.py


# 导入json模块用于处理JSON数据
import json
# 导入re模块用于正则表达式操作
import re
# 导入time模块用于时间相关的操作
import time
# 从config模块导入各项配置常量和对象
from config import (
    MEMORY_DIR,               # 记忆目录
    MODEL_ID,            # 主模型
    TEXT_ENCODING,            # 文本编码
    client,                   # 客户端对象
    MEMORY_INDEX,             # 记忆索引文件路径
    CONSOLIDATE_THRESHOLD     # 记忆合并阈值
)
# 从utils模块导入工具函数
from utils import llm_text, message_text, parse_frontmatter

# 定义函数,列出所有记忆文件,并返回一个包含元数据的字典列表
def list_memory_files() -> list[dict]:
    # 初始化结果列表
    result = []
    # 遍历记忆目录下所有markdown文件,并按名称排序
    for f in sorted(MEMORY_DIR.glob('*.md')):
        # 跳过主记忆文件'MEMORY.md'
        if f.name == 'MEMORY.md':
            continue
        # 读取文件内容,指定编码及错误处理方式
        raw = f.read_text(encoding=TEXT_ENCODING, errors='replace')
        # 解析文件的frontmatter和正文内容
        meta, body = parse_frontmatter(raw)
        # 构造包含文件信息的字典并添加到结果列表
        result.append({
            'filename': f.name,
            'name': meta.get('name', f.stem),
            'description': meta.get('description', ''),
            'type': meta.get('type', 'user'),
            'body': body,
        })
    # 返回所有记忆文件的元信息列表
    return result

# 根据对话内容选择最相关的记忆文件,最多返回max_items个文件名
def select_relevant_memories(messages: list, max_items: int = 5) -> list[str]:
    # 获取所有记忆文件信息
    files = list_memory_files()
    # 如果没有记忆文件,直接返回空列表
    if not files:
        return []
    # 初始化保存最近用户消息内容的列表
    recent_texts = []
    # 逆序遍历消息列表,提取最近三条用户消息
    for msg in reversed(messages):
        # 仅处理角色为用户的消息
        if msg.get('role') == 'user':
            # 获取消息文本并去除前后空白
            text = message_text(msg).strip()
            # 如果消息文本不为空,则加入recent_texts
            if text:
                recent_texts.append(text)
            # 最多收集三条消息
            if len(recent_texts) >= 3:
                break
    # 将收集到的消息拼接成字符串,并限制最大长度为2000
    recent = ' '.join(reversed(recent_texts))[:2000]
    # 如果合成后的消息字符串为空,返回空列表
    if not recent.strip():
        return []
    # 构建记忆目录字符串
    catalog = '\n'.join(f"{i}: {f['name']} — {f['description']}" for i, f in enumerate(files))
    # 构造用于LLM筛选相关记忆的prompt
    prompt = (
        '根据近期对话和下方记忆目录,选出明显相关的记忆索引。'
        '仅返回 JSON 整数数组,例如 [0, 3]。若无相关则返回 []。\n\n'
        f'近期对话:\n{recent}\n\n记忆目录:\n{catalog}'
    )
    try:
        # 发送消息到LLM,让其分析哪些记忆相关
        response = client.chat.completions.create(
            model=MODEL_ID,
            messages=[{'role': 'user', 'content': prompt}],
            max_tokens=200,
        )
        # 解析LLM返回的文本内容
        text = llm_text(response)
        # 使用正则表达式匹配JSON数组内容
        match = re.search(r'$$.*$$', text, re.DOTALL)
        if match:
            # 将匹配到的字符串解析为Python列表
            indices = json.loads(match.group())
            # 初始化选择的文件名列表
            selected = []
            # 遍历得到的索引值
            for idx in indices:
                # 检查索引有效性
                if isinstance(idx, int) and 0 <= idx < len(files):
                    # 将对应的文件名加入结果列表
                    selected.append(files[idx]['filename'])
                    # 超出最大数量则提前结束
                    if len(selected) >= max_items:
                        break
            # 返回相关记忆文件名列表
            return selected
    # 捕获所有异常,避免错误导致中断
    except Exception:
        pass
    # 兜底方案:用最近消息中长度大于3的单词降级检索
    keywords = [w.lower() for w in recent.split() if len(w) > 3]
    # 初始化选择的文件名列表
    selected = []
    # 遍历所有记忆文件
    for f in files:
        # 合并name和description并转为小写,便于匹配关键词
        text = (f['name'] + ' ' + f['description']).lower()
        # 检查关键字是否存在于该文件的文本中
        if any(kw in text for kw in keywords):
            # 加入相关文件名
            selected.append(f['filename'])
            # 超出最大数量则提前结束
            if len(selected) >= max_items:
                break
    # 返回筛选得到的记忆文件名列表
    return selected

# 读取指定记忆文件内容,不存在则返回None
def read_memory_file(filename: str) -> str | None:
    # 拼接成完整文件路径
    path = MEMORY_DIR / filename
    # 检查文件是否存在
    if not path.exists():
        return None
    # 读取文件内容并返回
    return path.read_text(encoding=TEXT_ENCODING, errors='replace')

# 加载与对话相关的记忆内容,拼接为字符串返回
def load_memories(messages: list) -> str:
    # 选出对话相关的记忆文件名
    selected_files = select_relevant_memories(messages)
    # 如果没有相关记忆,直接返回空字符串
    if not selected_files:
        return ''
    # 初始化保存整合内容的列表,以分割标签开头
    parts = ['<relevant_memories>']
    # 遍历所有相关记忆文件名
    for filename in selected_files:
        # 读取对应的文件内容
        content = read_memory_file(filename)
        # 如果内容不为空,则添加到parts中
        if content:
            parts.append(content)
    # 添加结尾的分割标签
    parts.append('</relevant_memories>')
    # 将所有部分用空行拼接为字符串返回
    return '\n\n'.join(parts)

# 重建记忆索引文件的辅助函数
def _rebuild_index():
    # 初始化索引行的列表
    lines = []
    # 遍历记忆目录下所有markdown文件并排序
    for f in sorted(MEMORY_DIR.glob('*.md')):
        # 跳过主记忆文件'MEMORY.md'
        if f.name == 'MEMORY.md':
            continue
        # 读取文件内容
        raw = f.read_text(encoding=TEXT_ENCODING, errors='replace')
        # 解析frontmatter和正文
        meta, body = parse_frontmatter(raw)
        # 获取名称,默认用文件名去掉后缀
        name = meta.get('name', f.stem)
        # 获取描述,若无则取正文第一行前80字符
        desc = meta.get('description', body.split('\n')[0][:80])
        # 构造索引条目,添加到行列表
        lines.append(f'- [{name}]({f.name}) — {desc}')
    # 将所有索引行拼接文本并写入索引文件
    MEMORY_INDEX.write_text('\n'.join(lines) + '\n' if lines else '', encoding=TEXT_ENCODING)

# 写入新的记忆文件,并重建索引
def write_memory_file(name: str, mem_type: str, description: str, body: str):
    # 生成文件名slug(小写,空格和斜杠替换为连字符)
    slug = name.lower().replace(' ', '-').replace('/', '-')
    # 构建文件完整路径
    filepath = MEMORY_DIR / f'{slug}.md'
    # 按frontmatter格式写入文件内容
    filepath.write_text(
        f'---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n',
        encoding=TEXT_ENCODING,
    )
    # 更新记忆索引
    _rebuild_index()
    # 返回文件路径对象
    return filepath

# 从最近的对话内容中提取新的记忆
def extract_memories(messages: list):
    # 初始化保存对话片段的列表
    dialogue_parts = []
    # 只处理最近的10条消息
    for msg in messages[-10:]:
        # 获取消息角色,默认问号
        role = msg.get('role', '?')
        # 获取消息文本,去除首尾空白
        text = message_text(msg).strip()
        # 若文本有内容,则格式化后加入对话片段
        if text:
            dialogue_parts.append(f'{role}: {text}')
    # 合并所有对话片段成为多行字符串
    dialogue = '\n'.join(dialogue_parts)
    # 如果合成结果为空,则不再处理
    if not dialogue.strip():
        return
    # 获取所有已存在的记忆文件信息
    existing = list_memory_files()
    # 构建已存在记忆的描述文本,如无则为'(无)'
    existing_desc = (
        '\n'.join(f"- {m['name']}: {m['description']}" for m in existing)
        if existing else '(无)'
    )
    # 构造prompt,提示LLM输出新记忆项(数组)
    prompt = (
        '从对话中提取用户偏好、约束或项目事实。\n'
        '返回 JSON 数组,每项: {name, type, description, body}。\n'
        '- name: 短 kebab-case 标识\n'
        '- type: user | feedback | project | reference\n'
        '- description: 一行摘要供索引检索\n'
        '- body: markdown 详情\n'
        '若无新内容或已被现有记忆覆盖,返回 []。\n\n'
        f'现有记忆:\n{existing_desc}\n\n对话:\n{dialogue[:4000]}'
    )
    try:
        # 发送消息到LLM,请求提取新记忆
        response = client.chat.completions.create(
            model=MODEL_ID,
            messages=[{'role': 'user', 'content': prompt}],
            max_tokens=800,
        )
        # 解析返回的文本内容
        text = llm_text(response)
        # 用正则匹配整个JSON数组
        match = re.search(r'$$.*$$', text, re.DOTALL)
        # 如果没有匹配到数组则返回
        if not match:
            return
        # 解析为Python对象
        items = json.loads(match.group())
        # 没有新内容则返回
        if not items:
            return
        # 记录成功写入的新记忆条数
        count = 0
        # 遍历所有新记忆
        for mem in items:
            # 获取名称,默认用当前时间戳
            name = mem.get('name', f'memory_{int(time.time())}')
            # 获取类型,默认'user'
            mem_type = mem.get('type', 'user')
            # 获取摘要
            desc = mem.get('description', '')
            # 获取正文
            body = mem.get('body', '')
            # 描述和正文都非空则写入新文件
            if desc and body:
                write_memory_file(name, mem_type, desc, body)
                count += 1
        # 若有写入新记忆则在控制台打印提醒
        if count:
            print(f'\n\x1b[33m[记忆: 提取了 {count} 条新记忆]\x1b[0m')
    # 捕获所有异常,安全忽略
    except Exception:
        pass

# 合并记忆库,将冗余和冲突的信息归并,并限制数量
def consolidate_memories():
    # 获取全部记忆文件列表
    files = list_memory_files()
    # 若文件数量未到合并阈值则直接返回
    if len(files) < CONSOLIDATE_THRESHOLD:
        return
    # 构造所有记忆内容的目录文本,用于合并提示
    catalog = '\n\n'.join(
        f"## {f['filename']}\nname: {f['name']}\ndescription: {f['description']}\n{f['body']}"
        for f in files
    )
    # 构造合并记忆的LLM提示语
    prompt = (
        '合并以下记忆文件。规则:\n'
        '1. 重复项合并为一条\n2. 删除过时/矛盾的记忆\n'
        '3. 总数控制在 30 条以内\n4. 优先保留重要用户偏好\n'
        '返回 JSON 数组,每项: {name, type, description, body}。\n\n'
        f'{catalog[:16000]}'
    )
    try:
        # 向LLM发起合并记忆请求
        response = client.chat.completions.create(
            model=MODEL_ID,
            messages=[{'role': 'user', 'content': prompt}],
            max_tokens=3000,
        )
        # 解析返回文本
        text = llm_text(response)
        # 用正则获取JSON数组
        match = re.search(r'$$.*$$', text, re.DOTALL)
        # 匹配不到直接返回
        if not match:
            return
        # 解析为Python对象
        items = json.loads(match.group())
        # 清空除'MEMORY.md'以外的所有记忆文件
        for f in MEMORY_DIR.glob('*.md'):
            if f.name != 'MEMORY.md':
                f.unlink()
        # 遍历合并后的新记忆项并写入
        for mem in items:
            name = mem.get('name', f'memory_{int(time.time())}')
            mem_type = mem.get('type', 'user')
            desc = mem.get('description', '')
            body = mem.get('body', '')
            if desc and body:
                write_memory_file(name, mem_type, desc, body)
        # 打印合并后的总结信息
        print(f'\n\x1b[33m[记忆: 已整理 {len(files)} → {len(items)} 条]\x1b[0m')
    # 捕获所有异常,安全忽略
    except Exception:
        pass

9.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
from config import DEFAULT_MAX_TOKENS, MODEL_ID,CONTEXT_LIMIT
# 从utils模块导入assistant_message_dict函数
+from utils import assistant_message_dict,message_text
# 从llm模块导入call_llm函数
from llm import call_llm,is_prompt_too_long_error
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
from history import (tool_result_budget,snip_compact,micro_compact,estimate_size,compact_history,repair_message_chain,reactive_compact)
# 从memory模块导入load_memories函数
+from memory import load_memories,extract_memories,consolidate_memories
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0
# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 获取系统提示词
        system = get_system_prompt()
        # 加载有关历史消息的记忆内容
+       memories_content = load_memories(messages)
        # 如果记忆内容存在
+       if memories_content:
            # 将记忆内容追加到系统提示词后,前面加两个换行符
+           system += '\n\n' + memories_content
        # 创建一个用于存储消息压缩前内容的列表
+       pre_compress = [
            # 对于messages中的每一个元素m,如果m是字典,则
+           {'role': m.get('role', ''), 'content': message_text(m)}
            # 遍历messages列表,只处理那些是字典类型的元素
+           for m in messages if isinstance(m, dict)
+       ]  
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
        messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
        messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
        messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
        if estimate_size(messages) > CONTEXT_LIMIT:
            print('[自动压缩]')
            messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
        messages[:] = repair_message_chain(messages)    
        if rounds_since_todo >= 3 and messages:
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            rounds_since_todo = 0
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
        if rounds_since_todo >= 3 and messages:
            # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            # 轮数计数器 rounds_since_todo 复位为 0
            rounds_since_todo = 0
        # 尝试执行以下代码块
        try:
            # 调用大模型获取回复,传入系统提示、消息列表、最大token数和模型名
            response = call_llm(system, messages, max_tokens, model)
        # 捕获所有异常并命名为e
        except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
            if is_prompt_too_long_error(e):
                # 对消息列表进行反应式压缩,减少长度
                messages[:] = reactive_compact(messages)
                # 跳过本次循环,继续下一次
                continue
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 提取记忆
+           extract_memories(pre_compress)
            # 合并记忆
+           consolidate_memories()
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks('Stop', messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({'role': 'user', 'content': force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1  
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 如果工具名称是'compact'
            if name == 'compact':
                # 调用compact_history函数,对messages列表进行消息压缩处理
                messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
                break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 执行工具函数,返回输出结果
            output = execute_tool(name, args)
            # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks('PostToolUse', name, args, output)
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == 'todo_write':
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )  

9.3. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# 设置命令行编码为UTF-8
os.system('chcp 65001')
# 设置文本编码为UTF-8
TEXT_ENCODING = 'utf-8'
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)
# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / 'skills'
# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / '.task_outputs' / 'tool-results'
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / '.transcripts'
# 设置记忆目录为工作目录下的 .memory 目录
+MEMORY_DIR = WORKDIR / '.memory'
# 创建记忆目录,如果目录不存在
+MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
+MEMORY_INDEX = MEMORY_DIR / 'MEMORY.md'
# 设置记忆合并阈值为10
+CONSOLIDATE_THRESHOLD = 10

9.4. prompt.py #

prompt.py

# 从 config 模块导入工作目录常量 WORKDIR
+from config import WORKDIR,MEMORY_INDEX,TEXT_ENCODING
# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY
# 定义提示片段的字典,包含系统身份/工作目录/技能部分
PROMPT_SECTIONS = {
    # 'identity' 键,存储智能体的系统身份提示,强调直接行动等规则
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
        f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    'workspace': f'工作目录:{WORKDIR}',
    # 'skill' 键,指明需要完整技术文档时的指引
+   'skill': '需要完整技术说明时,使用 load_skill 加载相关文档。',
    # 'memory' 键,指明记忆的使用方式
+   'memory': '下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。',
}

# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
+def _assemble_system_prompt(skills: str,memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS['identity'], PROMPT_SECTIONS['workspace']]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f'可用技能:\n{skills}')
        sections.append(PROMPT_SECTIONS['skill'])
    # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
+   if memories:
+       sections.append(f'可用记忆:\n{memories}')
+       sections.append(PROMPT_SECTIONS['memory'])    
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return '\n\n'.join(sections)

# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ''
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return '\n'.join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())

# 定义一个私有函数,返回记忆索引的文本内容
+def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
+   if not MEMORY_INDEX.exists():
+       return ''
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
+   return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors='replace').strip()

# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 内部调用技能文本拼接及总装配函数
+   return _assemble_system_prompt(_skills_text(), _memory_index_text())

# 定义子 Agent 的系统提示,用于子任务调用
SUB_SYSTEM = (
    # 子 Agent 的身份/环境要求/任务完成后要求返回摘要
    f'你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。'
    '你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
    '完成分配给你的任务,然后返回简洁摘要。不要继续委派。'
)

9.5. utils.py #

utils.py

# 从 pathlib 库中导入 Path 类,用于管理和操作文件路径
from pathlib import Path

# 从 config 模块中导入 WORKDIR 变量,表示工作目录路径
from config import WORKDIR

# 定义一个函数assistant_message_dict,参数为message,返回一个字典
def assistant_message_dict(message) -> dict:
    # 使用model_dump方法转换message对象为字典,排除值为None的项
    data = message.model_dump(exclude_none=True)
    # 将字典中的'role'字段设置为'assistant'
    data['role'] = 'assistant'
    # 返回处理后的字典
    return data

# 定义一个函数decode_subprocess_output,参数为data(字节类型或None),返回字符串类型
def decode_subprocess_output(data: bytes | None) -> str:
    # 如果data为None或者为空字节,则返回空字符串
    if not data:
        return ''
    # 依次尝试三种编码方式进行解码
    for encoding in ('utf-8', 'gbk', 'cp936'):
        try:
            # 使用当前编码方式尝试解码,成功则返回结果
            return data.decode(encoding)
        # 如果解码时出现UnicodeDecodeError,则继续尝试下一个编码
        except UnicodeDecodeError:
            continue
    # 如果以上编码都无法解码,则使用utf-8编码并使用replace策略处理错误,并返回结果
    return data.decode('utf-8', errors='replace')

# 定义一个名为safe_path的函数,接收一个字符串参数p,返回值类型为Path
def safe_path(p: str) -> Path:
    # 通过将WORKDIR与p拼接,并调用resolve方法,获得绝对路径对象
    path = (WORKDIR / p).resolve()
    # 判断path路径是否在WORKDIR工作区内,如果不是则抛出异常
    if not path.is_relative_to(WORKDIR):
        # 抛出ValueError异常,提示路径超出工作区
        raise ValueError(f'路径超出工作区:{p}')
    # 返回最终安全生成的路径对象
    return path

# 定义一个extract_text函数,参数为content,返回字符串类型
def extract_text(content) -> str:
    # 如果content为None,返回空字符串
    if content is None:
        return ''
    # 如果content是字符串类型,直接返回
    if isinstance(content, str):
        return content
    # 否则,将content转换为字符串后返回
    return str(content)

# 定义parse_frontmatter函数,参数为text,返回一个元组(字典,字符串)
def parse_frontmatter(text: str) -> tuple[dict, str]:
    # 如果text不是以'---'开头,则直接返回空字典和原始文本
    if not text.startswith('---'):
        return {}, text
    # 用'---'分割文本,最多分割2次,得到3段内容
    parts = text.split('---', 2)
    # 如果分割出来的部分不足3个,说明无有效frontmatter,返回空字典和原始文本
    if len(parts) < 3:
        return {}, text
    # 新建一个空字典,用于存储frontmatter的键值对
    meta = {}
    # 遍历frontmatter内容区域的每一行
    for line in parts[1].strip().splitlines():
        # 如果该行包含冒号,认为是key:value格式
        if ':' in line:
            # 以冒号分割该行成键和值(只分一次)
            k, v = line.split(':', 1)
            # 去掉键和值两端空白,并将值两端的引号去除,存入字典
            meta[k.strip()] = v.strip().strip('"').strip("'")
    # 返回已解析好的meta字典和去除空白后的正文内容
    return meta, parts[2].strip()

# 定义一个llm_text函数,接收response对象,返回字符串类型
+def llm_text(response) -> str:
    # 获取response的第一个choice的message的content字段,如果为空则用'',去除首尾空白后返回
+   return (response.choices[0].message.content or '').strip()    

# 定义一个message_text函数,接收一个字典类型的msg参数,返回字符串
+def message_text(msg: dict) -> str:
    # 从msg字典中获取'content'字段,若没有则默认为空字符串
+   content = msg.get('content', '')
    # 如果content是字符串类型,直接返回
+   if isinstance(content, str):
+       return content
    # 否则将content转换为字符串类型返回
+   return str(content)

10. System Prompt — 运行时组装,不硬编码 #

"prompt 是组装出来的,不是写死的" — 分段定义、按真实状态拼接、缓存稳定前缀。

本节对应教程 s10,把 system prompt 从「一整段硬编码字符串」升级为 运行时组装机制。s07 的技能目录、s09 的记忆索引都汇入 PROMPT_SECTIONS;本节重点是把各段 拆开维护、按需拼接,并缓存不变的前缀,为 API 侧 prompt cache 创造条件。

本节要解决什么

到 s09,Agent 已有 todo、子 Agent、技能、压缩、记忆——若仍用一个 SYSTEM = "..." 字符串,会出现:

  1. 换项目不知改哪段 — identity、工具说明、记忆指引全搅在一起
  2. 改一处牵动全局 — 加一句 memory 说明可能影响 identity 语气
  3. 空段落也占 token — 没有技能时仍带「可用技能」块

System prompt 应是运行时根据 真实状态 组装的配置,而不是写死的常量。

分段定义:PROMPT_SECTIONS

把一大段 prompt 拆成独立 section,各段单独维护:

Section 键 加载策略 内容来源
身份 identity 始终 硬编码规则(Windows cmd、todo、子 Agent、compact…)
工作区 workspace 始终 WORKDIR 运行时路径
技能目录 可用技能 + skill 有则加载 SKILL_REGISTRY → _skills_text()
记忆索引 可用记忆 + memory 有则加载 .memory/MEMORY.md → _memory_index_text()
def _assemble_system_prompt(skills: str, memories: str) -> str:
    sections = [PROMPT_SECTIONS['identity'], PROMPT_SECTIONS['workspace']]
    if skills:
        sections += [f'可用技能:\n{skills}', PROMPT_SECTIONS['skill']]
    if memories:
        sections += [f'可用记忆:\n{memories}', PROMPT_SECTIONS['memory']]
    return '\n\n'.join(sections)

判断依据是 文件/注册表是否存在,不是扫 user 消息里的关键词。

最终 system 的两层结构

get_system_prompt() 只负责 稳定前缀;agent.py 再按对话动态追加:

system = get_system_prompt()              # 身份 + 工作区 + 技能目录 + 记忆索引
       + load_memories(messages)         # 按对话筛选的 <relevant_memories> 正文
层 函数 是否缓存 是否随对话变
稳定前缀 get_system_prompt() 是(本节新增) 仅 MEMORY.md 变更时失效
动态正文 load_memories(messages) 否 每轮按近期 user 消息重选

记忆 索引 进稳定前缀(利于缓存);记忆 正文 不进缓存(随对话变,避免撑爆前缀)。

缓存机制(相对 s09 的核心增量)

每轮都重新读文件、拼字符串是浪费。get_system_prompt() 用 MEMORY_INDEX 的 mtime 作失效信号:

if _last_prompt is not None and mtime == _last_memory_mtime:
    return _last_prompt   # [缓存命中] system prompt 未变化
_last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())

技能目录在进程启动时扫描,会话内不变,随前缀一并缓存。

相对 s09 的变化

文件 变化
prompt.py 新增 _last_prompt、_last_memory_mtime;get_system_prompt() 带 mtime 缓存
agent.py 无变化(仍为 get_system_prompt() + load_memories 拼接)

s09 已引入 PROMPT_SECTIONS 与 _assemble_system_prompt;本节补全 缓存层,把「组装」做成可长期稳定运行的 harness 能力。

试试这些 prompt:

  1. 读取文件 README.md(观察始终加载的三个 section)

时序图

每轮 LLM 调用前 system prompt 的组装流程:

sequenceDiagram participant Agent as agent_loop participant Get as get_system_prompt participant Asm as _assemble_system_prompt participant Skills as _skills_text participant MemIdx as _memory_index_text participant Load as load_memories participant LLM as call_llm Agent->>Get: get_system_prompt() Get->>Get: 读取 MEMORY.md mtime alt 缓存命中(mtime 未变) Get-->>Agent: _last_prompt Note over Get: [缓存命中] system prompt 未变化 else 缓存未命中 Get->>MemIdx: 读 .memory/MEMORY.md Get->>Skills: 遍历 SKILL_REGISTRY Get->>Asm: _assemble_system_prompt(memories, skills) Note over Asm: identity + workspace<br/>+ 有技能则加目录<br/>+ 有索引则加记忆目录 Asm-->>Get: 拼接后的前缀 Get->>Get: 更新 _last_prompt Get-->>Agent: 新 system 前缀 end Agent->>Load: load_memories(messages) Load-->>Agent: <relevant_memories> 正文(动态) Agent->>Agent: system += 记忆正文 Agent->>Agent: 压缩管线处理 messages Agent->>LLM: call_llm(system, messages) Note over LLM: messages 参数中<br/>system 与 user/assistant 分离传入

说明:组装逻辑集中在 prompt.py,agent_loop 只调用 get_system_prompt() 并追加动态记忆。换项目时通常只改 PROMPT_SECTIONS['identity'] 和 WORKDIR,不必重写整段 system。s11 将在 call_llm 外包一层错误恢复,应对 429、prompt too long 等故障。

10.1. prompt.py #

prompt.py

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR,MEMORY_INDEX,TEXT_ENCODING
# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY
# 定义提示片段的字典,包含系统身份/工作目录/技能部分
PROMPT_SECTIONS = {
    # 'identity' 键,存储智能体的系统身份提示,强调直接行动等规则
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
        f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    'workspace': f'工作目录:{WORKDIR}',
    # 'skill' 键,指明需要完整技术文档时的指引
    'skill': '需要完整技术说明时,使用 load_skill 加载相关文档。',
    # 'memory' 键,指明记忆的使用方式
    'memory': '下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。',
}

# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str,memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS['identity'], PROMPT_SECTIONS['workspace']]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f'可用技能:\n{skills}')
        sections.append(PROMPT_SECTIONS['skill'])
    # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f'可用记忆:\n{memories}')
        sections.append(PROMPT_SECTIONS['memory'])    
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return '\n\n'.join(sections)

# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ''
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return '\n'.join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())

# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ''
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors='replace').strip()

# 最近一次生成的系统提示内容,初始为 None
+_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
+_last_memory_mtime: float | None = None

# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
+   global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
+   mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
+   if _last_prompt is not None and mtime == _last_memory_mtime:
        # 输出缓存命中的提示信息
+       print('  \x1b[90m[缓存命中] system prompt 未变化\x1b[0m')
        # 返回缓存的系统提示
+       return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
+   _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
+   _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
+   return _last_prompt

# 定义子 Agent 的系统提示,用于子任务调用
SUB_SYSTEM = (
    # 子 Agent 的身份/环境要求/任务完成后要求返回摘要
    f'你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。'
    '你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
    '完成分配给你的任务,然后返回简洁摘要。不要继续委派。'
)

11. Error Recovery — 错误不是结束,是重试的开始 #

"错误不是终点,是重试的起点" — 429/529 退避重试、max_tokens 升级续写、prompt too long 响应式压缩。

本节对应教程 s11,在 s10 的组装 prompt 之上为 call_llm 包一层 韧性恢复。s08 已有 is_prompt_too_long_error + reactive_compact 的雏形;本节补齐生产级三类故障的完整策略,并用 RecoveryState 跨轮追踪恢复进度。

本节要解决什么

Agent 跑着跑着可能遇到:

故障 表现 s10 行为
速率限制 HTTP 429 直接崩溃
服务过载 HTTP 529 直接崩溃
输出截断 finish_reason == 'length' 半截 tool_calls,循环断裂
上下文超限 prompt_too_long 仅一次 reactive compact,无状态追踪

生产环境里 API 错误是常态。不处理错误的 Agent,一碰就停。

RecoveryState:跨轮恢复状态

每轮 agent_loop 创建一个 RecoveryState,记录本轮已尝试过的恢复动作,避免无限重试:

字段 含义
has_escalated 是否已将 max_tokens 从 8K 升到 64K
recovery_count 续写提示已注入次数(上限 3)
consecutive_529 连续 529 次数,满 3 可切 FALLBACK_MODEL
has_attempted_reactive_compact 是否已做过响应式压缩
current_model 当前模型(529 过多时切换备用)

三层恢复策略

call_llm
  └─ with_retry(429 / 529)
       ├─ 429 → 指数退避 + 抖动,最多 10 次
       └─ 529 → 同上;连续 3 次 → 切换 FALLBACK_MODEL(若配置)

成功返回后检查 finish_reason:
  ├─ length + 未升级 → max_tokens 8K→64K,continue
  ├─ length + 已升级 + 有 tool_calls → 补截断 tool_result,continue
  └─ length + 已升级 + 无 tool_calls → 注入 CONTINUATION_PROMPT 续写(≤3 次)

with_retry 仍抛出异常时:
  ├─ prompt_too_long + 未 compact → reactive_compact,continue
  └─ 其他 / compact 后仍过长 → [不可恢复],return

with_retry 退避公式

$$ \text{delay} = \min(500 \times 2^{\text{attempt}}, 32000)\,\text{ms} + \text{jitter} $$

429、529 在 llm.py 内消化,不中断 agent_loop;其他异常向上抛出,由 agent.py 的 except 分支处理。

相对 s10 的变化

文件 变化
llm.py 新增 RecoveryState、with_retry()、429/529 检测与退避
agent.py call_llm → with_retry(...);finish_reason == 'length' 三路恢复;完善 prompt too long 分支
config.py 新增 MAX_RETRIES、ESCALATED_MAX_TOKENS、FALLBACK_MODEL、CONTINUATION_PROMPT 等

s08 的压缩管线 完全保留;错误恢复是包在 LLM 调用外层的独立层,与 history.py 互不耦合。

试试这些 prompt:

  1. 写一篇长篇科幻小说 (让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 [max_tokens] escalating 日志))
  2. 读到当前目录下面所有的文件内容 (连续读取大量文件撑大上下文,观察 reactive compact)
  3. 如果遇到 429/529,观察指数退避的日志输出

时序图

一次 LLM 调用可能经历的恢复分支:

sequenceDiagram participant Agent as agent_loop participant State as RecoveryState participant Retry as with_retry participant LLM as call_llm participant Hist as reactive_compact Agent->>State: 创建 RecoveryState Agent->>Retry: with_retry(call_llm, state) loop 最多 MAX_RETRIES 次 Retry->>LLM: create(model=state.current_model) alt 429 速率限制 LLM-->>Retry: RateLimitError Retry->>Retry: 指数退避 sleep else 529 过载 LLM-->>Retry: 529 Retry->>State: consecutive_529 += 1 alt 连续 529 ≥ 3 且配置了 FALLBACK_MODEL Retry->>State: current_model = FALLBACK_MODEL end Retry->>Retry: 退避后 continue else 成功 LLM-->>Retry: response Retry-->>Agent: response else 其他异常 LLM-->>Retry: raise Retry-->>Agent: 向上抛出 end end alt finish_reason == length(输出截断) alt 未升级 max_tokens Agent->>Agent: 8K → 64K,continue else 有 tool_calls Agent->>Agent: 补 [输出被截断] tool_result,continue else 可续写 Agent->>Agent: 注入 CONTINUATION_PROMPT,continue end else with_retry 抛出 prompt_too_long Agent->>Hist: reactive_compact(messages) Agent->>Agent: has_attempted_reactive_compact = True,continue else 不可恢复 Agent->>Agent: [错误] 写入 messages,return else 正常 Agent->>Agent: append assistant,继续工具轮 end

说明:恢复动作大多是 continue 回到循环开头,而不是 return 崩溃。RecoveryState 保证每种策略只尝试有限次。.env 可配置 FALLBACK_MODEL 作为 529 的备用线路。s12 将在此基础上引入持久化任务系统。

11.1. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
+from config import DEFAULT_MAX_TOKENS, MODEL_ID,CONTEXT_LIMIT,ESCALATED_MAX_TOKENS,MAX_RECOVERY_RETRIES,CONTINUATION_PROMPT
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict,message_text
# 从llm模块导入call_llm函数
+from llm import call_llm,is_prompt_too_long_error,RecoveryState,with_retry
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
from history import (tool_result_budget,snip_compact,micro_compact,estimate_size,compact_history,repair_message_chain,reactive_compact)
# 从memory模块导入load_memories函数
from memory import load_memories,extract_memories,consolidate_memories
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0
# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
+   state = RecoveryState()
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 获取系统提示词
        system = get_system_prompt()
        # 加载有关历史消息的记忆内容
        memories_content = load_memories(messages)
        # 如果记忆内容存在
        if memories_content:
            # 将记忆内容追加到系统提示词后,前面加两个换行符
            system += '\n\n' + memories_content
        # 创建一个用于存储消息压缩前内容的列表
        pre_compress = [
            # 对于messages中的每一个元素m,如果m是字典,则
            {'role': m.get('role', ''), 'content': message_text(m)}
            # 遍历messages列表,只处理那些是字典类型的元素
            for m in messages if isinstance(m, dict)
        ]  
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
        messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
        messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
        messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
        if estimate_size(messages) > CONTEXT_LIMIT:
            print('[自动压缩]')
            messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
        messages[:] = repair_message_chain(messages)    
        if rounds_since_todo >= 3 and messages:
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            rounds_since_todo = 0
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
        if rounds_since_todo >= 3 and messages:
            # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            # 轮数计数器 rounds_since_todo 复位为 0
            rounds_since_todo = 0
        # 尝试执行以下代码块
        try:
+          response = with_retry(
+               lambda max_tokens=max_tokens, model=state.current_model: call_llm(system, messages, max_tokens, model),
+               state,
+           )
        # 捕获所有异常并命名为e
        except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
            if is_prompt_too_long_error(e):
                # 如果还没有尝试过reactive_compact方法进行压缩
+               if not state.has_attempted_reactive_compact:
                    # 使用reactive_compact进行消息压缩
+                   messages[:] = reactive_compact(messages)
                    # 标记已经尝试过reactive_compact
+                   state.has_attempted_reactive_compact = True
                    # 继续while循环,重新尝试
+                   continue
                # 如果压缩后仍然过长,则打印错误提示(红色字体)
+               print('  \x1b[31m[不可恢复] compact 后仍然过长\x1b[0m')
                # 在消息列表中加入assistant角色的错误消息,提示上下文过大
+               messages.append({'role': 'assistant', 'content': '[错误] 上下文过大,无法继续。'})
                # 终止函数执行
+               return
            # 获取异常的类型名称
+           name = type(e).__name__
            # 打印不可恢复的错误信息,取错误内容的前100个字符(红色字体)
+           print(f'  \x1b[31m[不可恢复] {name}: {str(e)[:100]}\x1b[0m')
            # 在消息列表中添加assistant角色的错误信息,包含异常类型和前200字符内容
+           messages.append({'role': 'assistant', 'content': f'[错误] {name}: {str(e)[:200]}'})
            # 终止函数执行
+           return

        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 判断回复是否因达到最大长度被截断
+       if choice.finish_reason == 'length':
            # 如果还未升级max_tokens
+           if not state.has_escalated:
                # 升级max_tokens至更大值
+               max_tokens = ESCALATED_MAX_TOKENS
                # 标记已升级
+               state.has_escalated = True
                # 打印升级提示
+               print(f'  \x1b[33m[max_tokens] 升级 {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\x1b[0m')
                # 重新进入循环再次请求
+               continue
            # 将助手的消息以dict形式加入消息列表
+           messages.append(assistant_message_dict(choice.message))
            # 如果助手回复里包含工具调用
+           if choice.message.tool_calls:
                # 遍历所有工具调用
+               for tool_call in choice.message.tool_calls:
                    # 添加一条 tool 消息,提示输出被截断未执行
+                   messages.append({
+                       'role': 'tool',
+                       'tool_call_id': tool_call.id,
+                       'content': '[输出被截断,未能执行工具]',
+                   })
                # 跳出本次循环,重新开始
+               continue
            # 如果还在允许的最大恢复次数范围内
+           if state.recovery_count < MAX_RECOVERY_RETRIES:
                # 添加一条用户消息,提示助手续写回复
+               messages.append({'role': 'user', 'content': CONTINUATION_PROMPT})
                # 恢复计数加一
+               state.recovery_count += 1
                # 打印续写提示
+               print(f'  \x1b[33m[max_tokens] 续写 {state.recovery_count}/{MAX_RECOVERY_RETRIES}\x1b[0m')
                # 进入下一个循环尝试续写
+               continue
            # 已达最大恢复重试次数,打印告警
+           print('  \x1b[31m[max_tokens] 已达恢复上限\x1b[0m')
            # 终止函数执行
+           return

        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 提取记忆
            extract_memories(pre_compress)
            # 合并记忆
            consolidate_memories()
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks('Stop', messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({'role': 'user', 'content': force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1  
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 如果工具名称是'compact'
            if name == 'compact':
                # 调用compact_history函数,对messages列表进行消息压缩处理
                messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
                break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 执行工具函数,返回输出结果
            output = execute_tool(name, args)
            # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks('PostToolUse', name, args, output)
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == 'todo_write':
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )  

11.2. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# 设置命令行编码为UTF-8
os.system('chcp 65001')
# 设置文本编码为UTF-8
TEXT_ENCODING = 'utf-8'
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)
# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / 'skills'
# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / '.task_outputs' / 'tool-results'
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / '.transcripts'
# 设置记忆目录为工作目录下的 .memory 目录
MEMORY_DIR = WORKDIR / '.memory'
# 创建记忆目录,如果目录不存在
MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
MEMORY_INDEX = MEMORY_DIR / 'MEMORY.md'
# 设置记忆合并阈值为10
CONSOLIDATE_THRESHOLD = 10
# 设置最大重试次数为10
+MAX_RETRIES = 10
# 设置基础延迟时间为500毫秒
+BASE_DELAY_MS = 500
# 定义连续发生529错误的最大次数
+MAX_CONSECUTIVE_529 = 3
# 从环境变量中获取备用模型的名称
+FALLBACK_MODEL = os.getenv('FALLBACK_MODEL')
# 定义升级后的最大token数
+ESCALATED_MAX_TOKENS = 64000
# 定义最大恢复重试次数为3
+MAX_RECOVERY_RETRIES = 3
# 定义续写提示
+CONTINUATION_PROMPT = '输出 token 上限已达到。直接继续 — 不要道歉或复述,从思路中断处接上。'

11.3. llm.py #

llm.py

+from openai import APIStatusError, RateLimitError
+import random
+import time
# 从config模块中导入client对象
from config import (
+   client,
+   MODEL_ID,
+   MAX_RETRIES,
+   BASE_DELAY_MS,
+   MAX_CONSECUTIVE_529,
+   FALLBACK_MODEL
)
# 从tools.schema模块中导入TOOLS常量
from tools.schema import TOOLS

# 定义call_llm函数,参数包括system(系统消息)、messages(消息列表)、max_tokens(最大token数)、model(模型名)
def call_llm(system: str, messages: list, max_tokens: int, model: str):
    # 调用client.chat.completions.create方法生成响应,传入模型名、拼接的消息、工具集合和最大token数
    return client.chat.completions.create(
        model=model,
        # 将系统提示和传入的消息列表组合成messages参数
        messages=[{'role': 'system', 'content': system}, *messages],
        # 传入工具集合
        tools=TOOLS,
        # 传入最大允许的token数
        max_tokens=max_tokens,
    )

# 定义一个函数,用于判断异常是否为提示过长相关错误
def is_prompt_too_long_error(e: Exception) -> bool:
    # 将异常对象转换为字符串,并转换为小写
    msg = str(e).lower()
    # 返回一个布尔值,判断是否包含与“提示过长”相关的各种关键字
    return (
        # 检查字符串中是否有 'prompt' 且有 'long'
        ('prompt' in msg and 'long' in msg)
        # 检查是否有 'prompt_is_too_long'
        or 'prompt_is_too_long' in msg
        # 检查是否有 'context_length_exceeded'
        or 'context_length_exceeded' in msg
        # 检查是否有 'max_context_window'
        or 'max_context_window' in msg
        # 检查是否有 'context_length'
        or 'context_length' in msg
        # 检查是否有 'maximum context'
        or 'maximum context' in msg
    )

# 定义一个RecoveryState类,用于表示恢复状态
+class RecoveryState:
    # 初始化方法
+   def __init__(self):
        # 标记是否已升级处理
+       self.has_escalated = False
        # 恢复尝试的次数计数
+       self.recovery_count = 0
        # 连续发生529错误的次数
+       self.consecutive_529 = 0
        # 是否已尝试被动压缩
+       self.has_attempted_reactive_compact = False
        # 当前使用的模型
+       self.current_model = MODEL_ID

# 判断是否是速率限制错误
+def _is_rate_limit_error(e: Exception) -> bool:
    # 如果异常是 RateLimitError 类型
+   if isinstance(e, RateLimitError):
        # 返回 True
+       return True
    # 将异常信息转为小写字符串
+   msg = str(e).lower()
    # 获取异常类型的名字并转为小写
+   name = type(e).__name__.lower()
    # 检查异常类型名字中是否包含 'ratelimit' 或异常信息中是否包含 '429'
+   return 'ratelimit' in name or '429' in msg

# 计算重试的延时
+def retry_delay(attempt: int, retry_after=None) -> float:
    # 如果有 retry_after 参数
+   if retry_after:
+       try:
            # 尝试将 retry_after 转为 float 并返回
+           return float(retry_after)
        # 如果转换失败则忽略错误
+       except (TypeError, ValueError):
+           pass
    # 计算基础延时,指数退避,最大不超过 32000 毫秒,并转换为秒
+   base = min(BASE_DELAY_MS * 2 ** attempt, 32000) / 1000
    # 返回基础延时加上 0 到 base*0.25 之间的随机浮点数
+   return base + random.uniform(0, base * 0.25)

# 判断是否是过载错误
+def _is_overloaded_error(e: Exception) -> bool:
    # 如果异常是 APIStatusError 并且状态码为 529
+   if isinstance(e, APIStatusError) and e.status_code == 529:
        # 返回 True
+       return True
    # 将异常信息转为小写字符串
+   msg = str(e).lower()
    # 获取异常类型的名字并转为小写
+   name = type(e).__name__.lower()
    # 检查异常类型名字中是否包含 'overloaded' 或异常信息中是否包含 '529'
+   return 'overloaded' in name or '529' in msg

# 为一个函数增加重试机制
+def with_retry(fn, state: RecoveryState):
    # 尝试 MAX_RETRIES 次
+   for attempt in range(MAX_RETRIES):
+       try:
            # 执行传入的函数
+           result = fn()
            # 529 计数清零
+           state.consecutive_529 = 0
            # 返回结果
+           return result
        # 捕获所有异常
+       except Exception as e:
            # 如果是速率限制错误
+           if _is_rate_limit_error(e):
                # 计算重试等待时间
+               delay = retry_delay(attempt)
                # 打印重试信息(黄色)
+               print(f'  \x1b[33m[429 速率限制] 重试 {attempt + 1}/{MAX_RETRIES},等待 {delay:.1f}s\x1b[0m')
                # 等待 delay 秒
+               time.sleep(delay)
                # 继续下一次重试
+               continue
            # 如果是过载错误
+           if _is_overloaded_error(e):
                # 529 计数加一
+               state.consecutive_529 += 1
                # 如果连续 529 次数超过最大允许次数
+               if state.consecutive_529 >= MAX_CONSECUTIVE_529:
                    # 如果配置了备用模型
+                   if FALLBACK_MODEL:
                        # 切换到备用模型
+                       state.current_model = FALLBACK_MODEL
                        # 529 计数清零
+                       state.consecutive_529 = 0
                        # 打印切换模型信息(红色)
+                       print(f'  \x1b[31m[529 x{MAX_CONSECUTIVE_529}] 切换到 {FALLBACK_MODEL}\x1b[0m')
                    # 如果没有配置备用模型
+                   else:
                        # 529 计数清零
+                       state.consecutive_529 = 0
                        # 打印未配置备用模型信息(红色)
+                       print(f'  \x1b[31m[529 x{MAX_CONSECUTIVE_529}] 未配置 FALLBACK_MODEL,继续重试\x1b[0m')
                # 计算重试等待时间
+               delay = retry_delay(attempt)
                # 打印过载重试信息(黄色)
+               print(f'  \x1b[33m[529 过载] 重试 {attempt + 1}/{MAX_RETRIES},等待 {delay:.1f}s\x1b[0m')
                # 等待 delay 秒
+               time.sleep(delay)
                # 继续下一次重试
+               continue
            # 如果不是以上错误则抛出异常
+           raise
    # 如果超过最大重试次数,抛出运行时错误
+   raise RuntimeError(f'超过最大重试次数({MAX_RETRIES})')

12. Task System — 跨会话的持久化任务图 #

"todo 是会话内的便签,task 是落盘的工单" — 文件持久化、blockedBy 依赖图、认领与解阻。

本节对应教程 s12,在 s11 错误恢复之上引入 持久化任务系统。s05 的 todo_write 管当前会话内的短期清单;本节把任务写入 .tasks/ 目录,进程重启后仍在,并支持任务间依赖。

本节要解决什么

能力 s05 todo_write s12 Task System
存储 内存 CURRENT_TODOS .tasks/task_*.json 文件
生命周期 会话结束即消失 跨会话持久
依赖 无 blockedBy 阻塞链
负责人 无 owner 字段(认领时写入)
状态校验 仅 enum 检查 claim / complete 有前置条件

复杂工作往往拆成多步、有先后。没有持久任务图,Agent 只能凭上下文「记住」进度;上下文一压缩或重启,计划就丢了。

Task 数据模型

每个任务是一个 JSON 文件,字段如下:

字段 含义
id task_{timestamp}_{rand} 唯一 ID
subject 任务标题
description 详细描述(可选)
status pending → in_progress → completed
owner 认领者(如 agent),未认领为 null
blockedBy 依赖任务 ID 列表,全部 completed 才可认领

状态机与守卫

pending ──claim_task(can_start)──► in_progress ──complete_task──► completed
   ▲                                      │
   └── blockedBy 未满足 → 拒绝认领          └── 非 in_progress → 拒绝完成

can_start(task_id) 遍历 blockedBy:依赖文件不存在或状态非 completed,则返回 False。complete_task 完成后会扫描全部 pending 任务,报告因本次完成而 解阻 的下游任务。

五个新工具

工具 作用
create_task 创建任务,可选 blockedBy
list_tasks 列出全部任务及状态、负责人、依赖
get_task 按 ID 返回完整 JSON
claim_task pending → in_progress,写入 owner
complete_task in_progress → completed,报告解阻

工具经 handlers.py → tasks.py 调用,schema.py 注册到 TOOLS,agent_loop 本身 无需改动 — 与 s02 工具扩展模式一致。

依赖 DAG 示例

task_A(实现 API)     task_B(写测试,blockedBy: [task_A])
     │                          │
     └── completed ─────────────┘
                    claim_task(B) 才允许

相对 s11 的变化

文件 变化
tasks.py 新增:Task 数据类与 CRUD、依赖检查、认领/完成逻辑
config.py 新增 TASKS_DIR = .tasks/,启动时 mkdir
tools/handlers.py 5 个 run_*_task 处理函数 + TOOL_HANDLERS 映射
tools/schema.py 5 个任务工具 schema 加入 TOOLS

s11 的 with_retry、s05 的 todo_write、s08 的压缩管线 全部保留。任务系统是独立模块,与错误恢复层正交可组合。

试试这些 prompt:

  1. 创建任务:setup database schema,create API endpoints(依赖 schema),write tests(依赖 endpoints),write docs(依赖 schema)
  2. 列出所有任务及其状态
  3. 认领第一个未被阻塞的任务并完成
  4. 再次列出任务 —— 哪些任务已被解锁?

时序图

带依赖的任务从创建到解阻的完整流程:

sequenceDiagram participant Agent as agent_loop participant LLM as LLM participant Handler as handlers.py participant Tasks as tasks.py participant Disk as .tasks/*.json Agent->>LLM: 用户请求拆分多步工作 LLM-->>Agent: tool_call: create_task(A) Agent->>Handler: execute_tool(create_task) Handler->>Tasks: create_task(subject=A) Tasks->>Disk: 写入 task_A.json (pending) Handler-->>Agent: 已创建 task_A LLM-->>Agent: tool_call: create_task(B, blockedBy=[A]) Agent->>Handler: execute_tool(create_task) Handler->>Tasks: create_task(B, blockedBy=[A]) Tasks->>Disk: 写入 task_B.json (pending, blocked) Handler-->>Agent: 已创建 task_B LLM-->>Agent: tool_call: claim_task(B) Agent->>Handler: execute_tool(claim_task) Handler->>Tasks: claim_task(B) Tasks->>Tasks: can_start(B)? → False Handler-->>Agent: 被阻塞,依赖: [task_A] LLM-->>Agent: tool_call: claim_task(A) → 执行 A 的工作 ... Agent->>Handler: execute_tool(complete_task, A) Handler->>Tasks: complete_task(A) Tasks->>Disk: task_A.json → completed Tasks->>Tasks: 扫描解阻 → B 可开始 Handler-->>Agent: 已完成 A\n已解阻: B LLM-->>Agent: tool_call: claim_task(B) Handler->>Tasks: claim_task(B) Tasks->>Tasks: can_start(B)? → True Tasks->>Disk: task_B.json → in_progress, owner=agent Handler-->>Agent: 已认领 B

说明:todo_write 适合会话内即时勾选;create_task 适合需要 持久化、有依赖、可跨轮认领 的工作分解。s13 将在此基础上引入后台线程执行,让慢任务不阻塞主循环。

12.1. tasks.py #

tasks.py

# 导入random模块用于生成随机数
import random
# 导入time模块用于获取当前时间戳
import time
# 导入json模块用于处理JSON数据
import json
# 从dataclasses模块导入dataclass和asdict用于数据结构定义和转换
from dataclasses import dataclass,asdict
# 从pathlib模块导入Path用于路径操作
from pathlib import Path
# 从config.py导入任务目录和文本编码设置
from config import TASKS_DIR, TEXT_ENCODING

# 用于表示任务的数据类
@dataclass
class Task:
    # 任务ID
    id: str
    # 任务主题
    subject: str
    # 任务描述
    description: str
    # 任务状态
    status: str
    # 任务负责人
    owner: str | None
    # 任务依赖列表
    blockedBy: list[str]

# 根据任务ID生成任务文件路径
def _task_path(task_id: str) -> Path:
    return TASKS_DIR / f'{task_id}.json'

# 保存Task对象到文件
def save_task(task: Task):
    _task_path(task.id).write_text(
        json.dumps(asdict(task), indent=2, ensure_ascii=False),
        encoding=TEXT_ENCODING,
    )

# 创建一个新的任务并保存
def create_task(subject: str, description: str = '', blockedBy: list[str] | None = None) -> Task:
    task = Task(
        # 生成唯一的任务ID
        id=f'task_{int(time.time())}_{random.randint(0, 9999):04d}',
        # 设置任务主题
        subject=subject,
        # 设置任务描述
        description=description,
        # 任务初始状态为pending
        status='pending',
        # 初始负责人为空
        owner=None,
        # 设置依赖列表,为空则默认[]
        blockedBy=blockedBy or [],
    )
    # 存储任务到文件
    save_task(task)
    # 返回任务对象
    return task

# 获取所有任务列表
def list_tasks() -> list[Task]:
    return [
        # 读取每个任务文件并转为Task对象
        Task(**json.loads(p.read_text(encoding=TEXT_ENCODING)))
        # 查找所有任务json文件,并排序
        for p in sorted(TASKS_DIR.glob('task_*.json'))
    ]

# 加载指定ID的任务
def load_task(task_id: str) -> Task:
    return Task(**json.loads(_task_path(task_id).read_text(encoding=TEXT_ENCODING)))

# 获取任务的JSON字符串
def get_task(task_id: str) -> str:
    return json.dumps(asdict(load_task(task_id)), indent=2, ensure_ascii=False)

# 判断任务是否可以开始(依赖完成且存在)
def can_start(task_id: str) -> bool:
    # 加载当前任务信息
    task = load_task(task_id)
    # 遍历所有依赖任务
    for dep_id in task.blockedBy:
        # 依赖任务文件不存在则不可开始
        if not _task_path(dep_id).exists():
            return False
        # 依赖任务未完成也不可开始
        if load_task(dep_id).status != 'completed':
            return False
    # 所有依赖都满足
    return True

# 认领任务
def claim_task(task_id: str, owner: str = 'agent') -> str:
    # 加载任务
    task = load_task(task_id)
    # 如果任务状态不是pending, 则无法认领
    if task.status != 'pending':
        return f'任务 {task_id} 状态为 {task.status},无法认领'
    # 如果任务被依赖阻塞, 则无法认领
    if not can_start(task_id):
        # 找出所有未完成的依赖
        deps = [
            d for d in task.blockedBy
            if not _task_path(d).exists() or load_task(d).status != 'completed'
        ]
        return f'被阻塞,依赖: {deps}'
    # 设置负责人
    task.owner = owner
    # 设置任务状态为进行中
    task.status = 'in_progress'
    # 保存任务
    save_task(task)
    # 在控制台输出认领提示
    print(f'  \x1b[36m[认领] {task.subject} → in_progress(负责人: {owner})\x1b[0m')
    # 返回认领结果字符串
    return f'已认领 {task.id}({task.subject})'    

# 完成任务
def complete_task(task_id: str) -> str:
    # 加载任务
    task = load_task(task_id)
    # 如果任务不是进行中状态, 则无法完成
    if task.status != 'in_progress':
        return f'任务 {task_id} 状态为 {task.status},无法完成'
    # 设置任务状态为已完成
    task.status = 'completed'
    # 保存任务
    save_task(task)
    # 查找因本任务解锁、现在可以开始的所有等待任务
    unblocked = [
        t.subject for t in list_tasks()
        if t.status == 'pending' and t.blockedBy and can_start(t.id)
    ]
    # 在控制台输出任务完成信息
    print(f'  \x1b[32m[完成] {task.subject} ✓\x1b[0m')
    # 构造返回信息
    msg = f'已完成 {task.id}({task.subject})'
    # 如果有已解锁的任务,则信息中增加这些任务
    if unblocked:
        msg += f"\n已解阻: {', '.join(unblocked)}"
        print(f"  \x1b[33m[解阻] {', '.join(unblocked)}\x1b[0m")
    # 返回最终的信息
    return msg

12.2. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# 设置命令行编码为UTF-8
os.system('chcp 65001')
# 设置文本编码为UTF-8
TEXT_ENCODING = 'utf-8'
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)
# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / 'skills'
# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / '.task_outputs' / 'tool-results'
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / '.transcripts'
# 设置记忆目录为工作目录下的 .memory 目录
MEMORY_DIR = WORKDIR / '.memory'
# 创建记忆目录,如果目录不存在
MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
MEMORY_INDEX = MEMORY_DIR / 'MEMORY.md'
# 设置记忆合并阈值为10
CONSOLIDATE_THRESHOLD = 10
# 设置最大重试次数为10
MAX_RETRIES = 10
# 设置基础延迟时间为500毫秒
BASE_DELAY_MS = 500
# 定义连续发生529错误的最大次数
MAX_CONSECUTIVE_529 = 3
# 从环境变量中获取备用模型的名称
FALLBACK_MODEL = os.getenv('FALLBACK_MODEL')
# 定义升级后的最大token数
ESCALATED_MAX_TOKENS = 64000
# 定义最大恢复重试次数为3
MAX_RECOVERY_RETRIES = 3
# 定义续写提示
CONTINUATION_PROMPT = '输出 token 上限已达到。直接继续 — 不要道歉或复述,从思路中断处接上。'
# 设置任务目录为工作目录下的 .tasks 目录
+TASKS_DIR = WORKDIR / '.tasks'
# 创建任务目录,如果目录不存在
+TASKS_DIR.mkdir(exist_ok=True)

12.3. handlers.py #

tools/handlers.py

# 导入操作系统相关模块
import os
# 导入操作系统相关模块
import glob as g
# 导入子进程处理模块
import subprocess
# 从utils模块导入decode_subprocess_output和safe_path函数
from utils import decode_subprocess_output, safe_path
# 从config模块导入文本编码配置
from config import TEXT_ENCODING,WORKDIR
# 从skills模块导入load_skill函数
from skills import load_skill
# 从tasks模块导入create_task函数
+from tasks import (create_task,list_tasks,get_task,claim_task,complete_task)
# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == 'nt' and command.strip().lower() == 'date':
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = 'date /t & time /t'
    # 定义危险命令的列表
    dangerous = ['rm -rf /', 'sudo', 'shutdown', 'reboot', '> /dev/']
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return '错误:危险命令已被拦截'
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,            # 要执行的命令
            shell=True,         # 在shell中执行
            cwd=os.getcwd(),    # 当前工作目录设置为当前路径
            capture_output=True,# 捕获标准输出和标准错误
            timeout=120,        # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else '(无输出)'
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return '错误:超时(120 秒)'
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f'错误:{e}'

# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f'...(还有 {len(lines) - limit} 行)']
        # 将行列表拼接为字符串并返回
        return '\n'.join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f'已写入 {len(content)} 字节到 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f'错误:在 {path} 中未找到指定文本'
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING)
        # 返回编辑成功的提示
        return f'已编辑 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return '\n'.join(results) if results else '(无匹配)'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'

CURRENT_TODOS: list[dict] = []

def run_todo_write(todos: list) -> str:
    global CURRENT_TODOS
    for i, t in enumerate(todos):
        if 'content' not in t or 'status' not in t:
            return f'错误:todos[{i}] 缺少 content 或 status'
        if t['status'] not in ('pending', 'in_progress', 'completed'):
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    CURRENT_TODOS = todos
    lines = ['\n\x1b[33m## 当前任务\x1b[0m']
    for t in CURRENT_TODOS:
        icon = {'pending': '\x1b[33m等待中\x1b[0m', 'in_progress': '\x1b[36m处理中\x1b[0m', 'completed': '\x1b[32m已完成\x1b[0m'}[t['status']]
        lines.append(f"  [{icon}] {t['content']}")
    print('\n'.join(lines))
    return f'已更新 {len(CURRENT_TODOS)} 个任务'


# 定义run_create_task函数,用于创建新任务
+def run_create_task(
     # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
+    subject: str, description: str = "", blockedBy: list[str] | None = None
# 函数返回类型为字符串
+) -> str:
     # 调用create_task函数创建任务对象
+    task = create_task(subject, description, blockedBy)
     # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
+    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
     # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
+    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
     # 返回已创建任务的ID、主题及依赖信息提示
+    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
+def run_list_tasks() -> str:
     # 调用list_tasks获取所有任务列表
+    tasks = list_tasks()
     # 如果任务列表为空
+    if not tasks:
         # 返回暂无任务的提示信息
+        return "暂无任务。使用 create_task 添加。"
     # 初始化用于存储显示行的空列表
+    lines = []
     # 遍历所有任务
+    for t in tasks:
         # 根据任务状态获取对应的中文状态标签
+        icon = {
+            # pending状态对应“等待中”
+            "pending": "等待中",
+            # in_progress状态对应“处理中”
+            "in_progress": "处理中",
+            # completed状态对应“已完成”
+            "completed": "已完成",
         # 按任务状态取值,未知状态则返回问号
+        }.get(t.status, "?")
         # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
+        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
         # 若任务有负责人则格式化负责人信息,否则为空字符串
+        owner = f" [{t.owner}]" if t.owner else ""
         # 将格式化后的任务信息行加入lines列表
+        lines.append(f"  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}")
     # 将所有行用换行符拼接成字符串后返回
+    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
+def run_get_task(task_id: str) -> str:
     # 尝试获取指定ID的任务
+    try:
         # 调用get_task返回任务详情
+        return get_task(task_id)
     # 捕获任务文件不存在的异常
+    except FileNotFoundError:
         # 返回未找到任务的错误提示
+        return f"错误:未找到任务 {task_id}"

# 定义run_claim_task函数,认领指定任务,返回字符串
+def run_claim_task(task_id: str) -> str:
     # 以agent为负责人认领该任务并返回结果
+    return claim_task(task_id, owner="agent")

# 定义run_complete_task函数,完成指定任务,返回字符串
+def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
+    return complete_task(task_id)

# 定义TOOL_HANDLERS字典,映射工具名到各自处理函数
TOOL_HANDLERS = {
    'bash': run_bash,#执行shell命令
    'read_file': run_read,#读取文件内容
    'write_file': run_write,#写入文件内容s
    'edit_file': run_edit,#编辑文件内容
    'glob': run_glob,#通配符路径匹配
    'todo_write': run_todo_write,#创建并管理当前编码会话的任务列表
    'load_skill': load_skill,#按名称加载技能的完整内容
+   'create_task': run_create_task, # 创建新任务
+   'list_tasks': run_list_tasks, # 列出所有任务
+   'get_task': run_get_task, # 按 ID 获取任务完整详情
+   'claim_task': run_claim_task, # 认领 pending 任务,设置 owner 并改为 in_progress
+   'complete_task': run_complete_task # 完成 in_progress 任务,并报告下游解阻任务
}

12.4. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)
    _fn_tool('bash', '执行一条 shell 命令。', {'command': {'type': 'string'}}, ['command']),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),

]
TOOLS = [
    *BASE_TOOLS,
     # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),
    _fn_tool('spawn_subagent', '启动子 Agent 处理复杂子任务。仅返回最终结论。', {'description': {'type': 'string'}}, ['description']),
    _fn_tool('load_skill', '按名称加载技能的完整内容。', {'name': {'type': 'string'}}, ['name']),
    _fn_tool('compact', '摘要较早对话以释放上下文空间。', {'focus': {'type': 'string'}}, []),
+   _fn_tool('create_task', '创建新任务,可选 blockedBy 依赖。', {'subject': {'type': 'string'}, 'description': {'type': 'string'}, 'blockedBy': {'type': 'array', 'items': {'type': 'string'}}}, ['subject']),
+   _fn_tool('list_tasks', '列出所有任务的状态、负责人与依赖。', {}, []),
+   _fn_tool('get_task', '按 ID 获取任务完整详情。', {'task_id': {'type': 'string'}}, ['task_id']),
+   _fn_tool('claim_task', '认领 pending 任务,设置 owner 并改为 in_progress。', {'task_id': {'type': 'string'}}, ['task_id']),
+   _fn_tool('complete_task', '完成 in_progress 任务,并报告下游解阻任务。', {'task_id': {'type': 'string'}}, ['task_id']),
]

13. Background Tasks — 慢命令不阻塞主循环 #

"派发即返回,完成再通知" — daemon 线程后台执行、task_notification 注入、主循环不空等。

本节对应教程 s13,在 s12 持久化任务之上引入 后台执行。s12 的 create_task 管「做什么」;本节管「怎么不阻塞」——pip install、pytest 等慢命令派到后台线程,主循环立刻继续。

本节要解决什么

场景 s12 行为 s13 行为
pip install 耗时 2 分钟 同步阻塞,Agent 干等 派发后台,立即返回占位符
LLM 想并行干活 无法继续 主循环继续调 LLM / 执行其他工具
命令完成 同步返回 stdout 下一轮循环注入 <task_notification>

没有后台执行,一次慢命令就会把整个 Agent 冻住;用户只能盯着终端等。

触发条件:两层判断

should_run_background(tool_name, args)
  ├─ args.run_in_background == true  → 显式请求(LLM 或用户指定)
  └─ is_slow_operation(bash, command)  → 启发式回退(正则匹配慢命令)

_SLOW_PATTERNS 覆盖 pip install、npm run build、pytest、docker build 等。run_in_background 在 调度层(agent.py)消费,run_bash 本身不处理该参数。

核心数据结构

变量 作用
background_tasks bg_id → {tool_call_id, command, status}
background_results bg_id → 完整输出
background_lock 线程安全读写
_bg_counter 生成 bg_0001 格式 ID

执行流程

工具轮(agent_loop for tool_call)
  └─ should_run_background?
       ├─ Yes → start_background_task → 占位符 tool_result → continue
       └─ No  → execute_tool 同步执行

每轮循环开头
  └─ collect_background_results()
       └─ 已完成任务 → <task_notification> 作为 user 消息注入

占位符示例:[后台任务 bg_0001 已启动] 命令: pip install ...。完成后将通过 task_notification 通知。

通知格式(不复用 tool_call_id,独立通道):

<task_notification>
  <task_id>bg_0001</task_id>
  <status>completed</status>
  <command>pip install requests</command>
  <summary>...前 200 字符...</summary>
</task_notification>

相对 s12 的变化

文件 变化
background.py 新增:慢命令检测、线程派发、结果收集
agent.py 循环开头 collect_background_results();工具执行分支 should_run_background
tools/handlers.py run_bash 增加 run_in_background 参数(schema 兼容,调度层消费)
tools/schema.py bash 工具增加 run_in_background: boolean

s12 的 .tasks/ 任务系统、s11 的错误恢复 全部保留。后台执行只影响 bash 的调度方式,与持久化任务正交。

试试这些 prompt:

  1. 在后台运行 pip list 并打印运行结果
  2. 运行 pip list 并打印运行结果 (使用run_in_background)

时序图

慢命令从派发到通知注入的完整流程:

sequenceDiagram participant Agent as agent_loop participant LLM as LLM participant BG as background.py participant Worker as daemon Thread participant Tool as execute_tool Agent->>LLM: call_llm(含历史消息) LLM-->>Agent: tool_call: bash(pip install ...) Agent->>BG: should_run_background(bash, args) BG-->>Agent: True(慢命令匹配) Agent->>BG: start_background_task(tool_call_id, bash, args) BG->>BG: background_tasks[bg_0001] = running BG->>Worker: Thread.start(daemon) BG-->>Agent: bg_0001 Agent->>Agent: tool_result = [后台任务 bg_0001 已启动]... Agent->>Agent: continue 主循环(不阻塞) par 主循环继续 Agent->>LLM: 可继续其他 tool_call / 对话 and 后台执行 Worker->>Tool: execute_tool(bash, args) Tool-->>Worker: stdout/stderr Worker->>BG: status=completed, 写入 results end Note over Agent: 下一轮 while True 开头 Agent->>BG: collect_background_results() BG->>BG: pop completed → 生成 task_notification BG-->>Agent: [notification, ...] Agent->>Agent: messages.append(user, task_notification) Agent->>LLM: call_llm(LLM 看到后台完成摘要) LLM-->>Agent: 基于 summary 继续回复

说明:后台线程是 daemon=True,进程退出时不会等待。通知在循环 开头 收集,保证 LLM 在下一轮请求前就能看到完成结果。与 s12 的 .tasks/ 不同,后台任务 ID(bg_xxxx)是会话内临时的,不落盘。s14 将在此基础上引入 Cron 定时调度。

13.1. background.py #

background.py


# 导入正则表达式模块
import re
# 导入线程模块
import threading
# 从工具包中导入execute_tool函数
from tools.executor import execute_tool
# 定义一组代表“慢操作”的正则模式
_SLOW_PATTERNS = [
    r'pip\s+install',    # 匹配pip install命令
    r'npm\s+install',    # 匹配npm install命令
    r'npm\s+ci',         # 匹配npm ci命令
    r'yarn\s+install',   # 匹配yarn install命令
    r'docker\s+build',   # 匹配docker build命令
    r'cargo\s+build',    # 匹配cargo build命令
    r'go\s+build',       # 匹配go build命令
    r'python\s+-m\s+pytest', # 匹配python -m pytest命令
    r'python\s+-m\s+build',  # 匹配python -m build命令
    r'\bpytest\b',       # 匹配pytest命令
    r'\bmake\b',         # 匹配make命令
    r'\bdeploy\b',       # 匹配deploy命令
    r'npm\s+run\s+build',# 匹配npm run build命令
    r'npm\s+run\s+test', # 匹配npm run test命令
]
# 背景任务计数器,初始值为0
_bg_counter = 0
# 创建一个线程锁,用于保证对共享资源的操作是线程安全的
background_lock = threading.Lock()
# 用于存储所有的后台任务,键为任务id,值为任务信息字典
background_tasks: dict[str, dict] = {}
# 用于存储所有后台任务的执行结果,键为任务id,值为输出内容
background_results: dict[str, str] = {}

# 判断是否为慢操作
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
    # 如果不是bash类型的工具,则不是慢操作
    if tool_name != 'bash':
        return False
    # 获取命令字符串并转为小写
    cmd = tool_input.get('command', '').lower()
    # 对所有慢操作模式进行匹配,只要有一个匹配就返回True
    return any(re.search(pattern, cmd) for pattern in _SLOW_PATTERNS)

# 判断操作是否应该在后台执行
def should_run_background(tool_name: str, tool_input: dict) -> bool:
    # 如果明确要求后台运行,则直接返回True
    if tool_input.get('run_in_background', False):
        return True
    # 否则根据是否为“慢操作”来判断
    return is_slow_operation(tool_name, tool_input)

# 启动一个后台任务
def start_background_task(tool_call_id: str, name: str, args: dict) -> str:
    # 声明使用全局变量_bg_counter
    global _bg_counter
    # 计数器加1
    _bg_counter += 1
    # 生成后台任务id,格式为bg_XXXX
    bg_id = f'bg_{_bg_counter:04d}'
    # 获取要执行的命令内容
    cmd = args.get('command', name)

    # 定义工作线程的函数
    def worker():
        try:
            # 调用工具并获取结果
            result = execute_tool(name, args)
        except Exception as e:
            # 如果执行出错,记录异常信息
            result = f'错误:{type(e).__name__}: {e}'
        # 对共享资源加锁,修改任务状态和记录结果
        with background_lock:
            background_tasks[bg_id]['status'] = 'completed'
            background_results[bg_id] = result

    # 先加锁,把任务信息写入后台任务字典
    with background_lock:
        background_tasks[bg_id] = {'tool_call_id': tool_call_id, 'command': cmd, 'status': 'running'}
    # 启动一个后台线程去执行worker任务(守护线程)
    threading.Thread(target=worker, daemon=True).start()
    # 打印后台任务派发的信息,便于调试和观察
    print(f'  \x1b[33m[后台] 已派发 {bg_id}: {cmd[:40]}\x1b[0m')
    # 返回后台任务id
    return bg_id    

# 收集所有已完成的后台任务结果
def collect_background_results() -> list[str]:
    # 首先加锁,找出所有状态为completed的任务id
    with background_lock:
        ready_ids = [bid for bid, task in background_tasks.items() if task['status'] == 'completed']
    # 用于存放通知消息的列表
    notifications = []
    # 遍历所有已经完成的所有任务id
    for bg_id in ready_ids:
        # 加锁,从任务和结果字典中弹出对应项
        with background_lock:
            task = background_tasks.pop(bg_id)
            output = background_results.pop(bg_id, '')
        # 如果输出内容超过200字符,则只取前200个字符作为概要
        summary = output[:200] if len(output) > 200 else output
        # 生成一个任务完成的通知字符串,并加入通知列表
        notifications.append(
            f'<task_notification>\n'
            f'  <task_id>{bg_id}</task_id>\n'
            f'  <status>completed</status>\n'
            f'  <command>{task["command"]}</command>\n'
            f'  <summary>{summary}</summary>\n'
            f'</task_notification>'
        )
        # 打印后台完成的信息,包含任务id和命令摘要及输出字符数
        print(f'  \x1b[32m[后台完成] {bg_id}: {task["command"][:40]}({len(output)} 字符)\x1b[0m')
    # 返回所有通知消息的列表
    return notifications

13.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
from config import DEFAULT_MAX_TOKENS, MODEL_ID,CONTEXT_LIMIT,ESCALATED_MAX_TOKENS,MAX_RECOVERY_RETRIES,CONTINUATION_PROMPT
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict,message_text
# 从llm模块导入call_llm函数
from llm import call_llm,is_prompt_too_long_error,RecoveryState,with_retry
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
from history import (tool_result_budget,snip_compact,micro_compact,estimate_size,compact_history,repair_message_chain,reactive_compact)
# 从memory模块导入load_memories函数
from memory import load_memories,extract_memories,consolidate_memories
+from background import should_run_background,start_background_task,collect_background_results
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0
# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
    state = RecoveryState()
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 设置所用模型为主模型
    model = MODEL_ID
    # 开始循环,直到遇到return退出
    while True:
        # 从后台收集通知消息(如果有的话)
+       bg_notifications = collect_background_results()
        # 如果收集到了后台通知
+       if bg_notifications:
            # 将收集到的后台通知以用户消息格式追加到messages列表
+           messages.append({'role': 'user', 'content': '\n\n'.join(bg_notifications)})
            # 打印注入后台通知的数量并以绿色高亮显示
+           print(f'  \x1b[32m[注入] {len(bg_notifications)} 条后台通知\x1b[0m')
        # 获取系统提示词
        system = get_system_prompt()
        # 加载有关历史消息的记忆内容
        memories_content = load_memories(messages)
        # 如果记忆内容存在
        if memories_content:
            # 将记忆内容追加到系统提示词后,前面加两个换行符
            system += '\n\n' + memories_content
        # 创建一个用于存储消息压缩前内容的列表
        pre_compress = [
            # 对于messages中的每一个元素m,如果m是字典,则
            {'role': m.get('role', ''), 'content': message_text(m)}
            # 遍历messages列表,只处理那些是字典类型的元素
            for m in messages if isinstance(m, dict)
        ]  
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
        messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
        messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
        messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
        if estimate_size(messages) > CONTEXT_LIMIT:
            print('[自动压缩]')
            messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
        messages[:] = repair_message_chain(messages)    
        if rounds_since_todo >= 3 and messages:
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            rounds_since_todo = 0
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
        if rounds_since_todo >= 3 and messages:
            # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            # 轮数计数器 rounds_since_todo 复位为 0
            rounds_since_todo = 0
        # 尝试执行以下代码块
        try:
           response = with_retry(
                lambda mt=max_tokens, mdl=state.current_model: call_llm(system, messages, mt, mdl),
                state,
            )
        # 捕获所有异常并命名为e
        except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
            if is_prompt_too_long_error(e):
                # 如果还没有尝试过reactive_compact方法进行压缩
                if not state.has_attempted_reactive_compact:
                    # 使用reactive_compact进行消息压缩
                    messages[:] = reactive_compact(messages)
                    # 标记已经尝试过reactive_compact
                    state.has_attempted_reactive_compact = True
                    # 继续while循环,重新尝试
                    continue
                # 如果压缩后仍然过长,则打印错误提示(红色字体)
                print('  \x1b[31m[不可恢复] compact 后仍然过长\x1b[0m')
                # 在消息列表中加入assistant角色的错误消息,提示上下文过大
                messages.append({'role': 'assistant', 'content': '[错误] 上下文过大,无法继续。'})
                # 终止函数执行
                return
            # 获取异常的类型名称
            name = type(e).__name__
            # 打印不可恢复的错误信息,取错误内容的前100个字符(红色字体)
            print(f'  \x1b[31m[不可恢复] {name}: {str(e)[:100]}\x1b[0m')
            # 在消息列表中添加assistant角色的错误信息,包含异常类型和前200字符内容
            messages.append({'role': 'assistant', 'content': f'[错误] {name}: {str(e)[:200]}'})
            # 终止函数执行
            return

        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 判断回复是否因达到最大长度被截断
        if choice.finish_reason == 'length':
            # 如果还未升级max_tokens
            if not state.has_escalated:
                # 升级max_tokens至更大值
                max_tokens = ESCALATED_MAX_TOKENS
                # 标记已升级
                state.has_escalated = True
                # 打印升级提示
                print(f'  \x1b[33m[max_tokens] 升级 {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\x1b[0m')
                # 重新进入循环再次请求
                continue
            # 将助手的消息以dict形式加入消息列表
            messages.append(assistant_message_dict(choice.message))
            # 如果助手回复里包含工具调用
            if choice.message.tool_calls:
                # 遍历所有工具调用
                for tool_call in choice.message.tool_calls:
                    # 添加一条 tool 消息,提示输出被截断未执行
                    messages.append({
                        'role': 'tool',
                        'tool_call_id': tool_call.id,
                        'content': '[输出被截断,未能执行工具]',
                    })
                # 跳出本次循环,重新开始
                continue
            # 如果还在允许的最大恢复次数范围内
            if state.recovery_count < MAX_RECOVERY_RETRIES:
                # 添加一条用户消息,提示助手续写回复
                messages.append({'role': 'user', 'content': CONTINUATION_PROMPT})
                # 恢复计数加一
                state.recovery_count += 1
                # 打印续写提示
                print(f'  \x1b[33m[max_tokens] 续写 {state.recovery_count}/{MAX_RECOVERY_RETRIES}\x1b[0m')
                # 进入下一个循环尝试续写
                continue
            # 已达最大恢复重试次数,打印告警
            print('  \x1b[31m[max_tokens] 已达恢复上限\x1b[0m')
            # 终止函数执行
            return

        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 提取记忆
            extract_memories(pre_compress)
            # 合并记忆
            consolidate_memories()
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks('Stop', messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({'role': 'user', 'content': force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1  
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 如果工具名称是'compact'
            if name == 'compact':
                # 调用compact_history函数,对messages列表进行消息压缩处理
                messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
                break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 判断是否应该以后台任务方式运行工具
+           if should_run_background(name, args):
                # 启动后台任务,并获取后台任务ID
+               bg_id = start_background_task(tool_call.id, name, args)
                # 组织后台任务已启动的输出消息,包括任务ID、命令、通知方式
+               output = (
+                   f'[后台任务 {bg_id} 已启动] '
+                   f'命令: {args.get("command", "")}。'
+                   f'完成后将通过 task_notification 通知。'
+               )
            # 如果不是后台运行,则直接同步执行工具
+           else:
+               try:
                    # 执行工具函数,并获取输出
+                   output = execute_tool(name, args)
+               except Exception as e:
                    # 如果执行过程中发生异常,将异常信息作为输出内容
+                   output = f'错误:{type(e).__name__}: {e}'

            # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks('PostToolUse', name, args, output)
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == 'todo_write':
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )  

13.3. handlers.py #

tools/handlers.py

# 导入操作系统相关模块
import os
# 导入操作系统相关模块
import glob as g
# 导入子进程处理模块
import subprocess
# 从utils模块导入decode_subprocess_output和safe_path函数
from utils import decode_subprocess_output, safe_path
# 从config模块导入文本编码配置
from config import TEXT_ENCODING,WORKDIR
# 从skills模块导入load_skill函数
from skills import load_skill
# 从tasks模块导入create_task函数
from tasks import (create_task,list_tasks,get_task,claim_task,complete_task)
# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
+def run_bash(command: str, run_in_background: bool = False) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == 'nt' and command.strip().lower() == 'date':
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = 'date /t & time /t'
    # 定义危险命令的列表
    dangerous = ['rm -rf /', 'sudo', 'shutdown', 'reboot', '> /dev/']
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return '错误:危险命令已被拦截'
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,            # 要执行的命令
            shell=True,         # 在shell中执行
            cwd=os.getcwd(),    # 当前工作目录设置为当前路径
            capture_output=True,# 捕获标准输出和标准错误
            timeout=120,        # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else '(无输出)'
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return '错误:超时(120 秒)'
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f'错误:{e}'

# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f'...(还有 {len(lines) - limit} 行)']
        # 将行列表拼接为字符串并返回
        return '\n'.join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f'已写入 {len(content)} 字节到 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f'错误:在 {path} 中未找到指定文本'
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING)
        # 返回编辑成功的提示
        return f'已编辑 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return '\n'.join(results) if results else '(无匹配)'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'

CURRENT_TODOS: list[dict] = []

def run_todo_write(todos: list) -> str:
    global CURRENT_TODOS
    for i, t in enumerate(todos):
        if 'content' not in t or 'status' not in t:
            return f'错误:todos[{i}] 缺少 content 或 status'
        if t['status'] not in ('pending', 'in_progress', 'completed'):
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    CURRENT_TODOS = todos
    lines = ['\n\x1b[33m## 当前任务\x1b[0m']
    for t in CURRENT_TODOS:
        icon = {'pending': '\x1b[33m等待中\x1b[0m', 'in_progress': '\x1b[36m处理中\x1b[0m', 'completed': '\x1b[32m已完成\x1b[0m'}[t['status']]
        lines.append(f"  [{icon}] {t['content']}")
    print('\n'.join(lines))
    return f'已更新 {len(CURRENT_TODOS)} 个任务'

def run_create_task(subject: str, description: str = '', blockedBy: list[str] | None = None) -> str:
    task = create_task(subject, description, blockedBy)
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ''
    print(f'  \x1b[34m[创建] {task.subject}{deps}\x1b[0m')
    return f'已创建 {task.id}: {task.subject}{deps}'

def run_list_tasks() -> str:
    tasks = list_tasks()
    if not tasks:
        return '暂无任务。使用 create_task 添加。'
    lines = []
    for t in tasks:
        icon = {'pending': '等待中', 'in_progress': '处理中', 'completed': '已完成'}.get(t.status, '?')
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ''
        owner = f' [{t.owner}]' if t.owner else ''
        lines.append(f'  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}')
    return '\n'.join(lines)

def run_get_task(task_id: str) -> str:
    try:
        return get_task(task_id)
    except FileNotFoundError:
        return f'错误:未找到任务 {task_id}'

def run_claim_task(task_id: str) -> str:
    return claim_task(task_id, owner='agent')

def run_complete_task(task_id: str) -> str:
    return complete_task(task_id)

# 定义TOOL_HANDLERS字典,映射工具名到各自处理函数
TOOL_HANDLERS = {
    'bash': run_bash,#执行shell命令
    'read_file': run_read,#读取文件内容
    'write_file': run_write,#写入文件内容
    'edit_file': run_edit,#编辑文件内容
    'glob': run_glob,#通配符路径匹配
    'todo_write': run_todo_write,#创建并管理当前编码会话的任务列表
    'load_skill': load_skill,#按名称加载技能的完整内容
    'create_task': run_create_task, # 创建新任务
    'list_tasks': run_list_tasks, # 列出所有任务
    'get_task': run_get_task, # 按 ID 获取任务完整详情
    'claim_task': run_claim_task, # 认领 pending 任务,设置 owner 并改为 in_progress
    'complete_task': run_complete_task # 完成 in_progress 任务,并报告下游解阻任务
}

13.4. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)和 run_in_background(布尔类型,默认为 False)
+   _fn_tool(
+       'bash',
+       '执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。',
+       {
+           'command': {'type': 'string'},
+           'run_in_background': {'type': 'boolean', 'default': False}
+       },
+       ['command']
+   ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),

]
TOOLS = [
    *BASE_TOOLS,
     # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),
    _fn_tool('spawn_subagent', '启动子 Agent 处理复杂子任务。仅返回最终结论。', {'description': {'type': 'string'}}, ['description']),
    _fn_tool('load_skill', '按名称加载技能的完整内容。', {'name': {'type': 'string'}}, ['name']),
    _fn_tool('compact', '摘要较早对话以释放上下文空间。', {'focus': {'type': 'string'}}, []),
    _fn_tool('create_task', '创建新任务,可选 blockedBy 依赖。', {'subject': {'type': 'string'}, 'description': {'type': 'string'}, 'blockedBy': {'type': 'array', 'items': {'type': 'string'}}}, ['subject']),
    _fn_tool('list_tasks', '列出所有任务的状态、负责人与依赖。', {}, []),
    _fn_tool('get_task', '按 ID 获取任务完整详情。', {'task_id': {'type': 'string'}}, ['task_id']),
    _fn_tool('claim_task', '认领 pending 任务,设置 owner 并改为 in_progress。', {'task_id': {'type': 'string'}}, ['task_id']),
    _fn_tool('complete_task', '完成 in_progress 任务,并报告下游解阻任务。', {'task_id': {'type': 'string'}}, ['task_id']),
]

13.4. prompt.py #

prompt.py

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR,MEMORY_INDEX,TEXT_ENCODING
# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY
# 定义提示片段的字典,包含系统身份/工作目录/技能部分
PROMPT_SECTIONS = {
    # 'identity' 键,存储智能体的系统身份提示,强调直接行动等规则
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
        f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
+       f"bash 支持 run_in_background 参数以在后台运行耗时命令。"

    ),
    # 'workspace' 键,对应当前的工作目录描述
    'workspace': f'工作目录:{WORKDIR}',
    # 'skill' 键,指明需要完整技术文档时的指引
    'skill': '需要完整技术说明时,使用 load_skill 加载相关文档。',
    # 'memory' 键,指明记忆的使用方式
    'memory': '下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。',
}

# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str,memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS['identity'], PROMPT_SECTIONS['workspace']]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f'可用技能:\n{skills}')
        sections.append(PROMPT_SECTIONS['skill'])
    # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f'可用记忆:\n{memories}')
        sections.append(PROMPT_SECTIONS['memory'])    
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return '\n\n'.join(sections)

# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ''
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return '\n'.join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())

# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ''
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors='replace').strip()

# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None

# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 输出缓存命中的提示信息
        print('  \x1b[90m[缓存命中] system prompt 未变化\x1b[0m')
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt

# 定义子 Agent 的系统提示,用于子任务调用
SUB_SYSTEM = (
    # 子 Agent 的身份/环境要求/任务完成后要求返回摘要
    f'你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。'
    '你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
    '完成分配给你的任务,然后返回简洁摘要。不要继续委派。'
)

14. Cron Scheduler — 时间驱动的自动唤醒 #

"时钟到点,Agent 自己醒" — 独立调度线程、cron_queue 解耦、agent_lock 与用户输入互斥。

本节对应教程 s14,在 s13 后台执行之上引入 定时调度。s12 的 create_task 管持久化工单;s13 的 bg_xxxx 管会话内慢命令;本节管 按 cron 表达式自动触发——到点把 prompt 注入 messages,Agent 无需用户敲回车。

本节要解决什么

能力 s13 后台任务 s14 Cron Scheduler
触发方式 工具调用时派发 时钟到点自动触发
持久化 会话内 bg_xxxx .scheduled_tasks.json(durable=true)
与用户输入 并行(主循环继续) agent_lock 互斥,避免并发写 history
典型场景 pip install 不阻塞 每天 9 点检查日志、每 2 分钟 ping

没有定时调度,Agent 只能「有人说话才动」;周期性巡检、定时提醒做不到。

CronJob 数据模型

字段 含义
id cron_XXXXXX 唯一 ID
cron 5 段表达式:分 时 日 月 周
prompt 触发时注入的消息([定时任务] ...)
recurring true 循环 / false 单次后移除
durable true 落盘 .scheduled_tasks.json / false 仅本会话

四层架构

1. Scheduler(cron_scheduler_loop)
   └─ daemon 线程,每秒轮询 → cron_matches → 写入 cron_queue

2. Queue(cron_queue + cron_lock)
   └─ 调度器与 agent_loop 解耦,线程安全

3. Queue Processor(start_queue_processor)
   └─ 每 0.2s 检查队列;agent_lock 空闲时调用 run_agent_turn_locked()

4. Consumer(agent_loop 开头)
   └─ consume_cron_queue() → 注入 [定时任务] user 消息 → call_llm

cron 匹配要点

三个新工具

工具 作用
schedule_cron 注册 cron 任务(recurring / durable 可选)
list_crons 列出全部任务及循环/持久化标记
cancel_cron 按 ID 取消并更新磁盘

相对 s13 的变化

文件 变化
cron.py 新增:匹配/校验、调度线程、start_cron_scheduler / start_queue_processor
agent.py 循环开头 consume_cron_queue(),注入 [定时任务]
main.py session_history + agent_lock + run_agent_turn_locked;启动调度与队列处理器
config.py 新增 DURABLE_PATH
tools/handlers.py / schema.py 3 个 cron 工具
prompt.py identity 补充 cron 工具说明

s13 的 background.py、s12 的 .tasks/、s11 错误恢复 全部保留。定时调度通过 agent_lock 与用户输入串行,不与后台 bash 线程冲突。

试试这些 prompt:

  1. 安排一个每 2 分钟打印当前日期时间的任务
  2. 列出所有 cron 任务
  3. 取消循环任务并用 list_crons 验证

时序图

从注册到自动执行的完整流程:

sequenceDiagram participant User as 用户/LLM participant Agent as agent_loop participant Cron as cron.py participant Sched as cron_scheduler_loop participant QP as queue_processor participant Disk as .scheduled_tasks.json User->>Agent: schedule_cron("0 9 * * *", "检查日志") Agent->>Cron: schedule_job(...) Cron->>Disk: save_durable_jobs(durable=true) Cron-->>Agent: 已调度 cron_xxxx loop 每秒 Sched->>Cron: cron_matches(now)? alt 匹配且本分钟未触发 Sched->>Cron: cron_queue.append(job) Sched->>Sched: _last_fired[job.id] = minute end end loop 每 0.2s QP->>Cron: has_cron_queue()? alt 有任务且 agent_lock 空闲 QP->>Agent: run_agent_turn_locked() Agent->>Cron: consume_cron_queue() Cron-->>Agent: [CronJob, ...] Agent->>Agent: messages += [定时任务] prompt Agent->>Agent: call_llm → 工具轮 → 回复 end end Note over User,Disk: 用户输入与 cron 投递共用 agent_lock,不会并发写 session_history

说明:durable=false 的任务重启后消失,适合临时测试;recurring=false 触发一次后从 scheduled_jobs 移除。与 s12 工单(.tasks/)和 s13 后台 ID(bg_xxxx)是三种不同的「任务」概念。s15 将在此基础上引入多 Agent 团队协作。

14.1. cron.py #

cron.py

# 导入json模块,用于序列化与反序列化
import json
# 导入random模块,用于生成随机id
import random
# 导入threading模块,实现多线程
import threading
# 导入time模块,实现定时相关操作
import time
# 引入dataclasses的asdict和dataclass,用于数据结构定义和转换为字典
from dataclasses import asdict, dataclass
# 导入datetime类,处理时间相关
from datetime import datetime

# 从config模块中导入DURABLE_PATH和TEXT_ENCODING常量
from config import DURABLE_PATH, TEXT_ENCODING

# 定义CronJob数据类,表示一个定时任务
@dataclass
class CronJob:
    # 任务ID
    id: str
    # cron表达式
    cron: str
    # 任务触发时的提示内容
    prompt: str
    # 是否为循环任务
    recurring: bool
    # 是否需要持久化
    durable: bool

# 定义调度中的任务字典,key为任务id,value为CronJob对象
scheduled_jobs: dict[str, CronJob] = {}
# 定义cron执行队列,存储等待消费的CronJob对象
cron_queue: list[CronJob] = []
# 定义锁对象,保证线程安全
cron_lock = threading.Lock()
# 记录各任务上一次触发的时间,key为任务id,value为时间字符串
_last_fired: dict[str, str] = {}

# 判断cron表达式中的某个字段和具体时间值是否匹配
def _cron_field_matches(field: str, value: int) -> bool:
    # 如果是通配符*,则总是匹配
    if field == '*':
        return True
    # 支持步进写法,如 */5
    if field.startswith('*/'):
        step = int(field[2:])
        return step > 0 and value % step == 0
    # 多个值逗号分隔,如1,5,10
    if ',' in field:
        return any(_cron_field_matches(f.strip(), value) for f in field.split(','))
    # 支持区间写法,如8-10
    if '-' in field:
        lo, hi = field.split('-', 1)
        return int(lo) <= value <= int(hi)
    # 普通数字匹配
    return value == int(field)

# 检查给定时间dt是否满足cron表达式cron_expr
def cron_matches(cron_expr: str, dt: datetime) -> bool:
    # 将cron表达式去除两端空白后按空格分隔为字段列表
    fields = cron_expr.strip().split()
    # 如果字段数不是5,返回False
    if len(fields) != 5:
        return False
    # 解包五个字段,分别为:分、时、日、月、星期
    minute_field, hour_field, day_of_month_field, month_field, day_of_week_field = fields
    # 计算符合cron语法的星期数字:Python中weekday(),0代表周一,转换为cron的0-6,0为周日
    day_of_week_val = (dt.weekday() + 1) % 7

    # 检查分钟字段是否匹配当前时间的分钟值
    minute_match = _cron_field_matches(minute_field, dt.minute)
    # 检查小时字段是否匹配当前时间的小时值
    hour_match = _cron_field_matches(hour_field, dt.hour)
    # 检查日期(一个月中的某天)字段是否匹配当前日期
    day_of_month_match = _cron_field_matches(day_of_month_field, dt.day)
    # 检查月份字段是否匹配当前月份
    month_match = _cron_field_matches(month_field, dt.month)
    # 检查星期字段是否匹配当前星期数字
    day_of_week_match = _cron_field_matches(day_of_week_field, day_of_week_val)

    # 如果分钟、小时、月份中有任何一个不匹配,直接返回False
    if not (minute_match and hour_match and month_match):
        return False
    # 判断日期字段是否为无约束(即为*号)
    day_of_month_unconstrained = day_of_month_field == '*'
    # 判断星期字段是否为无约束(即为*号)
    day_of_week_unconstrained = day_of_week_field == '*'
    # 若日期和星期都不受约束,直接认为匹配
    if day_of_month_unconstrained and day_of_week_unconstrained:
        return True
    # 如果日期不受约束,只要星期字段匹配则返回True
    if day_of_month_unconstrained:
        return day_of_week_match
    # 如果星期不受约束,只要日期字段匹配则返回True
    if day_of_week_unconstrained:
        return day_of_month_match
    # 否则,只要日期或星期有一个匹配就返回True
    return day_of_month_match or day_of_week_match

# 检查某个cron字段是否合法
def _validate_cron_field(field: str, lo: int, hi: int) -> str | None:
    # 若字段为*,合法
    if field == '*':
        return None
    # 检查步长写法
    if field.startswith('*/'):
        step_str = field[2:]
        if not step_str.isdigit():
            return f'无效步长: {field}'
        if int(step_str) <= 0:
            return f'步长必须 > 0: {field}'
        return None
    # 逗号分隔多个字段须全部校验
    if ',' in field:
        for part in field.split(','):
            err = _validate_cron_field(part.strip(), lo, hi)
            if err:
                return err
        return None
    # 检查区间写法是否合法
    if '-' in field:
        parts = field.split('-', 1)
        if not parts[0].isdigit() or not parts[1].isdigit():
            return f'无效范围: {field}'
        a, b = int(parts[0]), int(parts[1])
        if a < lo or a > hi or b < lo or b > hi:
            return f'范围 {field} 超出 [{lo}-{hi}]'
        if a > b:
            return f'范围起始 > 结束: {field}'
        return None
    # 检查是否是合法数字
    if not field.isdigit():
        return f'无效字段: {field}'
    val = int(field)
    # 检查数字是否在合法范围
    if val < lo or val > hi:
        return f'值 {val} 超出 [{lo}-{hi}]'
    return None

# 校验整个cron表达式是否合法
def validate_cron(cron_expr: str) -> str | None:
    # 拆分cron表达式为字段
    fields = cron_expr.strip().split()
    # 字段数必须为5
    if len(fields) != 5:
        return f'需要 5 个字段,实际 {len(fields)} 个'
    # 各字段的上下界
    bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]
    # 字段名称
    names = ['分', '时', '日', '月', '周']
    # 对每个字段逐一校验
    for field, (lo, hi), name in zip(fields, bounds, names):
        err = _validate_cron_field(field, lo, hi)
        if err:
            return f'{name}: {err}'
    return None

# 保存持久化任务到硬盘
def save_durable_jobs():
    # 只保存标记了durable的任务
    durable = [asdict(j) for j in scheduled_jobs.values() if j.durable]
    # 写入到指定文件
    DURABLE_PATH.write_text(
        json.dumps(durable, indent=2, ensure_ascii=False),
        encoding=TEXT_ENCODING,
    )

# 从硬盘加载持久化任务
def load_durable_jobs():
    # 若路径不存在则返回
    if not DURABLE_PATH.exists():
        return
    try:
        # 读取文件,反序列化为对象列表
        jobs = json.loads(DURABLE_PATH.read_text(encoding=TEXT_ENCODING))
        # 每个任务逐一检查合法性,并加入调度列表
        for j in jobs:
            job = CronJob(**j)
            err = validate_cron(job.cron)
            if err:
                print(f'  \x1b[31m[cron] 跳过无效任务 {job.id}: {err}\x1b[0m')
                continue
            scheduled_jobs[job.id] = job
        # 统计有效任务数并打印加载成功提示
        valid = [j for j in jobs if j['id'] in scheduled_jobs]
        if valid:
            print(f'  \x1b[35m[cron] 已加载 {len(valid)} 个持久化任务\x1b[0m')
    # 捕获所有异常,防止崩溃
    except Exception:
        pass

# 调度一个新任务
def schedule_job(
    cron: str,# cron表达式
    prompt: str,# 提示词
    recurring: bool = True,# 是否循环
    durable: bool = True,# 是否持久化
) -> CronJob | str: # 返回CronJob对象或错误字符串
    # 校验cron表达式合法性
    err = validate_cron(cron)
    # 如果校验失败,返回错误字符串
    if err:
        return err
    # 创建CronJob对象,分配随机id
    job = CronJob(
        id=f'cron_{random.randint(0, 999999):06d}',# 任务id
        cron=cron,# cron表达式
        prompt=prompt,# 提示词
        recurring=recurring,# 是否循环
        durable=durable,# 是否持久化
    )
    # 写入任务池(线程安全)
    with cron_lock:
        scheduled_jobs[job.id] = job
    # 若需持久化,写入硬盘
    if durable:
        save_durable_jobs()
    # 打印调度注册提示
    print(f'  \x1b[35m[cron 注册] {job.id} \'{cron}\' → {prompt[:40]}\x1b[0m')
    return job

# 取消一个任务
def cancel_job(job_id: str) -> str:
    # 从任务池移除该任务
    with cron_lock:
        job = scheduled_jobs.pop(job_id, None)
    # 若找不到则返回提示
    if not job:
        return f'未找到任务 {job_id}'
    # 若该任务需要持久化则存盘
    if job.durable:
        save_durable_jobs()
    # 打印取消提示
    print(f'  \x1b[31m[cron 取消] {job_id}\x1b[0m')
    return f'已取消 {job_id}'

# cron调度主循环(线程内)
def cron_scheduler_loop():
    while True:
        # 每秒调度一次
        time.sleep(1)
        # 获取当前时间
        now = datetime.now()
        # 获取分钟粒度的时间戳标识
        minute_marker = now.strftime('%Y-%m-%d %H:%M')
        # 锁定后穷举所有调度任务
        with cron_lock:
            for job in list(scheduled_jobs.values()):
                try:
                    # 当前时间是否符合cron表达式
                    if cron_matches(job.cron, now):
                        # 上一次调度和这次的minute不相等才能触发
                        if _last_fired.get(job.id) != minute_marker:
                            # 将任务加入待触发队列
                            cron_queue.append(job)
                            # 记录本次已触发
                            _last_fired[job.id] = minute_marker
                            # 打印触发提示
                            print(f'  \x1b[35m[cron 触发] {job.id} → {job.prompt[:40]}\x1b[0m')
                        # 若任务不需循环,触发一次后移除
                        if not job.recurring:
                            scheduled_jobs.pop(job.id, None)
                            # 若需持久化,移除后保存
                            if job.durable:
                                save_durable_jobs()
                except Exception as e:
                    # 若出错,打印异常日志
                    print(f'  \x1b[31m[cron 错误] {job.id}: {e}\x1b[0m')

# 消费cron触发队列
def consume_cron_queue() -> list[CronJob]:
    with cron_lock:
        # 备份所有被触发任务
        fired = list(cron_queue)
        # 清空队列
        cron_queue.clear()
    # 返回所有被触发任务
    return fired

# 判断是否有等待消费的任务
def has_cron_queue() -> bool:
    with cron_lock:
        return bool(cron_queue)

# 启动cron调度器(新线程)
def start_cron_scheduler():
    # 先加载持久化任务
    load_durable_jobs()
    # 启动调度线程
    threading.Thread(target=cron_scheduler_loop, daemon=True).start()
    # 打印提示
    print('  \x1b[35m[cron] 调度线程已启动\x1b[0m')

# 定义队列处理器主循环函数,参数为调度函数和agent互斥锁
def _queue_processor_loop(dispatch_fn, agent_lock):
    # 循环执行
    while True:
        # 等待 0.2 秒后再处理(降低CPU占用)
        time.sleep(0.2)
        # 如果没有可消费的定时任务队列,跳过本次循环
        if not has_cron_queue():
            continue
        # 未能获取到agent锁则跳过本次循环
        if not agent_lock.acquire(blocking=False):
            continue
        try:
            # 再次检测是否真的有可消费的定时任务队列
            if not has_cron_queue():
                continue
            # 打印处理队列任务的提示信息
            print('\n  \x1b[35m[队列处理器] 投递定时任务\x1b[0m')
            # 执行传入的调度分发函数
            dispatch_fn()
            # 再打印一个空行,便于输出分隔
            print()
        finally:
            # 在任何情况下都释放agent锁
            agent_lock.release()


# 定义启动队列处理器的函数
def start_queue_processor(run_agent_turn_locked, agent_lock):
    # 创建一个新的线程,目标函数为 _queue_processor_loop
    threading.Thread(
        # 指定线程的目标函数
        target=_queue_processor_loop,
        # 传递 dispatch_fn 和 agent_lock 作为参数
        args=(run_agent_turn_locked, agent_lock),
        # 设置线程为守护线程
        daemon=True,
    # 启动线程
    ).start()
    # 打印队列处理器已启动的提示信息
    print('  \x1b[35m[队列处理器] 已启动\x1b[0m')

14.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json
# 从config模块导入默认最大token数和主模型
from config import DEFAULT_MAX_TOKENS, MODEL_ID,CONTEXT_LIMIT,ESCALATED_MAX_TOKENS,MAX_RECOVERY_RETRIES,CONTINUATION_PROMPT
# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict,message_text
# 从llm模块导入call_llm函数
from llm import call_llm,is_prompt_too_long_error,RecoveryState,with_retry
# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt
# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
from history import (tool_result_budget,snip_compact,micro_compact,estimate_size,compact_history,repair_message_chain,reactive_compact)
# 从memory模块导入load_memories函数
from memory import load_memories,extract_memories,consolidate_memories
from background import should_run_background,start_background_task,collect_background_results
+from cron import consume_cron_queue
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0
# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
    state = RecoveryState()
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 开始循环,直到遇到return退出
    while True:
        # 调用consume_cron_queue函数,获取需要执行的定时任务,并赋值给fired
+       fired = consume_cron_queue()
        # 遍历所有被触发的定时任务job
+       for job in fired:
            # 将定时任务的信息以用户消息的形式追加到messages列表
+           messages.append({'role': 'user', 'content': f'[定时任务] {job.prompt}'})
            # 打印注入的cron任务,内容为job的prompt前50个字符,使用紫色高亮输出
+           print(f'  \x1b[35m[注入 cron] {job.prompt[:50]}\x1b[0m')
        # 从后台收集通知消息(如果有的话)
        bg_notifications = collect_background_results()
        # 如果收集到了后台通知
        if bg_notifications:
            # 将收集到的后台通知以用户消息格式追加到messages列表
            messages.append({'role': 'user', 'content': '\n\n'.join(bg_notifications)})
            # 打印注入后台通知的数量并以绿色高亮显示
            print(f'  \x1b[32m[注入] {len(bg_notifications)} 条后台通知\x1b[0m')
        # 获取系统提示词
        system = get_system_prompt()
        # 加载有关历史消息的记忆内容
        memories_content = load_memories(messages)
        # 如果记忆内容存在
        if memories_content:
            # 将记忆内容追加到系统提示词后,前面加两个换行符
            system += '\n\n' + memories_content
        # 创建一个用于存储消息压缩前内容的列表
        pre_compress = [
            # 对于messages中的每一个元素m,如果m是字典,则
            {'role': m.get('role', ''), 'content': message_text(m)}
            # 遍历messages列表,只处理那些是字典类型的元素
            for m in messages if isinstance(m, dict)
        ]  
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
        messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
        messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
        messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
        if estimate_size(messages) > CONTEXT_LIMIT:
            print('[自动压缩]')
            messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
        messages[:] = repair_message_chain(messages)    
        if rounds_since_todo >= 3 and messages:
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            rounds_since_todo = 0
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
        if rounds_since_todo >= 3 and messages:
            # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
            messages.append({'role': 'user', 'content': '<reminder>请更新你的 todo 列表。</reminder>'})
            # 轮数计数器 rounds_since_todo 复位为 0
            rounds_since_todo = 0
        # 尝试执行以下代码块
        try:
           response = with_retry(
                lambda mt=max_tokens, mdl=state.current_model: call_llm(system, messages, mt, mdl),
                state,
            )
        # 捕获所有异常并命名为e
        except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
            if is_prompt_too_long_error(e):
                # 如果还没有尝试过reactive_compact方法进行压缩
                if not state.has_attempted_reactive_compact:
                    # 使用reactive_compact进行消息压缩
                    messages[:] = reactive_compact(messages)
                    # 标记已经尝试过reactive_compact
                    state.has_attempted_reactive_compact = True
                    # 继续while循环,重新尝试
                    continue
                # 如果压缩后仍然过长,则打印错误提示(红色字体)
                print('  \x1b[31m[不可恢复] compact 后仍然过长\x1b[0m')
                # 在消息列表中加入assistant角色的错误消息,提示上下文过大
                messages.append({'role': 'assistant', 'content': '[错误] 上下文过大,无法继续。'})
                # 终止函数执行
                return
            # 获取异常的类型名称
            name = type(e).__name__
            # 打印不可恢复的错误信息,取错误内容的前100个字符(红色字体)
            print(f'  \x1b[31m[不可恢复] {name}: {str(e)[:100]}\x1b[0m')
            # 在消息列表中添加assistant角色的错误信息,包含异常类型和前200字符内容
            messages.append({'role': 'assistant', 'content': f'[错误] {name}: {str(e)[:200]}'})
            # 终止函数执行
            return

        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 判断回复是否因达到最大长度被截断
        if choice.finish_reason == 'length':
            # 如果还未升级max_tokens
            if not state.has_escalated:
                # 升级max_tokens至更大值
                max_tokens = ESCALATED_MAX_TOKENS
                # 标记已升级
                state.has_escalated = True
                # 打印升级提示
                print(f'  \x1b[33m[max_tokens] 升级 {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\x1b[0m')
                # 重新进入循环再次请求
                continue
            # 将助手的消息以dict形式加入消息列表
            messages.append(assistant_message_dict(choice.message))
            # 如果助手回复里包含工具调用
            if choice.message.tool_calls:
                # 遍历所有工具调用
                for tool_call in choice.message.tool_calls:
                    # 添加一条 tool 消息,提示输出被截断未执行
                    messages.append({
                        'role': 'tool',
                        'tool_call_id': tool_call.id,
                        'content': '[输出被截断,未能执行工具]',
                    })
                # 跳出本次循环,重新开始
                continue
            # 如果还在允许的最大恢复次数范围内
            if state.recovery_count < MAX_RECOVERY_RETRIES:
                # 添加一条用户消息,提示助手续写回复
                messages.append({'role': 'user', 'content': CONTINUATION_PROMPT})
                # 恢复计数加一
                state.recovery_count += 1
                # 打印续写提示
                print(f'  \x1b[33m[max_tokens] 续写 {state.recovery_count}/{MAX_RECOVERY_RETRIES}\x1b[0m')
                # 进入下一个循环尝试续写
                continue
            # 已达最大恢复重试次数,打印告警
            print('  \x1b[31m[max_tokens] 已达恢复上限\x1b[0m')
            # 终止函数执行
            return

        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 提取记忆
            extract_memories(pre_compress)
            # 合并记忆
            consolidate_memories()
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks('Stop', messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({'role': 'user', 'content': force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1  
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f'\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m')
            # 如果工具名称是'compact'
            if name == 'compact':
                # 调用compact_history函数,对messages列表进行消息压缩处理
                messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
                break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks('PreToolUse', name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append({'role': 'tool', 'tool_call_id': tool_call.id, 'content': str(blocked)})
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 判断是否应该以后台任务方式运行工具
            if should_run_background(name, args):
                # 启动后台任务,并获取后台任务ID
                bg_id = start_background_task(tool_call.id, name, args)
                # 组织后台任务已启动的输出消息,包括任务ID、命令、通知方式
                output = (
                    f'[后台任务 {bg_id} 已启动] '
                    f'命令: {args.get("command", "")}。'
                    f'完成后将通过 task_notification 通知。'
                )
            # 如果不是后台运行,则直接同步执行工具
            else:
                try:
                    # 执行工具函数,并获取输出
                    output = execute_tool(name, args)
                except Exception as e:
                    # 如果执行过程中发生异常,将异常信息作为输出内容
                    output = f'错误:{type(e).__name__}: {e}'

            # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks('PostToolUse', name, args, output)
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == 'todo_write':
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )  

14.3. config.py #

config.py

# 导入操作系统相关的模块
import os
# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv
# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# 设置命令行编码为UTF-8
os.system('chcp 65001')
# 设置文本编码为UTF-8
TEXT_ENCODING = 'utf-8'
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ['MODEL_ID']
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY'],
    base_url=os.getenv('OPENAI_BASE_URL'),
)
# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / 'skills'
# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / '.task_outputs' / 'tool-results'
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / '.transcripts'
# 设置记忆目录为工作目录下的 .memory 目录
MEMORY_DIR = WORKDIR / '.memory'
# 创建记忆目录,如果目录不存在
MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
MEMORY_INDEX = MEMORY_DIR / 'MEMORY.md'
# 设置记忆合并阈值为10
CONSOLIDATE_THRESHOLD = 10
# 设置最大重试次数为10
MAX_RETRIES = 10
# 设置基础延迟时间为500毫秒
BASE_DELAY_MS = 500
# 定义连续发生529错误的最大次数
MAX_CONSECUTIVE_529 = 3
# 从环境变量中获取备用模型的名称
FALLBACK_MODEL = os.getenv('FALLBACK_MODEL')
# 定义升级后的最大token数
ESCALATED_MAX_TOKENS = 64000
# 定义最大恢复重试次数为3
MAX_RECOVERY_RETRIES = 3
# 定义续写提示
CONTINUATION_PROMPT = '输出 token 上限已达到。直接继续 — 不要道歉或复述,从思路中断处接上。'
# 设置任务目录为工作目录下的 .tasks 目录
TASKS_DIR = WORKDIR / '.tasks'
# 创建任务目录,如果目录不存在
TASKS_DIR.mkdir(exist_ok=True)
# 定时任务持久化文件
+DURABLE_PATH = WORKDIR / '.scheduled_tasks.json'

14.4. main.py #

main.py

# 导入线程模块
+import threading

# 从agent模块导入agent_loop函数
from agent import agent_loop
# 从cron模块导入start_cron_scheduler和start_queue_processor函数
+from cron import start_cron_scheduler, start_queue_processor
# 从hooks模块导入trigger_user_prompt_hooks函数
from hooks import trigger_user_prompt_hooks

# 定义会话历史记录列表
+session_history: list = []
# 定义互斥锁用于线程同步
+agent_lock = threading.Lock()


# 定义一个函数,带锁执行agent回合,可选参数为用户输入
+def run_agent_turn_locked(user_query: str | None = None):
    # 如果用户有输入,将其加入会话历史中
+   if user_query is not None:
+       session_history.append({'role': 'user', 'content': user_query})
    # 启动agent主循环,处理会话
+   agent_loop(session_history)
    # 获取最新一条历史记录,如果历史为空则为None
+   final = session_history[-1] if session_history else None
    # 如果最新一条是助手且消息内容不为空,则打印出来
+   if final and final.get('role') == 'assistant' and final.get('content'):
+       print(final['content'])


# 程序主入口函数
def main():
    # “定时投递/安排任务”(产生任务),作用是启动定时任务调度器。调度器负责定时、周期性地向任务队列投递任务,它为整个系统源源不断地按计划产生“待处理事件”。
+   start_cron_scheduler()
    # “消费/处理任务队列中的实际任务”,作用是不断轮询检查任务队列,一旦发现有待处理的任务,就会调用 agent 处理方法完成任务。因此,这个线程是专门用来“实际执行任务”的。
+   start_queue_processor(run_agent_turn_locked, agent_lock)
    # 打印用户输入说明
    print('输入问题,回车发送。输入 q 退出。\n')
    # 主循环,不断获取用户输入
    while True:
        try:
            # 提示用户输入,带有前缀色彩
            query = input('\x1b[36m>> \x1b[0m')
        # 捕获输入中断或文件结束符异常,跳出循环
        except (EOFError, KeyboardInterrupt):
            break
        # 如果用户输入为q、exit或空字符串,退出循环
        if query.strip().lower() in ('q', 'exit', ''):
            break
        # 调用钩子函数处理用户输入
        query = trigger_user_prompt_hooks(query)
        # 上锁,执行agent回合处理
+       with agent_lock:
+           run_agent_turn_locked(query)
        # 打印换行
+       print()


# 判断当前模块是否为主程序入口
if __name__ == '__main__':
    # 执行主函数
+   main()

14.5. prompt.py #

prompt.py

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR,MEMORY_INDEX,TEXT_ENCODING
# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY
# 定义提示片段的字典,包含系统身份/工作目录/技能部分
PROMPT_SECTIONS = {
    # 'identity' 键,存储智能体的系统身份提示,强调直接行动等规则
    'identity': (
        f'你是一个编程 Agent。直接行动,不要解释。'
        f'你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
        f'所有破坏性操作需要用户批准。'
        f'开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。'
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
        f"bash 支持 run_in_background 参数以在后台运行耗时命令。"
+       f"定时任务可使用 schedule_cron / list_crons / cancel_cron。"

    ),
    # 'workspace' 键,对应当前的工作目录描述
    'workspace': f'工作目录:{WORKDIR}',
    # 'skill' 键,指明需要完整技术文档时的指引
    'skill': '需要完整技术说明时,使用 load_skill 加载相关文档。',
    # 'memory' 键,指明记忆的使用方式
    'memory': '下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。',
}

# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str,memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS['identity'], PROMPT_SECTIONS['workspace']]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f'可用技能:\n{skills}')
        sections.append(PROMPT_SECTIONS['skill'])
    # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f'可用记忆:\n{memories}')
        sections.append(PROMPT_SECTIONS['memory'])    
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return '\n\n'.join(sections)

# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ''
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return '\n'.join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())

# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ''
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors='replace').strip()

# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None

# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 输出缓存命中的提示信息
        print('  \x1b[90m[缓存命中] system prompt 未变化\x1b[0m')
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt

# 定义子 Agent 的系统提示,用于子任务调用
SUB_SYSTEM = (
    # 子 Agent 的身份/环境要求/任务完成后要求返回摘要
    f'你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。'
    '你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。'
    '完成分配给你的任务,然后返回简洁摘要。不要继续委派。'
)

14.6. handlers.py #

tools/handlers.py

# 导入操作系统相关模块
import glob as g
import os
# 导入子进程处理模块
import subprocess
# 从utils模块导入decode_subprocess_output和safe_path函数
from utils import decode_subprocess_output, safe_path
# 从config模块导入文本编码配置
from config import TEXT_ENCODING,WORKDIR
# 从skills模块导入load_skill函数
from skills import load_skill
# 从tasks模块导入create_task函数
from tasks import (create_task,list_tasks,get_task,claim_task,complete_task)
+from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock
# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str, run_in_background: bool = False) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == 'nt' and command.strip().lower() == 'date':
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = 'date /t & time /t'
    # 定义危险命令的列表
    dangerous = ['rm -rf /', 'sudo', 'shutdown', 'reboot', '> /dev/']
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return '错误:危险命令已被拦截'
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,            # 要执行的命令
            shell=True,         # 在shell中执行
            cwd=os.getcwd(),    # 当前工作目录设置为当前路径
            capture_output=True,# 捕获标准输出和标准错误
            timeout=120,        # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else '(无输出)'
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return '错误:超时(120 秒)'
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f'错误:{e}'

# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f'...(还有 {len(lines) - limit} 行)']
        # 将行列表拼接为字符串并返回
        return '\n'.join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f'已写入 {len(content)} 字节到 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f'错误:在 {path} 中未找到指定文本'
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING)
        # 返回编辑成功的提示
        return f'已编辑 {path}'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return '\n'.join(results) if results else '(无匹配)'
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f'错误:{e}'

# 定义一个全局的任务列表,类型为列表,包含字典元素
CURRENT_TODOS: list[dict] = []

# 定义函数用于写入(刷新)当前任务列表
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量 CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历传入的 todos 列表,并获取每个元素的索引和值
    for i, t in enumerate(todos):
        # 如果某个任务缺少 'content' 或 'status' 字段
        if 'content' not in t or 'status' not in t:
            # 返回带错误信息的字符串,提示缺少字段
            return f'错误:todos[{i}] 缺少 content 或 status'
        # 如果任务的状态不是指定的三种之一
        if t['status'] not in ('pending', 'in_progress', 'completed'):
            # 返回带错误信息的字符串,提示状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 如果全部合法,则赋值给全局 CURRENT_TODOS
    CURRENT_TODOS = todos
    # 构造带颜色代码的当前任务标题字符串,加入 lines 列表
    lines = ['\n\x1b[33m## 当前任务\x1b[0m']
    # 遍历当前所有任务,渲染状态图标文本
    for t in CURRENT_TODOS:
        # 根据不同状态选取不同颜色及文字
        icon = {'pending': '\x1b[33m等待中\x1b[0m', 'in_progress': '\x1b[36m处理中\x1b[0m', 'completed': '\x1b[32m已完成\x1b[0m'}[t['status']]
        # 把格式化后的任务字符串加入 lines
        lines.append(f"  [{icon}] {t['content']}")
    # 用 print 输出所有任务信息到控制台
    print('\n'.join(lines))
    # 返回已更新的任务数量信息字符串
    return f'已更新 {len(CURRENT_TODOS)} 个任务'

# 定义函数用于创建新任务,带标题、描述和依赖参数
def run_create_task(subject: str, description: str = '', blockedBy: list[str] | None = None) -> str:
    # 调用 create_task 创建任务对象
    task = create_task(subject, description, blockedBy)
    # 如果该任务有依赖,拼接依赖展示字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ''
    # 控制台输出任务创建提示(带颜色)
    print(f'  \x1b[34m[创建] {task.subject}{deps}\x1b[0m')
    # 返回创建成功的任务信息字符串
    return f'已创建 {task.id}: {task.subject}{deps}'

# 定义函数用于列出所有任务
def run_list_tasks() -> str:
    # 调用 list_tasks 获取所有任务列表
    tasks = list_tasks()
    # 如果没有任务,则提示无任务
    if not tasks:
        return '暂无任务。使用 create_task 添加。'
    # 初始化结果字符串列表
    lines = []
    # 遍历所有任务对象
    for t in tasks:
        # 根据状态获取对应文字(无则为'?')
        icon = {'pending': '等待中', 'in_progress': '处理中', 'completed': '已完成'}.get(t.status, '?')
        # 如果有依赖,格式化依赖信息;否则置空
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ''
        # 如果有 owner,格式化 owner 信息;否则置空
        owner = f' [{t.owner}]' if t.owner else ''
        # 拼接每条任务信息并加入结果列表
        lines.append(f'  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}')
    # 返回所有任务拼接后的字符串
    return '\n'.join(lines)

# 定义根据 ID 获取单个任务详情的函数
def run_get_task(task_id: str) -> str:
    # 用 try-except 捕获异常
    try:
        # 调用 get_task 获取并返回任务详细内容
        return get_task(task_id)
    # 如果找不到任务,返回错误信息
    except FileNotFoundError:
        return f'错误:未找到任务 {task_id}'

# 定义认领任务的函数,owner 固定为 'agent'
def run_claim_task(task_id: str) -> str:
    # 调用 claim_task 并返回结果
    return claim_task(task_id, owner='agent')

# 定义完成任务的函数
def run_complete_task(task_id: str) -> str:
    # 调用 complete_task 并返回结果
    return complete_task(task_id)

# 定义调度定时(cron)任务的函数
+def run_schedule_cron(
+   cron: str,#cron表达式
+   prompt: str,#提示词
+   recurring: bool = True,#是否循环
+   durable: bool = True,#是否持久化
+) -> str:#返回结果
    # 调用 schedule_job 安排定时任务,返回结果
+   result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
+   if isinstance(result, str):
        # 返回错误提示
+       return f'错误:{result}'
    # 返回调度成功信息,包括 id、表达式和 prompt
+   return f'已调度 {result.id}: \'{cron}\' → {prompt}'

# 定义列出所有 cron 定时任务的函数
+def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
+   with cron_lock:
+       jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
+   if not jobs:
+       return '暂无 cron 任务。使用 schedule_cron 添加。'
    # 初始化结果字符串列表
+   lines = []
    # 遍历所有定时任务
+   for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
+       tag = '循环' if j.recurring else '单次'
        # 根据 durable 标记区分“持久化”或“会话”
+       dur = '持久化' if j.durable else '会话'
        # 拼接任务的信息字符串并加入列表
+       lines.append(f'  {j.id}: \'{j.cron}\' → {j.prompt[:40]} [{tag}, {dur}]')
    # 返回所有任务拼接后的字符串
+   return '\n'.join(lines)

# 定义取消定时任务的函数
+def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
+   return cancel_job(job_id)

# 定义TOOL_HANDLERS字典,映射工具名到各自处理函数
TOOL_HANDLERS = {
    'bash': run_bash,#执行shell命令
    'read_file': run_read,#读取文件内容
    'write_file': run_write,#写入文件内容
    'edit_file': run_edit,#编辑文件内容
    'glob': run_glob,#通配符路径匹配
    'todo_write': run_todo_write,#创建并管理当前编码会话的任务列表
    'load_skill': load_skill,#按名称加载技能的完整内容
    'create_task': run_create_task, # 创建新任务
    'list_tasks': run_list_tasks, # 列出所有任务
    'get_task': run_get_task, # 按 ID 获取任务完整详情
    'claim_task': run_claim_task, # 认领 pending 任务,设置 owner 并改为 in_progress
+   'complete_task': run_complete_task, # 完成 in_progress 任务,并报告下游解阻任务
+   'schedule_cron': run_schedule_cron, # 调度定时任务
+   'list_crons': run_list_crons, # 列出所有定时任务
+   'cancel_cron': run_cancel_cron, # 取消定时任务
}

14.7. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(name: str, description: str, properties: dict, required: list[str]) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        'type': 'function',
        # 定义函数的具体内容
        'function': {
            # 函数名称
            'name': name,
            # 函数描述
            'description': description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            'parameters': {'type': 'object', 'properties': properties, 'required': required},
        },
    }

# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    # 定义 bash 命令行工具,参数为 command(字符串类型)和 run_in_background(布尔类型,默认为 False)
    _fn_tool(
        'bash',
        '执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。',
        {
            'command': {'type': 'string'},
            'run_in_background': {'type': 'boolean', 'default': False}
        },
        ['command']
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool('read_file', '读取文件内容。', {'path': {'type': 'string'}, 'limit': {'type': 'integer'}}, ['path']),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool('write_file', '将内容写入文件。', {'path': {'type': 'string'}, 'content': {'type': 'string'}}, ['path', 'content']),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool('edit_file', '在文件中精确替换一段文本(仅替换一次)。', {'path': {'type': 'string'}, 'old_text': {'type': 'string'}, 'new_text': {'type': 'string'}}, ['path', 'old_text', 'new_text']),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool('glob', '按 glob 模式查找文件。', {'pattern': {'type': 'string'}}, ['pattern']),

]
TOOLS = [
    *BASE_TOOLS,
     # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool('todo_write', '创建并管理当前编码会话的任务列表。', {'todos': {'type': 'array', 'items': {'type': 'object', 'properties': {'content': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['pending', 'in_progress', 'completed']}}, 'required': ['content', 'status']}}}, ['todos']),
    _fn_tool('spawn_subagent', '启动子 Agent 处理复杂子任务。仅返回最终结论。', {'description': {'type': 'string'}}, ['description']),
    _fn_tool('load_skill', '按名称加载技能的完整内容。', {'name': {'type': 'string'}}, ['name']),
    _fn_tool('compact', '摘要较早对话以释放上下文空间。', {'focus': {'type': 'string'}}, []),
    _fn_tool('create_task', '创建新任务,可选 blockedBy 依赖。', {'subject': {'type': 'string'}, 'description': {'type': 'string'}, 'blockedBy': {'type': 'array', 'items': {'type': 'string'}}}, ['subject']),
    _fn_tool('list_tasks', '列出所有任务的状态、负责人与依赖。', {}, []),
    _fn_tool('get_task', '按 ID 获取任务完整详情。', {'task_id': {'type': 'string'}}, ['task_id']),
    _fn_tool('claim_task', '认领 pending 任务,设置 owner 并改为 in_progress。', {'task_id': {'type': 'string'}}, ['task_id']),
    _fn_tool('complete_task', '完成 in_progress 任务,并报告下游解阻任务。', {'task_id': {'type': 'string'}}, ['task_id']),
+   _fn_tool(
+       'schedule_cron',
+       '调度 cron 任务。cron 为 5 段:分 时 日 月 周。',
+       {
+           'cron': {'type': 'string', 'description': '5 段 cron 表达式'},
+           'prompt': {'type': 'string', 'description': '触发时注入的消息'},
+           'recurring': {'type': 'boolean', 'description': 'true=循环,false=单次'},
+           'durable': {'type': 'boolean', 'description': 'true=持久化到磁盘'},
+       },
+       ['cron', 'prompt'],
+   ),
+   _fn_tool('list_crons', '列出所有已注册的 cron 任务。', {}, []),
+   _fn_tool('cancel_cron', '按 ID 取消 cron 任务。', {'job_id': {'type': 'string'}}, ['job_id']),
]

15. Agent Teams — 多 Agent 异步协作 #

"派出去,别等着" — 后台队友线程、MessageBus 文件收件箱、Lead 循环开头自动注入。

本节对应教程 s15,在 s14 定时调度之上引入 多 Agent 团队协作。s13 的 spawn_subagent 是同步阻塞、只返回最终结论;本节管 异步委派——Lead 派队友后立即继续,队友在独立线程里跑精简 agent 循环,通过 MessageBus 把结果写回 Lead 收件箱。

本节要解决什么

场景 s13 spawn_subagent s15 spawn_teammate
执行方式 同步阻塞,Lead 干等 后台 daemon 线程,立即返回
结果回传 tool_result 直接返回摘要 .mailboxes/lead.jsonl → inject_lead_inbox
中途通信 不支持 send_message 双向投递
典型场景 一次性子任务 长耗时调研、并行分工、Lead 继续别的活

没有 Agent Teams,复杂任务只能串行堆在一个上下文里;Lead 也无法在队友干活时继续响应用户或 cron。

拓扑:Lead + Teammate

Lead(主 agent_loop)
  ├─ spawn_teammate  →  启动 daemon 线程,tool 立刻返回「已启动」
  ├─ send_message    →  BUS.send('lead', teammate, ...)
  ├─ check_inbox     →  主动读 lead 收件箱(与 inject 共用,读即消费)
  └─ inject_lead_inbox(每轮循环开头)→  把队友来信注入 messages

Teammate(spawn_teammate_thread → run())
  ├─ 独立 messages[] + 精简 LLM 循环(最多 TEAMMATE_MAX_ROUNDS=10 轮)
  ├─ 每轮开头 read_inbox(name) 收 Lead 来信
  ├─ 工具执行(bash / read / write / …)
  └─ 结束时 BUS.send(name, 'lead', summary, 'result')

MessageBus:基于文件的收件箱

机制 说明
存储 .mailboxes/{agent}.jsonl,每行一条 JSON 消息
发送 send(from, to, content) 追加写入 {to}.jsonl
读取 read_inbox(agent) 读全部行后 删除文件(读即消费)
注入 inject_lead_inbox 格式化为 [收件箱]\n来自 xxx: ... 作为 user 消息

消息字段:from、to、content、type(message / result)、ts。

三个新工具(仅 Lead)

工具 作用
spawn_teammate 按 name / role / prompt 启动后台队友;同名队友在 active_teammates 中则拒绝重复启动
send_message Lead → 队友:写入队友收件箱,队友下轮循环开头读取
check_inbox Lead 主动查看队友回信(与 inject_lead_inbox 消费同一 lead.jsonl)

与 s13 后台任务 / s14 Cron 的关系

概念 触发 通知方式
s13 后台 bash 工具层 should_run_background <task_notification> 注入
s14 Cron 时钟到点 [定时任务] 注入
s15 队友 Lead 调 spawn_teammate [收件箱] 注入 / check_inbox

三者互不替代:Cron 是时间驱动,后台 bash 是单命令异步,Agent Teams 是完整子 Agent 在独立上下文里多轮推理。

相对 s14 的变化

文件 变化
teams.py 新增:MessageBus、spawn_teammate_thread、inject_lead_inbox
agent.py 循环开头 inject_lead_inbox(messages)
config.py 新增 MAILBOX_DIR = .mailboxes/
tools/handlers.py / schema.py 3 个团队工具
prompt.py identity 补充 Lead 身份与团队工具说明

s14 的 cron、s13 的 background.py、s12 的 .tasks/ 全部保留。队友线程与 Lead 共用 WORKDIR 和文件系统,但 不共用 session_history——各自维护独立 messages[]。

试试这些 prompt:

  1. 生成一名名为 alice 的后端开发者。让她创建一个名为 schema.sql 的文件,并包含一个 users 表。
  2. 生成一名名为 bob 的测试人员。让他检查 schema.sql 是否存在,并列出其内容。

观察重点:Lead 如何启动队友?.mailboxes/ 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history?

时序图

从委派队友到 Lead 收到回信的完整流程:

sequenceDiagram participant User as 用户 participant Lead as agent_loop (Lead) participant Tool as spawn_teammate participant TM as Teammate 线程 participant LLM as LLM participant BUS as MessageBus participant Inbox as .mailboxes/lead.jsonl User->>Lead: 复杂子任务 Lead->>LLM: call_llm LLM-->>Lead: tool_call: spawn_teammate(name, role, prompt) Lead->>Tool: spawn_teammate_thread(...) Tool->>TM: Thread.start(daemon) Tool-->>Lead: 队友 'researcher' 已启动 Lead-->>User: 本轮可继续 / 结束(不阻塞) par Lead 继续 Lead->>Lead: 可执行其他 tool / 回复用户 and 队友后台执行 loop 最多 10 轮 TM->>BUS: read_inbox(researcher) BUS-->>TM: Lead 来信(若有) TM->>LLM: chat.completions + tools LLM-->>TM: tool_calls (bash/read/write/...) TM->>TM: handler(**args) 执行工具 opt 中途汇报 TM->>BUS: send(researcher, lead, 进度) BUS->>Inbox: append lead.jsonl end end TM->>BUS: send(researcher, lead, summary, result) BUS->>Inbox: append lead.jsonl TM->>TM: active_teammates.pop(name) end Note over Lead,Inbox: 下一轮 agent_loop 开头(用户输入 / cron / 同 turn 下一轮 while) Lead->>BUS: inject_lead_inbox → read_inbox(lead) BUS->>Inbox: 读并删除文件 BUS-->>Lead: [msg, ...] Lead->>Lead: messages += user [收件箱]... Lead->>LLM: call_llm(Lead 看到队友回信) LLM-->>Lead: 基于队友结果继续回复

说明:队友消息在 .mailboxes/ 中持久化到被读取为止;inject_lead_inbox 只在 agent_loop 每轮循环开头调用——若 Lead 本轮已结束且用户不再输入,回信会留在收件箱,直到下次用户输入或 cron 触发新回合。教学版队友最多 10 轮 LLM 循环(真实 Claude Code 使用 idle loop)。与 spawn_subagent 的同步阻塞不同,spawn_teammate 适合「派出去、稍后收信」的协作模式。s16 将在此基础上引入团队协议与权限冒泡。

15.1. teams.py #

teams.py


# 导入json模块,用于处理JSON数据
import json
# 导入threading模块,用于多线程
import threading
# 导入time模块,用于时间处理
import time
# 当前 Agent 身份(Lead 主线程默认 lead;队友线程启动时设为队友名)
from contextvars import ContextVar
# 从config模块导入常量和对象
from config import (
    WORKDIR,  # 工作目录
    client,  # 大语言模型客户端
    MODEL_ID, # 主模型名称
    DEFAULT_MAX_TOKENS,# 默认最大token数
    MAILBOX_DIR,   # 邮箱目录
    TEXT_ENCODING # 文本编码方式
)
# 从tools.schema模块导入队友工具列表
from tools.schema import TEAMMATE_TOOLS
# 从history模块导入repair_message_chain函数
from history import repair_message_chain
# 从utils模块导入assistant_message_dict方法
from utils import assistant_message_dict
# 定义主管(lead)的名字
LEAD_NAME = "lead"
# 队友 LLM 调用最大轮次(防止无限循环)
TEAMMATE_MAX_ROUNDS = 50
# 当前调用工具的 Agent 名称
current_agent: ContextVar[str] = ContextVar("current_agent", default="lead")
# active_teammates: 队友名 → 线程对象
active_teammates: dict[str, threading.Thread] = {}
# MessageBus 文件读写锁
_bus_lock = threading.Lock()

# 消息总线类,用于管理不同agent间消息传递
class MessageBus:
    """基于文件的消息总线。每个 Agent 一个 .jsonl 收件箱,读取即消费。"""
    # 发送消息的方法
    def send(
        self,
        from_agent: str,# 发送者
        to_agent: str,# 接收者
        content: str,# 消息内容
        msg_type: str = "message",# 消息类型
    ):
        # 构造消息内容的字典
        msg = {
            "from": from_agent,  # 发送者
            "to": to_agent,  # 接收者
            "content": content,  # 消息内容
            "type": msg_type,  # 消息类型
            "ts": time.time(),  # 时间戳
        }
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
        with _bus_lock:
            # 以追加模式写入收件箱
            with open(inbox, "a", encoding=TEXT_ENCODING) as f:
                # 将消息写为json字符串,每条一行
                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
        # 控制台打印消息发送信息
        print(
            f"  \x1b[33m[总线] {from_agent} → {to_agent}[{msg_type}]: {content[:50]}\x1b[0m"
        )

    # 读取某agent收件箱的方法(与 send 共用锁,避免读写竞态丢信)
    def read_inbox(self, agent: str) -> list[dict]:
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{agent}.jsonl"
        with _bus_lock:
            # 如果收件箱文件不存在,则返回空列表
            if not inbox.exists():
                return []
            # 读取所有消息,每行解析为json字典
            msgs = [
                json.loads(line)
                for line in inbox.read_text(encoding=TEXT_ENCODING).splitlines()
                if line.strip()
            ]
            # 读取后删除收件箱文件
            inbox.unlink()
        # 返回消息列表
        return msgs


# 实例化消息总线对象
BUS = MessageBus()

# 获取队友 LLM 上下文,只取最新 tail 条消息,并修复 tool 链
def _teammate_llm_context(messages: list, tail: int = 20) -> list:
    # 如果消息数量大于 tail,则取最后 tail 条,否则全部取
    window = messages[-tail:] if len(messages) > tail else list(messages)
    # 修复 tool 链,防止 API 错误,返回修复后的窗口消息
    return repair_message_chain(window)

# 启动一个队友线程函数
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
    # 如果请求启动的名字与 Lead 名字重复,则返回错误
    if name == LEAD_NAME:
        return f"错误:不能使用保留名 '{LEAD_NAME}'"
    # 查找当前名字的队友线程是否存在
    existing = active_teammates.get(name)
    # 如果该线程已经存在并且存活,则提示已存在
    if existing and existing.is_alive():
        return f"队友 '{name}' 已存在且仍在运行"
    # 如果线程对象存在但未存活,将其从 active_teammates 移除
    if existing:
        active_teammates.pop(name, None)
        # 打印黄色日志说明旧线程被移除,可以重新启动
        print(f"  \x1b[33m[队友] {name} 旧线程已退出,允许重新启动\x1b[0m")
    # 构建 system prompt,指示 AI 队友身份及工作指令
    system = (
        f"你是 '{name}',角色为 {role}。"
        f"工作目录: {WORKDIR}。使用 Windows cmd 命令。"
        f"使用工具完成任务后通过 send_message 将结果发送给 '{LEAD_NAME}'。"
    )
    # 队友线程主执行函数
    def run():
        # 延迟导入,避免与 handlers 循环依赖
        from tools.executor import execute_tool
        # 绑定当前线程的 Agent 身份,防止伪造  from_agent
        identity_token = current_agent.set(name)
        # 初始化消息,prompt作为第一条user消息
        messages = [{"role": "user", "content": prompt}]
        # 用于记录退出原因
        exit_reason = ""
        # LLM 调用轮次计数
        llm_rounds = 0
         # try-finally 保证安全清理退出
        try:
            while True:
                 # 达到最大 LLM 轮次则退出
                if llm_rounds >= TEAMMATE_MAX_ROUNDS:
                    exit_reason = f"达到最大轮次 {TEAMMATE_MAX_ROUNDS}"
                    print(f"  \x1b[33m[队友] {name} {exit_reason}\x1b[0m")
                    break
                llm_rounds += 1
                # 尝试请求 LLM 补全
                try:
                    response = client.chat.completions.create(
                        model=MODEL_ID,
                        messages=[
                            {"role": "system", "content": system},
                            *_teammate_llm_context(messages),
                        ],
                        tools=TEAMMATE_TOOLS,
                        max_tokens=DEFAULT_MAX_TOKENS,
                    )
                except Exception as e:
                    exit_reason = f"LLM 错误: {type(e).__name__}: {e}"
                    print(f"  \x1b[31m[队友] {name} {exit_reason}\x1b[0m")
                    break
                # 得到 assistant 返回的第一个回复消息对象
                assistant = response.choices[0].message
                # 格式化并加入通知消息历史
                messages.append(assistant_message_dict(assistant))
                # 如果 assistant 没有调用任何工具,跳出当前大循环
                if not assistant.tool_calls:
                    break
                # 有工具调用时,依次处理每一项工具调用
                for tool_call in assistant.tool_calls:
                    tname = tool_call.function.name
                    args = json.loads(tool_call.function.arguments or "{}")
                    preview = json.dumps(args, ensure_ascii=False)
                    print(f"  \x1b[36m[{name}] > {tname} {preview[:100]}\x1b[0m")
                    # 工具异常不打崩整条线程
                    try:
                        output = execute_tool(tname, args)
                    except Exception as e:
                        output = f"工具错误: {type(e).__name__}: {e}"
                        print(f"  \x1b[31m[{name}] {output}\x1b[0m")
                    messages.append(
                        {
                            "role": "tool",
                            "tool_call_id": tool_call.id,
                            "content": output,
                        }
                    )
            # 结束时将 summary 发给 lead
            # 初始化 summary,优先使用退出原因,否则默认为“完成。”
            summary = exit_reason or "完成。"
            # 从后往前遍历消息历史,寻找最后一条有效的助手回复
            for msg in reversed(messages):
                # 判断当前消息是否为助手角色且包含内容
                if msg.get("role") == "assistant" and msg.get("content"):
                    # 取出消息中的内容字段
                    content = msg["content"]
                    # 确认内容是非空字符串
                    if isinstance(content, str) and content.strip():
                        # 用该内容更新 summary
                        summary = content
                        # 找到后立即跳出循环
                        break
            # 通过消息总线将 summary 作为结果发送给 lead
            BUS.send(name, LEAD_NAME, summary, "result")
            # 打印队友已结束的绿色提示信息
            print(f"  \x1b[32m[队友] {name} 已结束\x1b[0m")
        finally:
            current_agent.reset(identity_token)
            active_teammates.pop(name, None)   
    # 创建线程对象,目标为 run 函数,设置为守护线程
    thread = threading.Thread(target=run, daemon=True)
    # 将该线程注册到 active_teammates 字典
    active_teammates[name] = thread
    # 启动线程
    thread.start()
    # 启动后打印青色控制台日志
    print(f"  \x1b[36m[队友] 已启动 {name},角色 {role}\x1b[0m")
    return f"队友 '{name}' 已启动,角色 {role}"         

# 将收件箱消息格式化为文本字符串(含 type / request_id,便于 review_plan)
def format_inbox_messages(msgs: list[dict]) -> str:
    lines = []
    for m in msgs:
        msg_type = m.get("type", "message")
        # 协议消息突出 request_id
        header = f"来自 {m['from']} [{msg_type}]"
        lines.append(f"{header}: {m['content'][:200]}")
    return "[收件箱]\n" + "\n".join(lines)


# 注入lead的收件箱消息到对话消息列表
def inject_lead_inbox(messages: list) -> int:
    # 读取lead的收件箱
    inbox = BUS.read_inbox(LEAD_NAME)
    # 如果没有消息,返回0
    if not inbox:
        return
    # 把收件箱内容格式化为一条user消息,添加到对话消息列表
    messages.append({'role': 'user', 'content': format_inbox_messages(inbox)})
    # 控制台打印注入了多少条消息
    print(f'  \x1b[33m[收件箱] 已注入 {len(inbox)} 条消息\x1b[0m')

# 判断指定名字的队友线程是否在运行
def is_teammate_running(name: str) -> bool:
    # 从 active_teammates 字典中获取指定名字的线程对象
    thread = active_teammates.get(name)
    # 判断线程对象是否存在且线程是否存活
    return thread is not None and thread.is_alive()


# 定义函数,读取 Lead 收件箱。
def consume_inbox(agent_name: str) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(agent_name)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 返回读取到的所有消息
    return msgs

15.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json

# 从config模块导入默认最大token数和主模型
from config import (
    DEFAULT_MAX_TOKENS,
    MODEL_ID,
    CONTEXT_LIMIT,
    ESCALATED_MAX_TOKENS,
    MAX_RECOVERY_RETRIES,
    CONTINUATION_PROMPT,
)

# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict

# 从llm模块导入call_llm函数
from llm import call_llm, is_prompt_too_long_error, RecoveryState, with_retry

# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt

# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从teams模块导入inject_lead_inbox函数
+from teams import inject_lead_inbox
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
from history import (
    tool_result_budget,
    snip_compact,
    micro_compact,
    estimate_size,
    compact_history,
    repair_message_chain,
    reactive_compact,
)

# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks

# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict, message_text

# 从memory模块导入load_memories函数
from memory import load_memories, extract_memories, consolidate_memories
# 从background模块导入should_run_background,start_background_task,collect_background_results函数
from background import should_run_background,start_background_task,collect_background_results
# 从cron模块导入consume_cron_queue函数
from cron import consume_cron_queue
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0


# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
    state = RecoveryState()
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 开始循环,直到遇到return退出
    while True:
        # 调用consume_cron_queue函数,获取需要执行的定时任务,并赋值给fired
        fired = consume_cron_queue()
        # 遍历所有被触发的定时任务job
        for job in fired:
            # 将定时任务的信息以用户消息的形式追加到messages列表
           messages.append({'role': 'user', 'content': f'[定时任务] {job.prompt}'})
            # 打印注入的cron任务,内容为job的prompt前50个字符,使用紫色高亮输出
           print(f'  \x1b[35m[注入 cron] {job.prompt[:50]}\x1b[0m')
        # 注入lead inbox
+       inject_lead_inbox(messages)   
        # 从后台收集通知消息(如果有的话)
        bg_notifications = collect_background_results()
        # 如果收集到了后台通知
        if bg_notifications:
            # 将收集到的后台通知以用户消息格式追加到messages列表
           messages.append({'role': 'user', 'content': '\n\n'.join(bg_notifications)})
            # 打印注入后台通知的数量并以绿色高亮显示
           print(f'  \x1b[32m[注入] {len(bg_notifications)} 条后台通知\x1b[0m')
        # 获取系统提示词
        system = get_system_prompt()
        # 加载有关历史消息的记忆内容
        memories_content = load_memories(messages)
        # 如果记忆内容存在
        if memories_content:
            # 将记忆内容追加到系统提示词后,前面加两个换行符
            system += "\n\n" + memories_content
        # 创建一个用于存储消息压缩前内容的列表
        pre_compress = [
            # 对于messages中的每一个元素m,如果m是字典,则
            {"role": m.get("role", ""), "content": message_text(m)}
            # 遍历messages列表,只处理那些是字典类型的元素
            for m in messages
            if isinstance(m, dict)
        ]
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
        messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
        messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
        messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
        if estimate_size(messages) > CONTEXT_LIMIT:
            print("[自动压缩]")
            messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
        messages[:] = repair_message_chain(messages)
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
        if rounds_since_todo >= 3 and messages:
            # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
            messages.append(
                {
                    "role": "user",
                    "content": "<reminder>请更新你的 todo 列表。</reminder>",
                }
            )
            print(f"\x1b[33m> 请更新你的 todo 列表。\x1b[0m")
            # 轮数计数器 rounds_since_todo 复位为 0
            rounds_since_todo = 0
        # 尝试执行以下代码块
        try:
            response = with_retry(
                lambda max_tokens=max_tokens, model=state.current_model: call_llm(
                    system, messages, max_tokens, model
                ),
                state,
            )
        # 捕获所有异常并命名为e
        except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
            if is_prompt_too_long_error(e):
                # 如果还没有尝试过reactive_compact方法进行压缩
               if not state.has_attempted_reactive_compact:
                    # 使用reactive_compact进行消息压缩
                   messages[:] = reactive_compact(messages)
                    # 标记已经尝试过reactive_compact
                   state.has_attempted_reactive_compact = True
                    # 继续while循环,重新尝试
                   continue
                # 如果压缩后仍然过长,则打印错误提示(红色字体)
               print('  \x1b[31m[不可恢复] compact 后仍然过长\x1b[0m')
                # 在消息列表中加入assistant角色的错误消息,提示上下文过大
               messages.append({'role': 'assistant', 'content': '[错误] 上下文过大,无法继续。'})
                # 终止函数执行
               return
            # 获取异常的类型名称
            name = type(e).__name__
            # 打印不可恢复的错误信息,取错误内容的前100个字符(红色字体)
            print(f'  \x1b[31m[不可恢复] {name}: {str(e)[:100]}\x1b[0m')
            # 在消息列表中添加assistant角色的错误信息,包含异常类型和前200字符内容
            messages.append({'role': 'assistant', 'content': f'[错误] {name}: {str(e)[:200]}'})
            # 终止函数执行
            return
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 判断回复是否因达到最大长度被截断
        if choice.finish_reason == 'length':
            # 如果还未升级max_tokens
           if not state.has_escalated:
                # 升级max_tokens至更大值
               max_tokens = ESCALATED_MAX_TOKENS
                # 标记已升级
               state.has_escalated = True
                # 打印升级提示
               print(f'  \x1b[33m[max_tokens] 升级 {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\x1b[0m')
                # 重新进入循环再次请求
               continue
            # 将助手的消息以dict形式加入消息列表
           messages.append(assistant_message_dict(choice.message))
            # 如果助手回复里包含工具调用
           if choice.message.tool_calls:
                # 遍历所有工具调用
               for tool_call in choice.message.tool_calls:
                    # 添加一条 tool 消息,提示输出被截断未执行
                   messages.append({
                       'role': 'tool',
                       'tool_call_id': tool_call.id,
                       'content': '[输出被截断,未能执行工具]',
                   })
                # 跳出本次循环,重新开始
               continue
            # 如果还在允许的最大恢复次数范围内
           if state.recovery_count < MAX_RECOVERY_RETRIES:
                # 添加一条用户消息,提示助手续写回复
               messages.append({'role': 'user', 'content': CONTINUATION_PROMPT})
                # 恢复计数加一
               state.recovery_count += 1
                # 打印续写提示
               print(f'  \x1b[33m[max_tokens] 续写 {state.recovery_count}/{MAX_RECOVERY_RETRIES}\x1b[0m')
                # 进入下一个循环尝试续写
               continue
            # 已达最大恢复重试次数,打印告警
           print('  \x1b[31m[max_tokens] 已达恢复上限\x1b[0m')
            # 终止函数执行
           return
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 提取记忆
            extract_memories(pre_compress)
            # 合并记忆
            consolidate_memories()
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks("Stop", messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({"role": "user", "content": force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f"\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m")
            # 如果工具名称是'compact'
            if name == "compact":
                # 调用compact_history函数,对messages列表进行消息压缩处理
                messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
                break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks("PreToolUse", name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": str(blocked),
                    }
                )
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 判断是否应该以后台任务方式运行工具
            if should_run_background(name, args):
                # 启动后台任务,并获取后台任务ID
               bg_id = start_background_task(tool_call.id, name, args)
                # 组织后台任务已启动的输出消息,包括任务ID、命令、通知方式
               output = (
                   f'[后台任务 {bg_id} 已启动] '
                   f'命令: {args.get("command", "")}。'
                   f'完成后将通过 task_notification 通知。'
               )
              # 如果不是后台运行,则直接同步执行工具
            else:
               try:
                    # 执行工具函数,并获取输出
                   output = execute_tool(name, args)
               except Exception as e:
                    # 如果执行过程中发生异常,将异常信息作为输出内容
                   output = f'错误:{type(e).__name__}: {e}'
             # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks("PostToolUse", name, args, output)
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == "todo_write":
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )

15.3. config.py #

config.py

# 导入操作系统相关的模块
import os

# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv

# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ["MODEL_ID"]
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
    base_url=os.getenv("OPENAI_BASE_URL"),
)
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# Change Code Page 设置命令行编码为UTF-8,UTF-8对应的代码页编号是65001,GBK 对应的代码页编号是 936
os.system("chcp 65001")
# 设置文本编码为UTF-8
TEXT_ENCODING = "utf-8"

# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / "skills"

# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results"
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
# 设置记忆目录为工作目录下的 .memory 目录
MEMORY_DIR = WORKDIR / ".memory"
# 创建记忆目录,如果目录不存在
MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
# 设置记忆合并阈值为10
CONSOLIDATE_THRESHOLD = 10
# 设置最大重试次数为10
MAX_RETRIES = 10
# 设置基础延迟时间为500毫秒
BASE_DELAY_MS = 500
# 定义连续发生529错误的最大次数
MAX_CONSECUTIVE_529 = 3
# 从环境变量中获取备用模型的名称
FALLBACK_MODEL = os.getenv("FALLBACK_MODEL")
# 定义升级后的最大token数
ESCALATED_MAX_TOKENS = 64000
# 定义最大恢复重试次数为3
MAX_RECOVERY_RETRIES = 3
# 定义续写提示
CONTINUATION_PROMPT = (
    "输出 token 上限已达到。直接继续 — 不要道歉或复述,从思路中断处接上。"
)
# 设置任务目录为工作目录下的 .tasks 目录
TASKS_DIR = WORKDIR / ".tasks"
# 创建任务目录,如果目录不存在
TASKS_DIR.mkdir(exist_ok=True)
# 定时任务持久化文件
DURABLE_PATH = WORKDIR / '.scheduled_tasks.json'
# 队友消息邮箱目录
+MAILBOX_DIR = WORKDIR / '.mailboxes'
# 创建队友消息邮箱目录,如果目录不存在
+MAILBOX_DIR.mkdir(exist_ok=True)

15.4. prompt.py #

prompt.py

from config import WORKDIR

# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR, MEMORY_INDEX, TEXT_ENCODING

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    "identity": (
        f"你是一个编程 Agent。直接行动,不要解释。"
        f"你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
        f"所有破坏性操作需要用户批准。"
        f"开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。"
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
        f"bash 支持 run_in_background 参数以在后台运行耗时命令。"
        f"定时任务可使用 schedule_cron / list_crons / cancel_cron。"
+       f"遇到复杂子问题时,可使用 spawn_teammate 委派队友。"
+       f"teammate团队协作可使用 spawn_teammate / send_message / check_inbox。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    "workspace": f"工作目录:{WORKDIR}",
    # 'skill' 键,指明需要完整技术文档时的指引
    "skill": "需要完整技术说明时,使用 load_skill 加载相关文档。",
    # 'memory' 键,指明记忆的使用方式
    "memory": "下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。",
}


# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str, memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS["identity"], PROMPT_SECTIONS["workspace"]]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f"可用技能:\n{skills}")
        sections.append(PROMPT_SECTIONS["skill"])
        # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f"可用记忆:\n{memories}")
        sections.append(PROMPT_SECTIONS["memory"])
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return "\n\n".join(sections)


# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ""
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return "\n".join(
        f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()
    )


# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ""
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors="replace").strip()


# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None


# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt


# 定义子任务的系统提示语
SUB_SYSTEM = (
    f"你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。"
    "你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
    "完成分配给你的任务,然后返回简洁摘要。不要继续委派。"
)

15.5. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入操作系统相关模块
import glob as g
# 导入subprocess模块,用于执行子进程
import subprocess

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output, safe_path

# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING, WORKDIR

# 从config模块导入文本编码配置
from config import TEXT_ENCODING, WORKDIR

# 从skills模块导入load_skill函数
from skills import load_skill

# 从tasks模块导入create_task函数
from tasks import create_task, list_tasks, get_task, claim_task, complete_task

# 从cron模块导入schedule_job, cancel_job, scheduled_jobs, cron_lock函数
from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock

# 导入操作系统相关模块
import glob as g

# 从 teams 模块导入 spawn_teammate_thread,BUS, LEAD_NAME, format_inbox_messages
+from teams import (spawn_teammate_thread,current_agent,BUS,LEAD_NAME,is_teammate_running,format_inbox_messages,consume_inbox)

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str, run_in_background: bool = False) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == "nt" and command.strip().lower() == "date":
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = "date /t & time /t"
    # 定义危险命令的列表
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return "错误:危险命令已被拦截"
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,  # 要执行的命令
            shell=True,  # 在shell中执行
            cwd=os.getcwd(),  # 当前工作目录设置为当前路径
            capture_output=True,  # 捕获标准输出和标准错误
            timeout=120,  # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b"") + (r.stderr or b"")).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else "(无输出)"
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return "错误:超时(120 秒)"
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f"错误:{e}"


# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f"...(还有 {len(lines) - limit} 行)"]
        # 将行列表拼接为字符串并返回
        return "\n".join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f"已写入 {len(content)} 字节到 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f"错误:在 {path} 中未找到指定文本"
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(
            text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING
        )
        # 返回编辑成功的提示
        return f"已编辑 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return "\n".join(results) if results else "(无匹配)"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
CURRENT_TODOS: list[dict] = []


# 定义run_todo_write函数,参数为todos列表,返回字符串
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历todos列表,获取每个任务及其索引
    for i, t in enumerate(todos):
        # 如果任务中缺少content或status字段
        if "content" not in t or "status" not in t:
            # 返回错误提示,指出缺少字段的位置
            return f"错误:todos[{i}] 缺少 content 或 status"
        # 如果任务的status不是允许的三种状态
        if t["status"] not in ("pending", "in_progress", "completed"):
            # 返回错误提示,指出状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 校验全部通过后,更新全局任务列表
    CURRENT_TODOS = todos
    # 初始化显示用的lines列表,第一行为标题,并加黄颜色
    lines = ["\n\x1b[33m## 当前任务\x1b[0m"]
    # 遍历所有当前任务
    for t in CURRENT_TODOS:
        # 根据任务状态,选择不同的彩色标签
        icon = {
            "pending": "\x1b[33m等待中\x1b[0m",
            "in_progress": "\x1b[36m处理中\x1b[0m",
            "completed": "\x1b[32m已完成\x1b[0m",
        }[t["status"]]
        # 将格式化后的任务内容和标签加入lines
        lines.append(f"  [{icon}] {t['content']}")
    # 将所有内容组合成字符串打印到标准输出
    print("\n".join(lines))
    # 返回已更新任务数的字符串提示
    return f"已更新 {len(CURRENT_TODOS)} 个任务"


# 定义run_create_task函数,用于创建新任务
def run_create_task(
    # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
    subject: str,
    description: str = "",
    blockedBy: list[str] | None = None,
    # 函数返回类型为字符串
) -> str:
    # 调用create_task函数创建任务对象
    task = create_task(subject, description, blockedBy)
    # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
    # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
    # 返回已创建任务的ID、主题及依赖信息提示
    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
def run_list_tasks() -> str:
    # 调用list_tasks获取所有任务列表
    tasks = list_tasks()
    # 如果任务列表为空
    if not tasks:
        # 返回暂无任务的提示信息
        return "暂无任务。使用 create_task 添加。"
    # 初始化用于存储显示行的空列表
    lines = []
    # 遍历所有任务
    for t in tasks:
        # 根据任务状态获取对应的中文状态标签
        icon = {
            # pending状态对应“等待中”
            "pending": "等待中",
            # in_progress状态对应“处理中”
            "in_progress": "处理中",
            # completed状态对应“已完成”
            "completed": "已完成",
            # 按任务状态取值,未知状态则返回问号
        }.get(t.status, "?")
        # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
        # 若任务有负责人则格式化负责人信息,否则为空字符串
        owner = f" [{t.owner}]" if t.owner else ""
        # 将格式化后的任务信息行加入lines列表
        lines.append(f"  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}")
    # 将所有行用换行符拼接成字符串后返回
    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
def run_get_task(task_id: str) -> str:
    # 尝试获取指定ID的任务
    try:
        # 调用get_task返回任务详情
        return get_task(task_id)
    # 捕获任务文件不存在的异常
    except FileNotFoundError:
        # 返回未找到任务的错误提示
        return f"错误:未找到任务 {task_id}"


# 定义run_claim_task函数,认领指定任务,返回字符串
def run_claim_task(task_id: str) -> str:
    # 以agent为负责人认领该任务并返回结果
    return claim_task(task_id, owner="agent")


# 定义run_complete_task函数,完成指定任务,返回字符串
def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
    return complete_task(task_id)


# 定义调度定时(cron)任务的函数
def run_schedule_cron(
    cron: str,  # cron表达式
    prompt: str,  # 提示词
    recurring: bool = True,  # 是否循环
    durable: bool = True,  # 是否持久化
) -> str:  # 返回结果
    # 调用 schedule_job 安排定时任务,返回结果
    result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
    if isinstance(result, str):
        # 返回错误提示
        return f"错误:{result}"
    # 返回调度成功信息,包括 id、表达式和 prompt
    return f"已调度 {result.id}: '{cron}' → {prompt}"


# 定义列出所有 cron 定时任务的函数
def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
    with cron_lock:
        jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
    if not jobs:
        return "暂无 cron 任务。使用 schedule_cron 添加。"
    # 初始化结果字符串列表
    lines = []
    # 遍历所有定时任务
    for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
        tag = "循环" if j.recurring else "单次"
        # 根据 durable 标记区分“持久化”或“会话”
        dur = "持久化" if j.durable else "会话"
        # 拼接任务的信息字符串并加入列表
        lines.append(f"  {j.id}: '{j.cron}' → {j.prompt[:40]} [{tag}, {dur}]")
    # 返回所有任务拼接后的字符串
    return "\n".join(lines)


# 定义取消定时任务的函数
def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
    return cancel_job(job_id)

# 定义函数,启动一个队友 agent 线程
+def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
    # 调用 spawn_teammate_thread 启动队友 agent,传递名字、角色和 prompt
+   return spawn_teammate_thread(name, role, prompt)

# 定义函数,通过消息总线发送消息给指定对象
+def run_send_message(to: str, content: str) -> str:
    # 发送方固定为当前会话身份,不可伪造
+   from_agent = current_agent.get()
    # 使用 BUS 发送消息
+   BUS.send(from_agent, to, content)
+   if to != LEAD_NAME and not is_teammate_running(to):
        # 返回已写入收件箱但队友未运行的提示
+       return (
+           f"已从 {from_agent} 写入 {to} 的收件箱,但该队友未在运行。"
+           f"请 spawn_teammate 重启后才会被读取。"
+       )
    # 返回发送结果的字符串说明
+   return f"已从 {from_agent} 发送给 {to}"


# 定义函数,仅允许读取当前 Agent 自己的收件箱(Lead 只能读 lead)
+def run_check_inbox() -> str:
    # 当前会话身份
+   name = current_agent.get()
    # Lead 与队友都只能消费自己的收件箱,避免抢走对方消息
+   msgs = consume_inbox(name)
    # 如果收件箱消息为空,返回提示信息
+   if not msgs:
+       return f"({name} 的收件箱为空)"
    # 如果收件箱有消息,格式化这些消息并返回
+   return format_inbox_messages(msgs)
# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
    "todo_write": run_todo_write,
    "load_skill": load_skill,  # 按名称加载技能的完整内容
    "create_task": run_create_task,  # 创建新任务
    "list_tasks": run_list_tasks,  # 列出所有任务
    "get_task": run_get_task,  # 按 ID 获取任务完整详情
    "claim_task": run_claim_task,  # 认领 pending 任务,设置 owner 并改为 in_progress
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "schedule_cron": run_schedule_cron,  # 调度定时任务
    "list_crons": run_list_crons,  # 列出所有定时任务
    "cancel_cron": run_cancel_cron,  # 取消定时任务
+   "spawn_teammate": run_spawn_teammate,  # 在后台线程启动队友 Agent。
+   "send_message": run_send_message,  # 通过 MessageBus 向队友发送消息。
+   "check_inbox": run_check_inbox,  # 仅检查当前 Agent 自己的收件箱。
}

15.6. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(
    name: str, description: str, properties: dict, required: list[str]
) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        "type": "function",
        # 定义函数的具体内容
        "function": {
            # 函数名称
            "name": name,
            # 函数描述
            "description": description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    _fn_tool(
        "bash",
        "执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。",
        {
            "command": {"type": "string"},
            "run_in_background": {"type": "boolean", "default": False},
        },
        ["command"],
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool(
        "read_file",
        "读取文件内容。",
        {"path": {"type": "string"}, "limit": {"type": "integer"}},
        ["path"],
    ),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool(
        "write_file",
        "将内容写入文件。",
        {"path": {"type": "string"}, "content": {"type": "string"}},
        ["path", "content"],
    ),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool(
        "edit_file",
        "在文件中精确替换一段文本(仅替换一次)。",
        {
            "path": {"type": "string"},
            "old_text": {"type": "string"},
            "new_text": {"type": "string"},
        },
        ["path", "old_text", "new_text"],
    ),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool(
        "glob", "按 glob 模式查找文件。", {"pattern": {"type": "string"}}, ["pattern"]
    ),  # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
]
TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool(
        "todo_write",
        "创建并管理当前编码会话的任务列表。",
        {
            "todos": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "content": {"type": "string"},
                        "status": {
                            "type": "string",
                            "enum": ["pending", "in_progress", "completed"],
                        },
                    },
                    "required": ["content", "status"],
                },
            }
        },
        ["todos"],
    ),
    _fn_tool(
        "spawn_subagent",
        "启动子 Agent 处理复杂子任务。仅返回最终结论。",
        {"description": {"type": "string"}},
        ["description"],
    ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    ),
    _fn_tool(
        "compact", "摘要较早对话以释放上下文空间。", {"focus": {"type": "string"}}, []
    ),
    _fn_tool(
        "create_task",
        "创建新任务,可选 blockedBy 依赖。",
        {
            "subject": {"type": "string"},
            "description": {"type": "string"},
            "blockedBy": {"type": "array", "items": {"type": "string"}},
        },
        ["subject"],
    ),
    _fn_tool("list_tasks", "列出所有任务的状态、负责人与依赖。", {}, []),
    _fn_tool(
        "get_task",
        "按 ID 获取任务完整详情。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "claim_task",
        "认领 pending 任务,设置 owner 并改为 in_progress。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "complete_task",
        "完成 in_progress 任务,并报告下游解阻任务。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "schedule_cron",
        "调度 cron 任务。cron 为 5 段:分 时 日 月 周。",
        {
            "cron": {"type": "string", "description": "5 段 cron 表达式"},
            "prompt": {"type": "string", "description": "触发时注入的消息"},
            "recurring": {"type": "boolean", "description": "true=循环,false=单次"},
            "durable": {"type": "boolean", "description": "true=持久化到磁盘"},
        },
        ["cron", "prompt"],
    ),
    _fn_tool("list_crons", "列出所有已注册的 cron 任务。", {}, []),
    _fn_tool(
        "cancel_cron",
        "按 ID 取消 cron 任务。",
        {"job_id": {"type": "string"}},
        ["job_id"],
    ),
+   _fn_tool(
+       "spawn_teammate",
+       "启动自主队友 Agent。",
+       {
+           "name": {"type": "string"},
+           "role": {"type": "string"},
+           "prompt": {"type": "string"},
+       },
+       ["name", "role", "prompt"],
+   ),
+   _fn_tool(
+       "send_message",
+       "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
+       {
+           "to": {"type": "string"},
+           "content": {"type": "string"},
+       },
+       ["to", "content"],
+   ),
+   _fn_tool(
+       "check_inbox",
+       "检查自己的收件箱(队友回信)。",
+       {},
+       [],
+   ),
]
+TEAMMATE_TOOLS = [
+   *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
+   _fn_tool(
+       "todo_write",
+       "创建并管理当前编码会话的任务列表。",
+       {
+           "todos": {
+               "type": "array",
+               "items": {
+                   "type": "object",
+                   "properties": {
+                       "content": {"type": "string"},
+                       "status": {
+                           "type": "string",
+                           "enum": ["pending", "in_progress", "completed"],
+                       },
+                   },
+                   "required": ["content", "status"],
+               },
+           }
+       },
+       ["todos"],
+   ),
+   _fn_tool(
+       "load_skill",
+       "按名称加载技能的完整内容。",
+       {"name": {"type": "string"}},
+       ["name"],
+   ),
+   _fn_tool(
+       "send_message",
+       "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
+       {
+           "to": {"type": "string"},
+           "content": {"type": "string"},
+       },
+       ["to", "content"],
+   ),
+   _fn_tool(
+       "check_inbox",
+       "检查自己的收件箱(队友回信)。",
+       {},
+       [],
+   ),
+]

16. Team Protocols — 队友之间要有约定 #

"请求带着 ID 出去,回复带着同一 ID 回来" — ProtocolState 追踪、按类型分发、idle 等待关机握手。

本节对应教程 s16,在 s15 松散消息之上引入 结构化请求-响应协议。s15 的 send_message 能传文本,但关机只能杀线程、高风险计划也没有审批闭环。本节用同一套机制解决两类协商:一方发请求、另一方回响应,全程用 request_id 关联,状态机走 pending → approved / rejected。

本节要解决什么

场景 s15 松散消息 s16 协议
关机 杀线程 / 等自然退出,半成品文件留盘 shutdown_request → 队友确认 → shutdown_response
计划审批 无结构,Lead 难对照「哪份计划」 plan_approval_request ↔ plan_approval_response + request_id
消息处理 一律当普通文本注入 dispatch / _process_teammate_inbox 按 type 路由
队友生命周期 最多 N 轮后退出 无 tool_use 后进入 idle_poll,等 inbox 或超时

没有协议,Lead 无法体面回收队友,也无法用同一 ID 把「请求」和「批复」钉在一起。

三样新增机制

机制 作用
ProtocolState + pending_requests 记录谁发的、发给谁、类型、状态、payload
match_response 用 request_id 找回状态,并校验响应类型是否匹配请求类型
consume_lead_inbox Lead 读收件箱时先路由 *_response,再注入 history,避免读走消息却没更新协议状态

MessageBus.send 增加 metadata 字段(至少带 request_id;关机/审批还可带 approve)。

两种协议,一套握手

协议 方向 工具 / 消息类型 用途
关机 Lead → 队友 request_shutdown → shutdown_request / shutdown_response 优雅退出
计划审批 队友 ↔ Lead submit_plan / review_plan 等(教学示例) 高风险操作前征得同意

教学版完整跑通关机握手;计划审批演示 request-response 消息流,未必实现「未 approved 就拦截 bash/write」的执行门控。

队友 idle loop:干完不立刻消失

LLM 返回非 tool_use
  → idle_poll:每 IDLE_POLL_INTERVAL 秒读 inbox
  → 收到 shutdown_request → 回 shutdown_response → 退出
  → 收到普通消息 → 注入 messages → 继续工作轮
  → IDLE_TIMEOUT 内无事 → 超时退出

这样 Lead 发关机请求时,队友仍在轮询收件箱,而不是已经退出导致请求无人应答。

四步关机握手(核心链路)

① Lead: request_shutdown(alice)
     → pending_requests[req_id] = ProtocolState(type=shutdown, status=pending)
     → BUS.send(..., "shutdown_request", metadata={request_id})

② Alice: idle / 工作轮读到 inbox
     → _process_teammate_inbox 识别 shutdown_request
     → BUS.send(..., "shutdown_response", metadata={request_id, approve:true})
     → 停止队友循环

③ Lead: 下一轮 inject_lead_inbox → consume_lead_inbox
     → 发现 type 以 _response 结尾 → match_response(req_id, approve)

④ pending_requests[req_id].status = approved
     → 收件箱文本注入 messages,LLM 看到关机结果

request_id 是全链路关联键:请求带着它出去,回复带着它回来;类型校验保证 shutdown_response 不会误批 plan_approval 请求。

相对 s15 的变化

文件 变化
teams.py 新增 ProtocolState、pending_requests、match_response、consume_lead_inbox、idle_poll、run_request_shutdown;MessageBus 支持 metadata
tools/handlers.py / schema.py Lead 增加 request_shutdown
prompt.py identity 补充协议工具说明(关机 / 计划审批)
agent.py 仍通过 inject_lead_inbox 消费 Lead 收件箱(内部改为走 consume_lead_inbox)

s15 的 spawn_teammate / send_message / check_inbox 全部保留;协议是叠在 MessageBus 上的一层约定,不是替换总线。

试试这些 prompt:

  1. 生成一名名为 alice 的后端开发者,再让她创建 config.py,然后再请求她优雅关闭。
  2. 派bob 创建一张用户表的schema.sql,先给bob发request_plan 让bob submit_plan,lead批准后 review_plan 通过bob再创建

观察重点:控制台是否出现 [协议] shutdown_request → ... 与 match_response 的 approved?.mailboxes/ 里 JSON 是否带 metadata.request_id?队友是否进入 idle 后再应答关机?

时序图

从 Lead 请求关机到协议状态落定的完整流程:

sequenceDiagram participant Lead as agent_loop (Lead) participant Tool as request_shutdown participant Pend as pending_requests participant BUS as MessageBus participant TM as Teammate (idle_poll) participant InboxL as .mailboxes/lead.jsonl participant InboxT as .mailboxes/alice.jsonl Note over Lead,TM: 队友已在跑;LLM 无 tool_use 后进入 idle Lead->>Tool: request_shutdown("alice") Tool->>Pend: ProtocolState(req_id, type=shutdown, status=pending) Tool->>BUS: send(lead→alice, shutdown_request, metadata.request_id) BUS->>InboxT: append alice.jsonl Tool-->>Lead: 已向 alice 发送关闭请求(req: req_xxxxxx) loop idle_poll 每 5s TM->>BUS: read_inbox(alice) BUS->>InboxT: 读并删除 BUS-->>TM: [shutdown_request + request_id] end TM->>BUS: send(alice→lead, shutdown_response, {request_id, approve:true}) BUS->>InboxL: append lead.jsonl TM->>TM: 退出队友循环 / pop active_teammates Note over Lead,InboxL: 下一轮 agent_loop 开头 Lead->>BUS: inject_lead_inbox → consume_lead_inbox BUS->>InboxL: 读并删除 BUS-->>Lead: [shutdown_response] Lead->>Pend: match_response(shutdown_response, req_id, approve=true) Pend->>Pend: status = approved(含类型校验) Lead->>Lead: messages += user [收件箱]... Lead->>Lead: LLM 看到关机结果,可继续回复用户

说明:match_response 会校验「请求类型 ↔ 响应类型」并忽略重复响应;consume_lead_inbox 与 check_inbox 共用读箱路径时,应先路由协议再交给 LLM,避免协议状态漏更新。队友在 idle 中应答关机,比「杀线程」多了一次可追溯握手。s17 将在 idle 之上引入自主认领任务(看板 + 空闲轮询)。

16.1 请求关机 #

16.1.1. hooks.py #

hooks.py

# 从config模块导入工作目录变量
from config import WORKDIR

# 定义一个钩子字典,每个事件对应一个回调函数列表
HOOKS = {'UserPromptSubmit': [], 'PreToolUse': [], 'PostToolUse': [], 'Stop': []}

# 定义禁止执行的命令列表
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]

# 定义需要用户确认的危险命令关键字列表(增加cmd的删除命令)
DESTRUCTIVE = ['rm ', '> /etc/', 'chmod 777', 'del ', 'erase ']

# 注册钩子函数,将回调添加到对应事件的钩子列表
def register_hook(event: str, callback):
    HOOKS[event].append(callback)

# 注入当前工作目录信息到用户查询
def workspace_inject_hook(query: str) -> str | None:
    # 打印注入工作目录的钩子信息
    #print(f'\x1b[90m[HOOK] UserPromptSubmit:注入工作目录 {WORKDIR}\x1b[0m')
    # 返回带有工作目录信息的查询字符串
    return f'<workspace>\n当前工作目录:{WORKDIR}\n</workspace>\n\n{query}'

# 权限控制钩子函数,对命令执行进行校验
def permission_hook(name: str, args: dict):
    # 如果工具类型是bash命令
    if name == 'bash':
        # 检查是否包含禁止列表中的命令
        for pattern in DENY_LIST:
            if pattern in args.get('command', ''):
                # 打印拦截信息
                print(f"\n\x1b[31m⛔ 已拦截:'{pattern}'\x1b[0m")
                # 返回拒绝权限的提示
                return '禁止列表拒绝权限'
        # 检查是否包含破坏性关键字
        for kw in DESTRUCTIVE:
            if kw in args.get('command', ''):
                # 打印警告信息
                print(f'\n\x1b[33m⚠  可能破坏性的命令\x1b[0m')
                print(f'   工具: {name}({args})')
                # 询问用户是否允许
                choice = input('   允许?[y/N] ').strip().lower()
                # 如果用户未确认,拒绝操作
                if choice not in ('y', 'yes'):
                    return '用户拒绝权限'
    # 如果是写文件或编辑文件操作
    if name in ('write_file', 'edit_file'):
        # 获取目标路径
        path = args.get('path', '')
        # 校验路径是否在工作目录下
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            # 警告工作区外写入
            print(f'\n\x1b[33m⚠  在工作区外写入\x1b[0m')
            print(f'   工具: {name}({args})')
            # 询问用户是否允许
            choice = input('   允许?[y/N] ').strip().lower()
            # 如果用户未确认,拒绝操作
            if choice not in ('y', 'yes'):
                return '用户拒绝权限'
    # 返回None表示通过检查
    return None  

# 日志钩子函数,记录调用信息
def log_hook(name: str, args: dict):
    # 取参数前两项并转换为字符串用于预览
    args_preview = str(list(args.values())[:2])[:60]
    # 打印钩子触发信息
    print(f'\x1b[90m[HOOK] {name}({args_preview})\x1b[0m')
    # 无特殊行为,直接返回None
    return None 

# 钩子,处理工具输出过大的情况
def large_output_hook(name: str, args: dict, output):
    # 判断输出长度是否超过10万字符
    if len(str(output)) > 1000000:
        # 打印输出过大警告
        print(f'\x1b[33m[HOOK] ⚠ {name} 输出过大:{len(str(output))} 字符\x1b[0m')
    # 返回None
    return None  

# 会话统计钩子函数
def summary_hook(messages: list):
    # 统计工具调用的次数
    tool_count = sum(1 for m in messages if m.get('role') == 'tool')
    # 打印工具调用次数信息
    print(f'\x1b[90m[HOOK] Stop:本次会话共使用 {tool_count} 次工具调用\x1b[0m')
    # 无特殊返回,直接None
    return None       

# 注册“用户提交”事件的钩子
register_hook('UserPromptSubmit', workspace_inject_hook)
# 注册“工具使用前”权限检查钩子
- register_hook('PreToolUse', permission_hook)
# 注册“工具使用前”日志记录钩子
register_hook('PreToolUse', log_hook)
# 注册“工具使用后”大输出检测钩子
register_hook('PostToolUse', large_output_hook)
# 注册停止事件的会话总结钩子
register_hook('Stop', summary_hook)

# 触发用户输入相关的钩子链
def trigger_user_prompt_hooks(query: str) -> str:
    # 当前待处理的查询
    current = query
    # 依次触发钩子
    for callback in HOOKS['UserPromptSubmit']:
        # 调用每个钩子获取结果
        result = callback(current)
        # 如果返回字符串则更新current
        if isinstance(result, str):
            current = result
    # 返回处理后的查询
    return current

# 通用钩子触发函数
def trigger_hooks(event: str, *args):
    # 按注册顺序依次触发对应事件下的钩子
    for callback in HOOKS[event]:
        # 调用钩子并获取返回值
        result = callback(*args)
        # 如果返回非None则终止并返回
        if result is not None:
            return result
    # 所有钩子都返回None则返回None
    return None

16.1.2. prompt.py #

prompt.py

from config import WORKDIR

# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR, MEMORY_INDEX, TEXT_ENCODING

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    "identity": (
        f"你是一个编程 Agent。直接行动,不要解释。"
        f"你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
        f"所有破坏性操作需要用户批准。"
        f"开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。"
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
        f"bash 支持 run_in_background 参数以在后台运行耗时命令。"
        f"定时任务可使用 schedule_cron / list_crons / cancel_cron。"
        f"遇到复杂子问题时,可使用 spawn_teammate 委派队友。"
        f"teammate团队协作可使用 spawn_teammate / send_message / check_inbox。"
+       f"request_plan 要求队友 submit_plan 后,用 review_plan(request_id, approve) 批准或拒绝;"
+       f"任务结束或需回收资源时用 request_shutdown 请求队友优雅退出。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    "workspace": f"工作目录:{WORKDIR}",
    # 'skill' 键,指明需要完整技术文档时的指引
    "skill": "需要完整技术说明时,使用 load_skill 加载相关文档。",
    # 'memory' 键,指明记忆的使用方式
    "memory": "下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。",
}


# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str, memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS["identity"], PROMPT_SECTIONS["workspace"]]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f"可用技能:\n{skills}")
        sections.append(PROMPT_SECTIONS["skill"])
        # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f"可用记忆:\n{memories}")
        sections.append(PROMPT_SECTIONS["memory"])
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return "\n\n".join(sections)


# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ""
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return "\n".join(
        f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()
    )


# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ""
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors="replace").strip()


# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None


# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt


# 定义子任务的系统提示语
SUB_SYSTEM = (
    f"你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。"
    "你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
    "完成分配给你的任务,然后返回简洁摘要。不要继续委派。"
)

16.1.3. teams.py #

teams.py


# 导入json模块,用于处理JSON数据
import json
# 导入threading模块,用于多线程
import threading
# 导入time模块,用于时间处理
import time
# 当前 Agent 身份(Lead 主线程默认 lead;队友线程启动时设为队友名)
from contextvars import ContextVar
# 导入dataclass模块,用于定义数据类
+from dataclasses import dataclass, field
# 从config模块导入常量和对象
from config import (
    WORKDIR,  # 工作目录
    client,  # 大语言模型客户端
    MODEL_ID, # 主模型名称
    DEFAULT_MAX_TOKENS,# 默认最大token数
    MAILBOX_DIR,   # 邮箱目录
    TEXT_ENCODING # 文本编码方式
)
# 从tools.schema模块导入队友工具列表
from tools.schema import TEAMMATE_TOOLS
# 从history模块导入repair_message_chain函数
from history import repair_message_chain
# 从utils模块导入assistant_message_dict方法
from utils import assistant_message_dict
# 定义主管(lead)的名字
LEAD_NAME = "lead"
# 导入random模块,用于生成 request_id
+import random
# 队友 LLM 调用最大轮次(防止无限循环)
TEAMMATE_MAX_ROUNDS = 50
# 空闲超时时间(单位:秒)
+IDLE_TIMEOUT = 60
# 空闲轮询时间间隔(单位:秒)
+IDLE_POLL_INTERVAL = 5
# 当前调用工具的 Agent 名称
current_agent: ContextVar[str] = ContextVar("current_agent", default="lead")
# active_teammates: 队友名 → 线程对象
active_teammates: dict[str, threading.Thread] = {}
# agent 最大工作回合数
+WORK_MAX_ROUNDS = 10
# MessageBus 文件读写锁
_bus_lock = threading.Lock()
# 使用dataclass装饰器定义一个协议状态的数据结构
+@dataclass
+class ProtocolState:
    # 请求的唯一标识符
+   request_id: str
    # 协议类型,可能为 shutdown 或 plan_approval
+   type: str       # shutdown | plan_approval
    # 请求发送者
+   sender: str
    # 请求目标对象
+   target: str
    # 状态,可能为 pending、approved 或 rejected
+   status: str     # pending | approved | rejected
    # 附加数据/信息
+   payload: str
    # 创建时间,默认为当前时间
+   created_at: float = field(default_factory=time.time)

# 用于存储所有挂起的协议请求,键为请求ID,值为协议状态对象
+pending_requests: dict[str, ProtocolState] = {}

# 生成新的唯一请求ID
+def new_request_id() -> str:
    # 随机生成6位数字,格式化为 req_xxxxxx 的字符串
+   return f'req_{random.randint(0, 999999):06d}'
# 消息总线类,用于管理不同agent间消息传递
class MessageBus:
    """基于文件的消息总线。每个 Agent 一个 .jsonl 收件箱,读取即消费。"""
    # 发送消息的方法
    def send(
        self,
        from_agent: str,# 发送者
        to_agent: str,# 接收者
        content: str,# 消息内容
        msg_type: str = "message",# 消息类型
+       metadata: dict | None = None, # 附加元数据,默认为 None
    ):
        # 构造消息内容的字典
        msg = {
            "from": from_agent,  # 发送者
            "to": to_agent,  # 接收者
            "content": content,  # 消息内容
            "type": msg_type,  # 消息类型
            "ts": time.time(),  # 时间戳
+           'metadata': metadata or {},            # 元数据,默认为空字典
        }
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
        with _bus_lock:
            # 以追加模式写入收件箱
            with open(inbox, "a", encoding=TEXT_ENCODING) as f:
                # 将消息写为json字符串,每条一行
                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
        # 控制台打印消息发送信息
        print(
            f"  \x1b[33m[总线] {from_agent} → {to_agent}[{msg_type}]: {content[:50]}\x1b[0m"
        )

    # 读取某agent收件箱的方法(与 send 共用锁,避免读写竞态丢信)
    def read_inbox(self, agent: str) -> list[dict]:
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{agent}.jsonl"
        with _bus_lock:
            # 如果收件箱文件不存在,则返回空列表
            if not inbox.exists():
                return []
            # 读取所有消息,每行解析为json字典
            msgs = [
                json.loads(line)
                for line in inbox.read_text(encoding=TEXT_ENCODING).splitlines()
                if line.strip()
            ]
            # 读取后删除收件箱文件
            inbox.unlink()
        # 返回消息列表
        return msgs


# 实例化消息总线对象
BUS = MessageBus()

# 获取队友 LLM 上下文,只取最新 tail 条消息,并修复 tool 链
def _teammate_llm_context(messages: list, tail: int = 20) -> list:
    # 如果消息数量大于 tail,则取最后 tail 条,否则全部取
    window = messages[-tail:] if len(messages) > tail else list(messages)
    # 修复 tool 链,防止 API 错误,返回修复后的窗口消息
    return repair_message_chain(window)



# 处理队友的收件箱消息,将协议消息(如关机批复、计划审批等)和普通消息区分开
+def _process_teammate_inbox(
+   teammate_name: str,      # 队友名称
+   inbox: list[dict],       # 收件箱消息列表
+   messages: list           # 对话消息列表
+) -> tuple[bool, list[dict]]:
    # 标记是否需要终止(收到关机请求)
+   should_stop = False
    # 用于保存非协议消息
+   non_protocol = []
    # 遍历收件箱中的每一条消息
+   for msg in inbox:
        # 获取消息类型,默认为 'message'
+       msg_type = msg.get('type', 'message')
        # 获取元数据字典,默认为空字典
+       meta = msg.get('metadata', {})
        # 获取请求 ID,默认为空字符串
+       req_id = meta.get('request_id', '')
        # 如果收到关机请求类型的协议消息
+       if msg_type == 'shutdown_request':
            # 回复 Lead,说明已同意关闭
+           BUS.send(
+               teammate_name,            # 当前队友名称
+               LEAD_NAME,                # Lead 名称
+               '正在优雅关闭。',           # 消息内容
+               'shutdown_response',      # 消息类型
+               {'request_id': req_id, 'approve': True},  # 元数据,附带请求 ID 和批准信号
+           )
            # 打印紫色的协议日志,说明已同意关闭
+           print(f'  \x1b[35m[协议] {teammate_name} 已同意关闭({req_id})\x1b[0m')
            # 标记 should_stop 为 True
+           should_stop = True
            # 跳出 for 循环,后续消息不再处理
+           break
        # 普通消息添加到 non_protocol 列表
+       non_protocol.append(msg)
    # 返回是否需要停止循环和所有未被协议处理的普通消息
+   return should_stop, non_protocol

# 空闲轮询函数,用于处理队友的空闲状态
+def idle_poll(agent_name: str, messages: list) -> str:
    # 轮询 IDLE_TIMEOUT 秒,分为若干小轮,每一轮暂停 IDLE_POLL_INTERVAL 秒
+   for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
        # 暂停 IDLE_POLL_INTERVAL 秒
+       time.sleep(IDLE_POLL_INTERVAL)

        # 读取 agent_name 的 inbox 消息
+       inbox = BUS.read_inbox(agent_name)
        # 如果 inbox 非空,说明有新消息
+       if inbox:
            # 遍历收件箱中的每一条消息
+           for msg in inbox:
                # 判断消息类型是否为关机请求
+               if msg.get('type') == 'shutdown_request':
                    # 获取该消息的 request_id,如果没有则为空字符串
+                   req_id = msg.get('metadata', {}).get('request_id', '')
                    # 回复 Lead,表示已同意关闭
+                   BUS.send(
+                       agent_name,
+                       LEAD_NAME,
+                       '正在优雅关闭。',
+                       'shutdown_response',
+                       {'request_id': req_id, 'approve': True},
+                   )
                    # 打印紫色的协议日志,表示在 idle 时同意关闭
+                   print(
+                       f'  \x1b[35m[协议] {agent_name} 在 idle 时同意关闭({req_id})\x1b[0m'
+                   )
                    # 返回 'shutdown',表示关闭
+                   return 'shutdown'

            # 将收到的 inbox 消息以 json 格式写入 messages
+           messages.append({
+               'role': 'user',
+               'content': '<inbox>' + json.dumps(inbox, ensure_ascii=False) + '</inbox>',
+           })
            # 打印收到 inbox 消息的提示
+           print(f'  \x1b[36m[idle] {agent_name} 收到 inbox 消息\x1b[0m')
            # 返回 'work',表示进入工作状态
+           return 'work'
    # 如果超时轮询结束仍未有新消息或任务,打印红色超时提示
+   print(f'  \x1b[31m[idle] {agent_name} 超时({IDLE_TIMEOUT}s)\x1b[0m')
    # 返回 'timeout',表示空闲超时
+   return 'timeout'

# 启动一个队友线程函数
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
    # 如果请求启动的名字与 Lead 名字重复,则返回错误
    if name == LEAD_NAME:
        return f"错误:不能使用保留名 '{LEAD_NAME}'"
    # 查找当前名字的队友线程是否存在
    existing = active_teammates.get(name)
    # 如果该线程已经存在并且存活,则提示已存在
    if existing and existing.is_alive():
        return f"队友 '{name}' 已存在且仍在运行"
    # 如果线程对象存在但未存活,将其从 active_teammates 移除
    if existing:
        active_teammates.pop(name, None)
        # 打印黄色日志说明旧线程被移除,可以重新启动
        print(f"  \x1b[33m[队友] {name} 旧线程已退出,允许重新启动\x1b[0m")
    # 构建 system prompt,指示 AI 队友身份及工作指令
    system = (
        f"你是 '{name}',角色为 {role}。"
        f"工作目录: {WORKDIR}。使用 Windows cmd 命令。"
+       f"检查收件箱中的协议消息(shutdown_request等)。"
    )
    # 队友线程主执行函数
    def run():
        # 延迟导入,避免与 handlers 循环依赖
        from tools.executor import execute_tool
+       from tools.schema import TOOLS
        # 绑定当前线程的 Agent 身份,防止伪造  from_agent
        identity_token = current_agent.set(name)
        # 初始化消息,prompt作为第一条user消息
        messages = [{"role": "user", "content": prompt}]
        # 用于记录退出原因
        exit_reason = ""
        # LLM 调用轮次计数
        llm_rounds = 0
         # try-finally 保证安全清理退出
        try:
            # 无限循环,直到线程被停止
            while True:
                # 若消息数量不超过 3 条,插入身份声明消息
+               if len(messages) <= 3:
+                   messages.insert(0, {
+                       'role': 'user',
+                       'content': (
+                           f"<identity>你是 '{name}',角色: {role}。"
+                           f"请继续你的工作。</identity>"
+                       ),
+                   })   
                # 初始化是否退出循环标志
+               should_shutdown = False  
                # 进入最大循环轮数限制
+               for _ in range(WORK_MAX_ROUNDS):
                    # 读取当前队友的收件箱消息
+                   inbox = BUS.read_inbox(name)    
                    # 如果收件箱有消息,则进行处理
+                   if inbox:
                        # 处理协议消息和非协议消息
+                       should_stop, non_protocol = _process_teammate_inbox(
+                           name, inbox, messages,
+                       )
                        # 若收到关闭信号,则设置标志并跳出循环
+                       if should_stop:
+                           should_shutdown = True
+                           break
                        # 如果有非协议消息,将其添加到对话消息列表
+                       if non_protocol:
+                           messages.append({
+                               'role': 'user',
+                               'content': (
+                                   f'<inbox>{json.dumps(non_protocol, ensure_ascii=False)}</inbox>'
+                               ),
+                           })  
                    # 通过 OpenAI 客户端请求 LLM 产生回复
                    try:
+                       response = client.chat.completions.create(
+                           model=MODEL_ID,
+                           messages=[
+                               {'role': 'system', 'content': system},
+                               *_teammate_llm_context(messages),
+                           ],
+                           tools=TOOLS,
+                           max_tokens=DEFAULT_MAX_TOKENS,
+                       )
                    # 捕捉 API 调用异常,记录错误与退出原因
                    except Exception as e:
+                       exit_reason = f'LLM 错误: {type(e).__name__}: {e}'
+                       print(f'  \x1b[31m[队友] {name} {exit_reason}\x1b[0m')
+                       should_shutdown = True
+                       break   
                     # 获取 assistant 角色的回复消息
+                   assistant = response.choices[0].message
                    # 将 assistant 消息加入消息列表
+                   messages.append(assistant_message_dict(assistant))

                    # 如果 assistant 没有调用任何工具,跳出当前大循环
+                   if not assistant.tool_calls:
+                       break

                    # 遍历所有工具调用,执行每一个工具
+                   for tool_call in assistant.tool_calls:
                        # 获取工具名称
+                       tname = tool_call.function.name
                        # 解析工具参数
+                       args = json.loads(tool_call.function.arguments or '{}')
                        # 执行工具(含 worktree cwd 切换)
+                       output = execute_tool(tname, args)
                        # 回复工具调用的结果消息
+                       messages.append({
+                           'role': 'tool',
+                           'tool_call_id': tool_call.id,
+                           'content': output,
+                       })
                # 如果应当退出主循环,则跳出外层 while
+               if should_shutdown:
+                   break     
                # 进入空闲轮询(自动认领时可设置 wt_ctx)
+               idle_result = idle_poll(name, messages)
                # 如果收到关闭信号,则跳出循环
+               if idle_result == 'shutdown':
+                   break
                # 如果长时间未响应,设置超时退出原因
+               if idle_result == 'timeout':
+                   exit_reason = f'idle 超时({IDLE_TIMEOUT}s)'
+                   break
            # 组织总结性回复,默认优先用 exit_reason
+           summary = exit_reason or '完成。'
            # 从最后的 assistant 消息中找一条有内容的作为总结
            for msg in reversed(messages):
+               if msg.get('role') == 'assistant' and msg.get('content'):
+                   content = msg['content']
                    if isinstance(content, str) and content.strip():
                        summary = content
                        break

            # 向 Lead 汇报最终结果消息
+           BUS.send(name, LEAD_NAME, summary, 'result')
            # 控制台输出队友结束日志
+           print(f'  \x1b[32m[队友] {name} 已结束\x1b[0m')    
        finally:
            current_agent.reset(identity_token)
            active_teammates.pop(name, None)   
    # 创建线程对象,目标为 run 函数,设置为守护线程
    thread = threading.Thread(target=run, daemon=True)
    # 将该线程注册到 active_teammates 字典
    active_teammates[name] = thread
    # 启动线程
    thread.start()
    # 启动后打印青色控制台日志
    print(f"  \x1b[36m[队友] 已启动 {name},角色 {role}\x1b[0m")
    return f"队友 '{name}' 已启动,角色 {role}"         

# 将收件箱消息格式化为文本字符串(含 type / request_id,便于 review_plan)
def format_inbox_messages(msgs: list[dict]) -> str:
    lines = []
    for m in msgs:
        msg_type = m.get("type", "message")
        # 协议消息突出 request_id
        header = f"来自 {m['from']} [{msg_type}]"
        lines.append(f"{header}: {m['content'][:200]}")
    return "[收件箱]\n" + "\n".join(lines)


# 匹配响应,根据 request_id 关联并校验响应类型
+def match_response(response_type: str, request_id: str, approve: bool) -> None:
    # 通过 request_id 获取协议状态
+   state = pending_requests.get(request_id)
    # 未找到对应协议请求
+   if not state:
+       print(f'  \x1b[31m[协议] 未知 request_id: {request_id}\x1b[0m')
+       return
    # 校验 shutdown 类型的请求响应类型是否正确
+   if state.type == 'shutdown' and response_type != 'shutdown_response':
+       print(
+           f'  \x1b[31m[协议] 类型不匹配: 期望 shutdown_response,'
+           f'实际 {response_type}\x1b[0m'
+       )
+       return
    # 校验 plan_approval 类型的请求响应类型是否正确
+   if state.type == 'plan_approval' and response_type != 'plan_approval_response':
+       print(
+           f'  \x1b[31m[协议] 类型不匹配: 期望 plan_approval_response,'
+           f'实际 {response_type}\x1b[0m'
+       )
+       return
    # 判断该请求状态是否已处理过,避免重复处理
+   if state.status != 'pending':
+       print(f'  \x1b[33m[协议] {request_id} 已是 {state.status},忽略重复响应\x1b[0m')
+       return
    # 根据approve参数设置状态为通过或拒绝
+   state.status = 'approved' if approve else 'rejected'
    # 选择显示的icon(勾或叉)
+   icon = '✓' if approve else '✗'
    # 通过或拒绝对应不同颜色
+   color = '32' if approve else '31'
    # 打印协议处理结果信息
+   print(
+       f'  \x1b[{color}m[协议] {state.type} {icon} '
+       f'({request_id}: {state.status})\x1b[0m'
+   )

# 定义函数,读取 Lead 收件箱。参数 route_protocol 表示是否需要路由协议响应。
+def consume_lead_inbox(route_protocol: bool = True) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
+   msgs = BUS.read_inbox(LEAD_NAME)
    # 如果消息列表为空,则直接返回空列表
+   if not msgs:
+       return []
    # 如果需要路由协议响应
+   if route_protocol:
        # 遍历所有消息
+       for msg in msgs:
            # 从消息中获取 metadata,默认为空字典
+           meta = msg.get('metadata', {})
            # 从 metadata 中获取 request_id,默认为空字符串
+           req_id = meta.get('request_id', '')
            # 获取消息类型
+           msg_type = msg.get('type', '')
            # 如果有 request_id 且消息类型以 "_response" 结尾
+           if req_id and msg_type.endswith('_response'):
                # 从 metadata 中获取 approve 字段,默认为 False
+               approve = meta.get('approve', False)
                # 调用 match_response 方法,路由协议响应
+               match_response(msg_type, req_id, approve)
    # 返回读取到的所有消息
+   return msgs

# 注入lead的收件箱消息到对话消息列表
def inject_lead_inbox(messages: list) -> int:
    # 调用consume_lead_inbox读取lead收件箱消息,开启路由协议
+   inbox = consume_lead_inbox(route_protocol=True)
    # 如果没有消息,返回0
    if not inbox:
        return
    # 把收件箱内容格式化为一条user消息,添加到对话消息列表
    messages.append({'role': 'user', 'content': format_inbox_messages(inbox)})
    # 控制台打印注入了多少条消息
    print(f'  \x1b[33m[收件箱] 已注入 {len(inbox)} 条消息\x1b[0m')

# 判断指定名字的队友线程是否在运行
def is_teammate_running(name: str) -> bool:
    # 从 active_teammates 字典中获取指定名字的线程对象
    thread = active_teammates.get(name)
    # 判断线程对象是否存在且线程是否存活
    return thread is not None and thread.is_alive()


# 定义函数,读取 Lead 收件箱。
def consume_inbox(agent_name: str) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(agent_name)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 返回读取到的所有消息
    return msgs

# 请求优雅关闭某队友,向其发起关机协议消息
+def run_request_shutdown(teammate: str) -> str:
    # 生成新的唯一请求 ID
+   req_id = new_request_id()
    # 在 pending_requests 字典中记录关机请求的 protocol 状态
+   pending_requests[req_id] = ProtocolState(
+       request_id=req_id,   # 请求编号
+       type='shutdown',     # 协议类型
+       sender=LEAD_NAME,    # 发起者为 Lead
+       target=teammate,     # 目标为指定队友名
+       status='pending',    # 当前状态为等待处理
+       payload='',          # 没有关联负载
+   )
    # 向队友发送关机请求消息,包括元数据中的请求 ID
+   BUS.send(LEAD_NAME, teammate, '请优雅关闭。', 'shutdown_request', {'request_id': req_id})
    # 打印带颜色的控制台日志,显示已发送关机请求
+   print(f'  \x1b[35m[协议] shutdown_request → {teammate}({req_id})\x1b[0m')
    # 正常情况下返回已发送请求的说明
+   return f'已向 {teammate} 发送关闭请求(req: {req_id})'

16.1.4. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入操作系统相关模块
import glob as g
# 导入subprocess模块,用于执行子进程
import subprocess

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output, safe_path

# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING, WORKDIR

# 从config模块导入文本编码配置
from config import TEXT_ENCODING, WORKDIR

# 从skills模块导入load_skill函数
from skills import load_skill

# 从tasks模块导入create_task函数
from tasks import create_task, list_tasks, get_task, claim_task, complete_task

# 从cron模块导入schedule_job, cancel_job, scheduled_jobs, cron_lock函数
from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock

# 导入操作系统相关模块
import glob as g

# 从 teams 模块导入 spawn_teammate_thread,BUS, LEAD_NAME, format_inbox_messages
+from teams import (spawn_teammate_thread,current_agent,BUS,LEAD_NAME,is_teammate_running,format_inbox_messages,consume_inbox,run_request_shutdown)

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str, run_in_background: bool = False) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == "nt" and command.strip().lower() == "date":
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = "date /t & time /t"
    # 定义危险命令的列表
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return "错误:危险命令已被拦截"
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,  # 要执行的命令
            shell=True,  # 在shell中执行
            cwd=os.getcwd(),  # 当前工作目录设置为当前路径
            capture_output=True,  # 捕获标准输出和标准错误
            timeout=120,  # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b"") + (r.stderr or b"")).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else "(无输出)"
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return "错误:超时(120 秒)"
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f"错误:{e}"


# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f"...(还有 {len(lines) - limit} 行)"]
        # 将行列表拼接为字符串并返回
        return "\n".join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f"已写入 {len(content)} 字节到 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f"错误:在 {path} 中未找到指定文本"
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(
            text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING
        )
        # 返回编辑成功的提示
        return f"已编辑 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return "\n".join(results) if results else "(无匹配)"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
CURRENT_TODOS: list[dict] = []


# 定义run_todo_write函数,参数为todos列表,返回字符串
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历todos列表,获取每个任务及其索引
    for i, t in enumerate(todos):
        # 如果任务中缺少content或status字段
        if "content" not in t or "status" not in t:
            # 返回错误提示,指出缺少字段的位置
            return f"错误:todos[{i}] 缺少 content 或 status"
        # 如果任务的status不是允许的三种状态
        if t["status"] not in ("pending", "in_progress", "completed"):
            # 返回错误提示,指出状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 校验全部通过后,更新全局任务列表
    CURRENT_TODOS = todos
    # 初始化显示用的lines列表,第一行为标题,并加黄颜色
    lines = ["\n\x1b[33m## 当前任务\x1b[0m"]
    # 遍历所有当前任务
    for t in CURRENT_TODOS:
        # 根据任务状态,选择不同的彩色标签
        icon = {
            "pending": "\x1b[33m等待中\x1b[0m",
            "in_progress": "\x1b[36m处理中\x1b[0m",
            "completed": "\x1b[32m已完成\x1b[0m",
        }[t["status"]]
        # 将格式化后的任务内容和标签加入lines
        lines.append(f"  [{icon}] {t['content']}")
    # 将所有内容组合成字符串打印到标准输出
    print("\n".join(lines))
    # 返回已更新任务数的字符串提示
    return f"已更新 {len(CURRENT_TODOS)} 个任务"


# 定义run_create_task函数,用于创建新任务
def run_create_task(
    # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
    subject: str,
    description: str = "",
    blockedBy: list[str] | None = None,
    # 函数返回类型为字符串
) -> str:
    # 调用create_task函数创建任务对象
    task = create_task(subject, description, blockedBy)
    # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
    # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
    # 返回已创建任务的ID、主题及依赖信息提示
    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
def run_list_tasks() -> str:
    # 调用list_tasks获取所有任务列表
    tasks = list_tasks()
    # 如果任务列表为空
    if not tasks:
        # 返回暂无任务的提示信息
        return "暂无任务。使用 create_task 添加。"
    # 初始化用于存储显示行的空列表
    lines = []
    # 遍历所有任务
    for t in tasks:
        # 根据任务状态获取对应的中文状态标签
        icon = {
            # pending状态对应“等待中”
            "pending": "等待中",
            # in_progress状态对应“处理中”
            "in_progress": "处理中",
            # completed状态对应“已完成”
            "completed": "已完成",
            # 按任务状态取值,未知状态则返回问号
        }.get(t.status, "?")
        # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
        # 若任务有负责人则格式化负责人信息,否则为空字符串
        owner = f" [{t.owner}]" if t.owner else ""
        # 将格式化后的任务信息行加入lines列表
        lines.append(f"  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}")
    # 将所有行用换行符拼接成字符串后返回
    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
def run_get_task(task_id: str) -> str:
    # 尝试获取指定ID的任务
    try:
        # 调用get_task返回任务详情
        return get_task(task_id)
    # 捕获任务文件不存在的异常
    except FileNotFoundError:
        # 返回未找到任务的错误提示
        return f"错误:未找到任务 {task_id}"


# 定义run_claim_task函数,认领指定任务,返回字符串
def run_claim_task(task_id: str) -> str:
    # 以agent为负责人认领该任务并返回结果
    return claim_task(task_id, owner="agent")


# 定义run_complete_task函数,完成指定任务,返回字符串
def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
    return complete_task(task_id)


# 定义调度定时(cron)任务的函数
def run_schedule_cron(
    cron: str,  # cron表达式
    prompt: str,  # 提示词
    recurring: bool = True,  # 是否循环
    durable: bool = True,  # 是否持久化
) -> str:  # 返回结果
    # 调用 schedule_job 安排定时任务,返回结果
    result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
    if isinstance(result, str):
        # 返回错误提示
        return f"错误:{result}"
    # 返回调度成功信息,包括 id、表达式和 prompt
    return f"已调度 {result.id}: '{cron}' → {prompt}"


# 定义列出所有 cron 定时任务的函数
def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
    with cron_lock:
        jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
    if not jobs:
        return "暂无 cron 任务。使用 schedule_cron 添加。"
    # 初始化结果字符串列表
    lines = []
    # 遍历所有定时任务
    for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
        tag = "循环" if j.recurring else "单次"
        # 根据 durable 标记区分“持久化”或“会话”
        dur = "持久化" if j.durable else "会话"
        # 拼接任务的信息字符串并加入列表
        lines.append(f"  {j.id}: '{j.cron}' → {j.prompt[:40]} [{tag}, {dur}]")
    # 返回所有任务拼接后的字符串
    return "\n".join(lines)


# 定义取消定时任务的函数
def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
    return cancel_job(job_id)

# 定义函数,启动一个队友 agent 线程
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
    # 调用 spawn_teammate_thread 启动队友 agent,传递名字、角色和 prompt
    return spawn_teammate_thread(name, role, prompt)

# 定义函数,通过消息总线发送消息给指定对象
def run_send_message(to: str, content: str) -> str:
    # 发送方固定为当前会话身份,不可伪造
    from_agent = current_agent.get()
    # 使用 BUS 发送消息
    BUS.send(from_agent, to, content)
    if to != LEAD_NAME and not is_teammate_running(to):
        # 返回已写入收件箱但队友未运行的提示
        return (
            f"已从 {from_agent} 写入 {to} 的收件箱,但该队友未在运行。"
            f"请 spawn_teammate 重启后才会被读取。"
        )
    # 返回发送结果的字符串说明
    return f"已从 {from_agent} 发送给 {to}"


# 定义函数,仅允许读取当前 Agent 自己的收件箱(Lead 只能读 lead)
def run_check_inbox() -> str:
    # 当前会话身份
    name = current_agent.get()
    # Lead 与队友都只能消费自己的收件箱,避免抢走对方消息
    msgs = consume_inbox(name)
    # 如果收件箱消息为空,返回提示信息
    if not msgs:
        return f"({name} 的收件箱为空)"
    # 如果收件箱有消息,格式化这些消息并返回
    return format_inbox_messages(msgs)
# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
    "todo_write": run_todo_write,
    "load_skill": load_skill,  # 按名称加载技能的完整内容
    "create_task": run_create_task,  # 创建新任务
    "list_tasks": run_list_tasks,  # 列出所有任务
    "get_task": run_get_task,  # 按 ID 获取任务完整详情
    "claim_task": run_claim_task,  # 认领 pending 任务,设置 owner 并改为 in_progress
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "schedule_cron": run_schedule_cron,  # 调度定时任务
    "list_crons": run_list_crons,  # 列出所有定时任务
    "cancel_cron": run_cancel_cron,  # 取消定时任务
    "spawn_teammate": run_spawn_teammate,  # 在后台线程启动队友 Agent。
    "send_message": run_send_message,  # 通过 MessageBus 向队友发送消息。
    "check_inbox": run_check_inbox,  # 仅检查当前 Agent 自己的收件箱。
+   'request_shutdown': run_request_shutdown,#请求队友优雅关闭。
}

16.1.5. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(
    name: str, description: str, properties: dict, required: list[str]
) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        "type": "function",
        # 定义函数的具体内容
        "function": {
            # 函数名称
            "name": name,
            # 函数描述
            "description": description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    _fn_tool(
        "bash",
        "执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。",
        {
            "command": {"type": "string"},
            "run_in_background": {"type": "boolean", "default": False},
        },
        ["command"],
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool(
        "read_file",
        "读取文件内容。",
        {"path": {"type": "string"}, "limit": {"type": "integer"}},
        ["path"],
    ),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool(
        "write_file",
        "将内容写入文件。",
        {"path": {"type": "string"}, "content": {"type": "string"}},
        ["path", "content"],
    ),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool(
        "edit_file",
        "在文件中精确替换一段文本(仅替换一次)。",
        {
            "path": {"type": "string"},
            "old_text": {"type": "string"},
            "new_text": {"type": "string"},
        },
        ["path", "old_text", "new_text"],
    ),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool(
        "glob", "按 glob 模式查找文件。", {"pattern": {"type": "string"}}, ["pattern"]
    ),  # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool(
+       "send_message",
+       "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
        {
+           "to": {"type": "string"},
+           "content": {"type": "string"},
        },
+       ["to", "content"],
+   ),
+   _fn_tool(
+       "check_inbox",
+       "检查自己的收件箱(队友回信)。",
+       {},
+       [],
    ),
+]
+TOOLS = [
+   *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "spawn_subagent",
        "启动子 Agent 处理复杂子任务。仅返回最终结论。",
        {"description": {"type": "string"}},
        ["description"],
    ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    ),
    _fn_tool(
        "compact", "摘要较早对话以释放上下文空间。", {"focus": {"type": "string"}}, []
    ),
    _fn_tool(
        "create_task",
        "创建新任务,可选 blockedBy 依赖。",
        {
            "subject": {"type": "string"},
            "description": {"type": "string"},
            "blockedBy": {"type": "array", "items": {"type": "string"}},
        },
        ["subject"],
    ),
    _fn_tool("list_tasks", "列出所有任务的状态、负责人与依赖。", {}, []),
    _fn_tool(
        "get_task",
        "按 ID 获取任务完整详情。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "claim_task",
        "认领 pending 任务,设置 owner 并改为 in_progress。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "complete_task",
        "完成 in_progress 任务,并报告下游解阻任务。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "schedule_cron",
        "调度 cron 任务。cron 为 5 段:分 时 日 月 周。",
        {
            "cron": {"type": "string", "description": "5 段 cron 表达式"},
            "prompt": {"type": "string", "description": "触发时注入的消息"},
            "recurring": {"type": "boolean", "description": "true=循环,false=单次"},
            "durable": {"type": "boolean", "description": "true=持久化到磁盘"},
        },
        ["cron", "prompt"],
    ),
    _fn_tool("list_crons", "列出所有已注册的 cron 任务。", {}, []),
    _fn_tool(
        "cancel_cron",
        "按 ID 取消 cron 任务。",
        {"job_id": {"type": "string"}},
        ["job_id"],
    ),
    _fn_tool(
        "spawn_teammate",
        "启动自主队友 Agent。",
        {
            "name": {"type": "string"},
            "role": {"type": "string"},
            "prompt": {"type": "string"},
        },
        ["name", "role", "prompt"],
    ),
     # 定义 request_shutdown 工具:请求队友优雅关闭
    _fn_tool(
+       'request_shutdown',
+       '请求队友优雅关闭。',
+       {'teammate': {'type': 'string'}},
+       ['teammate'],
    ),
]
TEAMMATE_TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
+   )
]

16.2 提交计划与审批 #

16.3.1. hooks.py #

hooks.py


# 从config模块导入工作目录变量
from config import WORKDIR

# 定义一个钩子字典,每个事件对应一个回调函数列表
HOOKS = {'UserPromptSubmit': [], 'PreToolUse': [], 'PostToolUse': [], 'Stop': []}

# 定义禁止执行的命令列表
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]

# 定义需要用户确认的危险命令关键字列表(增加cmd的删除命令)
DESTRUCTIVE = ['rm ', '> /etc/', 'chmod 777', 'del ', 'erase ']

# 注册钩子函数,将回调添加到对应事件的钩子列表
def register_hook(event: str, callback):
    HOOKS[event].append(callback)

# 注入当前工作目录信息到用户查询
def workspace_inject_hook(query: str) -> str | None:
    # 打印注入工作目录的钩子信息
    #print(f'\x1b[90m[HOOK] UserPromptSubmit:注入工作目录 {WORKDIR}\x1b[0m')
    # 返回带有工作目录信息的查询字符串
    return f'<workspace>\n当前工作目录:{WORKDIR}\n</workspace>\n\n{query}'

# 权限控制钩子函数,对命令执行进行校验
def permission_hook(name: str, args: dict):
    # 如果工具类型是bash命令
    if name == 'bash':
        # 检查是否包含禁止列表中的命令
        for pattern in DENY_LIST:
            if pattern in args.get('command', ''):
                # 打印拦截信息
                print(f"\n\x1b[31m⛔ 已拦截:'{pattern}'\x1b[0m")
                # 返回拒绝权限的提示
                return '禁止列表拒绝权限'
        # 检查是否包含破坏性关键字
        for kw in DESTRUCTIVE:
            if kw in args.get('command', ''):
                # 打印警告信息
                print(f'\n\x1b[33m⚠  可能破坏性的命令\x1b[0m')
                print(f'   工具: {name}({args})')
                # 询问用户是否允许
                choice = input('   允许?[y/N] ').strip().lower()
                # 如果用户未确认,拒绝操作
                if choice not in ('y', 'yes'):
                    return '用户拒绝权限'
    # 如果是写文件或编辑文件操作
    if name in ('write_file', 'edit_file'):
        # 获取目标路径
        path = args.get('path', '')
        # 校验路径是否在工作目录下
        if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
            # 警告工作区外写入
            print(f'\n\x1b[33m⚠  在工作区外写入\x1b[0m')
            print(f'   工具: {name}({args})')
            # 询问用户是否允许
            choice = input('   允许?[y/N] ').strip().lower()
            # 如果用户未确认,拒绝操作
            if choice not in ('y', 'yes'):
                return '用户拒绝权限'
    # 返回None表示通过检查
    return None  

# 日志钩子函数,记录调用信息
def log_hook(name: str, args: dict):
    # 取参数前两项并转换为字符串用于预览
    args_preview = str(list(args.values())[:2])[:60]
    # 打印钩子触发信息
    #print(f'\x1b[90m[HOOK] {name}({args_preview})\x1b[0m')
    # 无特殊行为,直接返回None
    return None 

# 钩子,处理工具输出过大的情况
def large_output_hook(name: str, args: dict, output):
    # 判断输出长度是否超过10万字符
    if len(str(output)) > 1000000:
        # 打印输出过大警告
        print(f'\x1b[33m[HOOK] ⚠ {name} 输出过大:{len(str(output))} 字符\x1b[0m')
    # 返回None
    return None  

# 会话统计钩子函数
def summary_hook(messages: list):
    # 统计工具调用的次数
    tool_count = sum(1 for m in messages if m.get('role') == 'tool')
    # 打印工具调用次数信息
    print(f'\x1b[90m[HOOK] Stop:本次会话共使用 {tool_count} 次工具调用\x1b[0m')
    # 无特殊返回,直接None
    return None       

# 注册“用户提交”事件的钩子
register_hook('UserPromptSubmit', workspace_inject_hook)
# 注册“工具使用前”权限检查钩子
register_hook('PreToolUse', permission_hook)
# 注册“工具使用前”日志记录钩子
register_hook('PreToolUse', log_hook)
# 注册“工具使用后”大输出检测钩子
register_hook('PostToolUse', large_output_hook)
# 注册停止事件的会话总结钩子
register_hook('Stop', summary_hook)

# 触发用户输入相关的钩子链
def trigger_user_prompt_hooks(query: str) -> str:
    # 当前待处理的查询
    current = query
    # 依次触发钩子
    for callback in HOOKS['UserPromptSubmit']:
        # 调用每个钩子获取结果
        result = callback(current)
        # 如果返回字符串则更新current
        if isinstance(result, str):
            current = result
    # 返回处理后的查询
    return current

# 通用钩子触发函数
def trigger_hooks(event: str, *args):
    # 按注册顺序依次触发对应事件下的钩子
    for callback in HOOKS[event]:
        # 调用钩子并获取返回值
        result = callback(*args)
        # 如果返回非None则终止并返回
        if result is not None:
            return result
    # 所有钩子都返回None则返回None
    return None

16.3.2. teams.py #

teams.py


# 导入json模块,用于处理JSON数据
import json
# 导入threading模块,用于多线程
import threading
# 导入time模块,用于时间处理
import time
# 当前 Agent 身份(Lead 主线程默认 lead;队友线程启动时设为队友名)
from contextvars import ContextVar
# 导入dataclass模块,用于定义数据类
from dataclasses import dataclass, field
# 从config模块导入常量和对象
from config import (
    WORKDIR,  # 工作目录
    client,  # 大语言模型客户端
    MODEL_ID, # 主模型名称
    DEFAULT_MAX_TOKENS,# 默认最大token数
    MAILBOX_DIR,   # 邮箱目录
    TEXT_ENCODING # 文本编码方式
)
# 从tools.schema模块导入队友工具列表
from tools.schema import TEAMMATE_TOOLS
# 从history模块导入repair_message_chain函数
from history import repair_message_chain
# 从utils模块导入assistant_message_dict方法
from utils import assistant_message_dict
# 定义主管(lead)的名字
LEAD_NAME = "lead"
# 导入random模块,用于生成 request_id
import random
# 队友 LLM 调用最大轮次(防止无限循环)
TEAMMATE_MAX_ROUNDS = 50
# 空闲超时时间(单位:秒)
IDLE_TIMEOUT = 60
# 空闲轮询时间间隔(单位:秒)
IDLE_POLL_INTERVAL = 5
# 当前调用工具的 Agent 名称
current_agent: ContextVar[str] = ContextVar("current_agent", default="lead")
# active_teammates: 队友名 → 线程对象
active_teammates: dict[str, threading.Thread] = {}
# agent 最大工作回合数
WORK_MAX_ROUNDS = 10
# MessageBus 文件读写锁
_bus_lock = threading.Lock()
# 使用dataclass装饰器定义一个协议状态的数据结构
@dataclass
class ProtocolState:
    # 请求的唯一标识符
    request_id: str
    # 协议类型,可能为 shutdown 或 plan_approval
    type: str       # shutdown | plan_approval
    # 请求发送者
    sender: str
    # 请求目标对象
    target: str
    # 状态,可能为 pending、approved 或 rejected
    status: str     # pending | approved | rejected
    # 附加数据/信息
    payload: str
    # 创建时间,默认为当前时间
    created_at: float = field(default_factory=time.time)

# 用于存储所有挂起的协议请求,键为请求ID,值为协议状态对象
pending_requests: dict[str, ProtocolState] = {}

# 生成新的唯一请求ID
def new_request_id() -> str:
    # 随机生成6位数字,格式化为 req_xxxxxx 的字符串
    return f'req_{random.randint(0, 999999):06d}'
# 消息总线类,用于管理不同agent间消息传递
class MessageBus:
    """基于文件的消息总线。每个 Agent 一个 .jsonl 收件箱,读取即消费。"""
    # 发送消息的方法
    def send(
        self,
        from_agent: str,# 发送者
        to_agent: str,# 接收者
        content: str,# 消息内容
        msg_type: str = "message",# 消息类型
        metadata: dict | None = None, # 附加元数据,默认为 None
    ):
        # 构造消息内容的字典
        msg = {
            "from": from_agent,  # 发送者
            "to": to_agent,  # 接收者
            "content": content,  # 消息内容
            "type": msg_type,  # 消息类型
            "ts": time.time(),  # 时间戳
            'metadata': metadata or {},            # 元数据,默认为空字典
        }
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
        with _bus_lock:
            # 以追加模式写入收件箱
            with open(inbox, "a", encoding=TEXT_ENCODING) as f:
                # 将消息写为json字符串,每条一行
                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
        # 控制台打印消息发送信息
        print(
            f"  \x1b[33m[总线] {from_agent} → {to_agent}[{msg_type}]: {content[:50]}\x1b[0m"
        )

    # 读取某agent收件箱的方法(与 send 共用锁,避免读写竞态丢信)
    def read_inbox(self, agent: str) -> list[dict]:
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{agent}.jsonl"
        with _bus_lock:
            # 如果收件箱文件不存在,则返回空列表
            if not inbox.exists():
                return []
            # 读取所有消息,每行解析为json字典
            msgs = [
                json.loads(line)
                for line in inbox.read_text(encoding=TEXT_ENCODING).splitlines()
                if line.strip()
            ]
            # 读取后删除收件箱文件
            inbox.unlink()
        # 返回消息列表
        return msgs


# 实例化消息总线对象
BUS = MessageBus()

# 获取队友 LLM 上下文,只取最新 tail 条消息,并修复 tool 链
def _teammate_llm_context(messages: list, tail: int = 20) -> list:
    # 如果消息数量大于 tail,则取最后 tail 条,否则全部取
    window = messages[-tail:] if len(messages) > tail else list(messages)
    # 修复 tool 链,防止 API 错误,返回修复后的窗口消息
    return repair_message_chain(window)



# 处理队友的收件箱消息,将协议消息(如关机批复、计划审批等)和普通消息区分开
def _process_teammate_inbox(
    teammate_name: str,      # 队友名称
    inbox: list[dict],       # 收件箱消息列表
    messages: list           # 对话消息列表
) -> tuple[bool, list[dict]]:
    # 标记是否需要终止(收到关机请求)
    should_stop = False
    # 用于保存非协议消息
    non_protocol = []
    # 遍历收件箱中的每一条消息
    for msg in inbox:
        # 获取消息类型,默认为 'message'
        msg_type = msg.get('type', 'message')
        # 获取元数据字典,默认为空字典
        meta = msg.get('metadata', {})
        # 获取请求 ID,默认为空字符串
        req_id = meta.get('request_id', '')
        # 如果收到关机请求类型的协议消息
        if msg_type == 'shutdown_request':
            # 回复 Lead,说明已同意关闭
            BUS.send(
                teammate_name,            # 当前队友名称
                LEAD_NAME,                # Lead 名称
                '正在优雅关闭。',           # 消息内容
                'shutdown_response',      # 消息类型
                {'request_id': req_id, 'approve': True},  # 元数据,附带请求 ID 和批准信号
            )
            # 打印紫色的协议日志,说明已同意关闭
            print(f'  \x1b[35m[协议] {teammate_name} 已同意关闭({req_id})\x1b[0m')
            # 标记 should_stop 为 True
            should_stop = True
            # 跳出 for 循环,后续消息不再处理
            break
        # 如果收到计划审批响应
+       if msg_type == 'plan_approval_response':
            # 获取是否批准
+           approve = meta.get('approve', False)
            # 如果批准
+           if approve:
                # 向对话消息列表添加“计划已批准”的提示消息
+               messages.append({
+                   'role': 'user',
+                   'content': '[计划已批准] 请继续执行任务。',
+               })
+           else:
                # 否则添加“计划被拒绝”与反馈内容
+               messages.append({
+                   'role': 'user',
+                   'content': f"[计划被拒绝] 反馈: {msg['content']}",
+               })
            # 忽略后续代码,继续处理下条收件箱消息
+           continue
        # 普通消息添加到 non_protocol 列表
        non_protocol.append(msg)
    # 返回是否需要停止循环和所有未被协议处理的普通消息
    return should_stop, non_protocol

# 空闲轮询函数,用于处理队友的空闲状态
def idle_poll(agent_name: str, messages: list) -> str:
    # 轮询 IDLE_TIMEOUT 秒,分为若干小轮,每一轮暂停 IDLE_POLL_INTERVAL 秒
    for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
        # 暂停 IDLE_POLL_INTERVAL 秒
        time.sleep(IDLE_POLL_INTERVAL)

        # 读取 agent_name 的 inbox 消息
        inbox = BUS.read_inbox(agent_name)
        # 如果 inbox 非空,说明有新消息
        if inbox:
            # 遍历收件箱中的每一条消息
            for msg in inbox:
                # 判断消息类型是否为关机请求
                if msg.get('type') == 'shutdown_request':
                    # 获取该消息的 request_id,如果没有则为空字符串
                    req_id = msg.get('metadata', {}).get('request_id', '')
                    # 回复 Lead,表示已同意关闭
                    BUS.send(
                        agent_name,
                        LEAD_NAME,
                        '正在优雅关闭。',
                        'shutdown_response',
                        {'request_id': req_id, 'approve': True},
                    )
                    # 打印紫色的协议日志,表示在 idle 时同意关闭
                    print(
                        f'  \x1b[35m[协议] {agent_name} 在 idle 时同意关闭({req_id})\x1b[0m'
                    )
                    # 返回 'shutdown',表示关闭
                    return 'shutdown'

            # 将收到的 inbox 消息以 json 格式写入 messages
            messages.append({
                'role': 'user',
                'content': '<inbox>' + json.dumps(inbox, ensure_ascii=False) + '</inbox>',
            })
            # 打印收到 inbox 消息的提示
            print(f'  \x1b[36m[idle] {agent_name} 收到 inbox 消息\x1b[0m')
            # 返回 'work',表示进入工作状态
            return 'work'
    # 如果超时轮询结束仍未有新消息或任务,打印红色超时提示
    print(f'  \x1b[31m[idle] {agent_name} 超时({IDLE_TIMEOUT}s)\x1b[0m')
    # 返回 'timeout',表示空闲超时
    return 'timeout'

# 启动一个队友线程函数
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
    # 如果请求启动的名字与 Lead 名字重复,则返回错误
    if name == LEAD_NAME:
        return f"错误:不能使用保留名 '{LEAD_NAME}'"
    # 查找当前名字的队友线程是否存在
    existing = active_teammates.get(name)
    # 如果该线程已经存在并且存活,则提示已存在
    if existing and existing.is_alive():
        return f"队友 '{name}' 已存在且仍在运行"
    # 如果线程对象存在但未存活,将其从 active_teammates 移除
    if existing:
        active_teammates.pop(name, None)
        # 打印黄色日志说明旧线程被移除,可以重新启动
        print(f"  \x1b[33m[队友] {name} 旧线程已退出,允许重新启动\x1b[0m")
    # 构建 system prompt,指示 AI 队友身份及工作指令
    system = (
        f"你是 '{name}',角色为 {role}。"
        f"工作目录: {WORKDIR}。使用 Windows cmd 命令。"
+       f"检查收件箱中的协议消息(shutdown_request、plan_approval_response等)。"
+       f"需要 Lead 审批时,使用 submit_plan 提交计划。"
    )
    # 队友线程主执行函数
    def run():
        # 延迟导入,避免与 handlers 循环依赖
        from tools.executor import execute_tool
        from tools.schema import TOOLS
        # 绑定当前线程的 Agent 身份,防止伪造  from_agent
        identity_token = current_agent.set(name)
        # 初始化消息,prompt作为第一条user消息
        messages = [{"role": "user", "content": prompt}]
        # 用于记录退出原因
        exit_reason = ""
        # LLM 调用轮次计数
        llm_rounds = 0
         # try-finally 保证安全清理退出
        try:
            # 无限循环,直到线程被停止
            while True:
                # 若消息数量不超过 3 条,插入身份声明消息
                if len(messages) <= 3:
                    messages.insert(0, {
                        'role': 'user',
                        'content': (
                            f"<identity>你是 '{name}',角色: {role}。"
                            f"请继续你的工作。</identity>"
                        ),
                    })   
                # 初始化是否退出循环标志
                should_shutdown = False  
                # 进入最大循环轮数限制
                for _ in range(WORK_MAX_ROUNDS):
                    # 读取当前队友的收件箱消息
                    inbox = BUS.read_inbox(name)    
                    # 如果收件箱有消息,则进行处理
                    if inbox:
                        # 处理协议消息和非协议消息
                        should_stop, non_protocol = _process_teammate_inbox(
                            name, inbox, messages,
                        )
                        # 若收到关闭信号,则设置标志并跳出循环
                        if should_stop:
                            should_shutdown = True
                            break
                        # 如果有非协议消息,将其添加到对话消息列表
                        if non_protocol:
                            messages.append({
                                'role': 'user',
                                'content': (
                                    f'<inbox>{json.dumps(non_protocol, ensure_ascii=False)}</inbox>'
                                ),
                            })  
                    # 通过 OpenAI 客户端请求 LLM 产生回复
                    try:
                        response = client.chat.completions.create(
                            model=MODEL_ID,
                            messages=[
                                {'role': 'system', 'content': system},
                                *_teammate_llm_context(messages),
                            ],
                            tools=TOOLS,
                            max_tokens=DEFAULT_MAX_TOKENS,
                        )
                    # 捕捉 API 调用异常,记录错误与退出原因
                    except Exception as e:
                        exit_reason = f'LLM 错误: {type(e).__name__}: {e}'
                        print(f'  \x1b[31m[队友] {name} {exit_reason}\x1b[0m')
                        should_shutdown = True
                        break   
                     # 获取 assistant 角色的回复消息
                    assistant = response.choices[0].message
                    # 将 assistant 消息加入消息列表
                    messages.append(assistant_message_dict(assistant))

                    # 如果 assistant 没有调用任何工具,跳出当前大循环
                    if not assistant.tool_calls:
                        break

                    # 遍历所有工具调用,执行每一个工具
                    for tool_call in assistant.tool_calls:
                        # 获取工具名称
                        tname = tool_call.function.name
                        # 解析工具参数
                        args = json.loads(tool_call.function.arguments or '{}')
                        # 执行工具(含 worktree cwd 切换)
                        output = execute_tool(tname, args)
                        # 回复工具调用的结果消息
                        messages.append({
                            'role': 'tool',
                            'tool_call_id': tool_call.id,
                            'content': output,
                        })
                # 如果应当退出主循环,则跳出外层 while
                if should_shutdown:
                    break     
                # 进入空闲轮询(自动认领时可设置 wt_ctx)
                idle_result = idle_poll(name, messages)
                # 如果收到关闭信号,则跳出循环
                if idle_result == 'shutdown':
                    break
                # 如果长时间未响应,设置超时退出原因
                if idle_result == 'timeout':
                    exit_reason = f'idle 超时({IDLE_TIMEOUT}s)'
                    break
            # 组织总结性回复,默认优先用 exit_reason
            summary = exit_reason or '完成。'
            # 从最后的 assistant 消息中找一条有内容的作为总结
            for msg in reversed(messages):
                if msg.get('role') == 'assistant' and msg.get('content'):
                    content = msg['content']
                    if isinstance(content, str) and content.strip():
                        summary = content
                        break

            # 向 Lead 汇报最终结果消息
            BUS.send(name, LEAD_NAME, summary, 'result')
            # 控制台输出队友结束日志
            print(f'  \x1b[32m[队友] {name} 已结束\x1b[0m')    
        finally:
            current_agent.reset(identity_token)
            active_teammates.pop(name, None)   
    # 创建线程对象,目标为 run 函数,设置为守护线程
    thread = threading.Thread(target=run, daemon=True)
    # 将该线程注册到 active_teammates 字典
    active_teammates[name] = thread
    # 启动线程
    thread.start()
    # 启动后打印青色控制台日志
    print(f"  \x1b[36m[队友] 已启动 {name},角色 {role}\x1b[0m")
    return f"队友 '{name}' 已启动,角色 {role}"         

# 将收件箱消息格式化为文本字符串(含 type / request_id,便于 review_plan)
def format_inbox_messages(msgs: list[dict]) -> str:
    lines = []
    for m in msgs:
         # 从消息字典中获取 'metadata' 字段,没有则默认为空字典
+       meta = m.get('metadata', {})
        # 从 metadata 字典中获取 'request_id' 字段,没有则默认为空字符串
+       req_id = meta.get('request_id', '')
        # 如果request_id存在,则格式为“[类型 req:request_id]”,否则为“[类型]”
+       tag = f" [{m.get('type', 'message')} req:{req_id}]" if req_id else f" [{m.get('type', 'message')}]"
        # 生成包含来源、标签和内容(截断到前200个字符)的字符串并加入lines列表
+       lines.append(f"来自 {m['from']}{tag}: {m['content'][:200]}")
    # 将所有格式化好的消息行用换行拼接,最前面加上“[收件箱]”标题,作为最终返回的字符串
    return "[收件箱]\n" + "\n".join(lines)


# 匹配响应,根据 request_id 关联并校验响应类型
def match_response(response_type: str, request_id: str, approve: bool) -> None:
    # 通过 request_id 获取协议状态
    state = pending_requests.get(request_id)
    # 未找到对应协议请求
    if not state:
        print(f'  \x1b[31m[协议] 未知 request_id: {request_id}\x1b[0m')
        return
    # 校验 shutdown 类型的请求响应类型是否正确
    if state.type == 'shutdown' and response_type != 'shutdown_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 shutdown_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 校验 plan_approval 类型的请求响应类型是否正确
    if state.type == 'plan_approval' and response_type != 'plan_approval_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 plan_approval_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 判断该请求状态是否已处理过,避免重复处理
    if state.status != 'pending':
        print(f'  \x1b[33m[协议] {request_id} 已是 {state.status},忽略重复响应\x1b[0m')
        return
    # 根据approve参数设置状态为通过或拒绝
    state.status = 'approved' if approve else 'rejected'
    # 选择显示的icon(勾或叉)
    icon = '✓' if approve else '✗'
    # 通过或拒绝对应不同颜色
    color = '32' if approve else '31'
    # 打印协议处理结果信息
    print(
        f'  \x1b[{color}m[协议] {state.type} {icon} '
        f'({request_id}: {state.status})\x1b[0m'
    )

# 定义函数,读取 Lead 收件箱。参数 route_protocol 表示是否需要路由协议响应。
def consume_lead_inbox(route_protocol: bool = True) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(LEAD_NAME)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 如果需要路由协议响应
    if route_protocol:
        # 遍历所有消息
        for msg in msgs:
            # 从消息中获取 metadata,默认为空字典
            meta = msg.get('metadata', {})
            # 从 metadata 中获取 request_id,默认为空字符串
            req_id = meta.get('request_id', '')
            # 获取消息类型
            msg_type = msg.get('type', '')
            # 如果有 request_id 且消息类型以 "_response" 结尾
            if req_id and msg_type.endswith('_response'):
                # 从 metadata 中获取 approve 字段,默认为 False
                approve = meta.get('approve', False)
                # 调用 match_response 方法,路由协议响应
                match_response(msg_type, req_id, approve)
    # 返回读取到的所有消息
    return msgs
    s
# 注入lead的收件箱消息到对话消息列表
def inject_lead_inbox(messages: list) -> int:
    # 调用consume_lead_inbox读取lead收件箱消息,开启路由协议
    inbox = consume_lead_inbox(route_protocol=True)
    # 如果没有消息,返回0
    if not inbox:
        return
    # 把收件箱内容格式化为一条user消息,添加到对话消息列表
    messages.append({'role': 'user', 'content': format_inbox_messages(inbox)})
    # 控制台打印注入了多少条消息
    print(f'  \x1b[33m[收件箱] 已注入 {len(inbox)} 条消息\x1b[0m')

# 判断指定名字的队友线程是否在运行
def is_teammate_running(name: str) -> bool:
    # 从 active_teammates 字典中获取指定名字的线程对象
    thread = active_teammates.get(name)
    # 判断线程对象是否存在且线程是否存活
    return thread is not None and thread.is_alive()


# 定义函数,读取 Lead 收件箱。
def consume_inbox(agent_name: str) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(agent_name)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 返回读取到的所有消息
    return msgs

# 请求优雅关闭某队友,向其发起关机协议消息
def run_request_shutdown(teammate: str) -> str:
    # 生成新的唯一请求 ID
    req_id = new_request_id()
    # 在 pending_requests 字典中记录关机请求的 protocol 状态
    pending_requests[req_id] = ProtocolState(
        request_id=req_id,   # 请求编号
        type='shutdown',     # 协议类型
        sender=LEAD_NAME,    # 发起者为 Lead
        target=teammate,     # 目标为指定队友名
        status='pending',    # 当前状态为等待处理
        payload='',          # 没有关联负载
    )
    # 向队友发送关机请求消息,包括元数据中的请求 ID
    BUS.send(LEAD_NAME, teammate, '请优雅关闭。', 'shutdown_request', {'request_id': req_id})
    # 打印带颜色的控制台日志,显示已发送关机请求
    print(f'  \x1b[35m[协议] shutdown_request → {teammate}({req_id})\x1b[0m')
    # 正常情况下返回已发送请求的说明
    return f'已向 {teammate} 发送关闭请求(req: {req_id})'

# 要求队友提交计划,即发消息让队友编写任务计划
+def run_request_plan(teammate: str, task: str) -> str:
    # 向队友收件箱发送请求,要求其提交计划,消息类型为普通 message
+   BUS.send(LEAD_NAME, teammate, f'请提交计划: {task}', 'message')
    # 否则返回已成功请求队友提交计划
+   return f'已要求 {teammate} 提交计划'


# 队友向 Lead 提交计划以待审批
+def run_submit_plan(from_name: str, plan: str) -> str:
    # 函数说明文档
+   """队友向 Lead 提交计划待审批。"""
    # 生成计划审批请求的新 request_id
+   req_id = new_request_id()
    # 在 pending_requests 保存本次请求的状态对象
+   pending_requests[req_id] = ProtocolState(
+       request_id=req_id,      # 当前 request_id
+       type='plan_approval',   # 协议类型为“计划审批”
+       sender=from_name,       # 谁发的
+       target=LEAD_NAME,       # 发给 Lead
+       status='pending',       # 当前状态为等待审批
+       payload=plan,           # 计划内容
+   )
    # 通过 BUS 发送计划审批请求协议消息,content 是计划内容
+   BUS.send(from_name, LEAD_NAME, plan, 'plan_approval_request', {'request_id': req_id})
    # 返回提示文本,包含 request_id
+   return f'计划已提交({req_id})。等待审批...'


# Lead 对队友提交的计划进行审批(批准或拒绝),并进行响应
+def run_review_plan(request_id: str, approve: bool, feedback: str = '') -> str:
    # 从 pending_requests 字典中查找请求状态对象
+   state = pending_requests.get(request_id)
    # 如果找不到该请求,返回提示
+   if not state:
+       return f'未找到请求 {request_id}'
    # 如果该请求已经不在待处理状态,说明已操作过,返回对应状态
+   if state.status != 'pending':
+       return f'请求 {request_id} 已是 {state.status}'
    # 根据 approve 设定当前请求的最终状态
+   state.status = 'approved' if approve else 'rejected'
    # 向队友发回计划审批协议响应,带上审批反馈和结果
+   BUS.send(
+       LEAD_NAME,#发送者
+       state.sender,#接收者
+       feedback or ('已批准' if approve else '已拒绝'),#消息内容
+       'plan_approval_response',#消息类型
+       {'request_id': request_id, 'approve': approve},#元数据
+   )
    # 设定审批通过或者拒绝的标志字符
+   icon = '✓' if approve else '✗'
    # 控制台输出审批过程日志,带颜色
+   print(f'  \x1b[32m[协议] 计划 {icon}({request_id})\x1b[0m')
    # 返回描述审批结果的字符串
+   return f"计划{'已批准' if approve else '已拒绝'}({request_id})"

16.3.3. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os

# 导入subprocess模块,用于执行子进程
import subprocess

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output, safe_path

# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING, WORKDIR

# 从config模块导入文本编码配置
from config import TEXT_ENCODING, WORKDIR

# 从skills模块导入load_skill函数
from skills import load_skill

# 从tasks模块导入create_task函数
from tasks import create_task, list_tasks, get_task, claim_task, complete_task

# 从cron模块导入schedule_job, cancel_job, scheduled_jobs, cron_lock函数
from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock

# 导入操作系统相关模块
import glob as g

# 从 teams 模块导入 spawn_teammate_thread,BUS, LEAD_NAME, format_inbox_messages
+from teams import (spawn_teammate_thread,current_agent,BUS,LEAD_NAME,is_teammate_running,format_inbox_messages,consume_inbox,
+run_request_shutdown,
+run_request_plan,#要求队友提交计划供审核。
+run_submit_plan,#向 Lead 提交计划待审批。
+run_review_plan,#按 request_id 批准或拒绝已提交的计划。
+)

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str, run_in_background: bool = False) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == "nt" and command.strip().lower() == "date":
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = "date /t & time /t"
    # 定义危险命令的列表
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return "错误:危险命令已被拦截"
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,  # 要执行的命令
            shell=True,  # 在shell中执行
            cwd=os.getcwd(),  # 当前工作目录设置为当前路径
            capture_output=True,  # 捕获标准输出和标准错误
            timeout=120,  # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b"") + (r.stderr or b"")).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else "(无输出)"
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return "错误:超时(120 秒)"
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f"错误:{e}"


# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f"...(还有 {len(lines) - limit} 行)"]
        # 将行列表拼接为字符串并返回
        return "\n".join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f"已写入 {len(content)} 字节到 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f"错误:在 {path} 中未找到指定文本"
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(
            text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING
        )
        # 返回编辑成功的提示
        return f"已编辑 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return "\n".join(results) if results else "(无匹配)"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
CURRENT_TODOS: list[dict] = []


# 定义run_todo_write函数,参数为todos列表,返回字符串
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历todos列表,获取每个任务及其索引
    for i, t in enumerate(todos):
        # 如果任务中缺少content或status字段
        if "content" not in t or "status" not in t:
            # 返回错误提示,指出缺少字段的位置
            return f"错误:todos[{i}] 缺少 content 或 status"
        # 如果任务的status不是允许的三种状态
        if t["status"] not in ("pending", "in_progress", "completed"):
            # 返回错误提示,指出状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 校验全部通过后,更新全局任务列表
    CURRENT_TODOS = todos
    # 初始化显示用的lines列表,第一行为标题,并加黄颜色
    lines = ["\n\x1b[33m## 当前任务\x1b[0m"]
    # 遍历所有当前任务
    for t in CURRENT_TODOS:
        # 根据任务状态,选择不同的彩色标签
        icon = {
            "pending": "\x1b[33m等待中\x1b[0m",
            "in_progress": "\x1b[36m处理中\x1b[0m",
            "completed": "\x1b[32m已完成\x1b[0m",
        }[t["status"]]
        # 将格式化后的任务内容和标签加入lines
        lines.append(f"  [{icon}] {t['content']}")
    # 将所有内容组合成字符串打印到标准输出
    print("\n".join(lines))
    # 返回已更新任务数的字符串提示
    return f"已更新 {len(CURRENT_TODOS)} 个任务"


# 定义run_create_task函数,用于创建新任务
def run_create_task(
    # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
    subject: str,
    description: str = "",
    blockedBy: list[str] | None = None,
    # 函数返回类型为字符串
) -> str:
    # 调用create_task函数创建任务对象
    task = create_task(subject, description, blockedBy)
    # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
    # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
    # 返回已创建任务的ID、主题及依赖信息提示
    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
def run_list_tasks() -> str:
    # 调用list_tasks获取所有任务列表
    tasks = list_tasks()
    # 如果任务列表为空
    if not tasks:
        # 返回暂无任务的提示信息
        return "暂无任务。使用 create_task 添加。"
    # 初始化用于存储显示行的空列表
    lines = []
    # 遍历所有任务
    for t in tasks:
        # 根据任务状态获取对应的中文状态标签
        icon = {
            # pending状态对应“等待中”
            "pending": "等待中",
            # in_progress状态对应“处理中”
            "in_progress": "处理中",
            # completed状态对应“已完成”
            "completed": "已完成",
            # 按任务状态取值,未知状态则返回问号
        }.get(t.status, "?")
        # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
        # 若任务有负责人则格式化负责人信息,否则为空字符串
        owner = f" [{t.owner}]" if t.owner else ""
        # 将格式化后的任务信息行加入lines列表
        lines.append(f"  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}")
    # 将所有行用换行符拼接成字符串后返回
    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
def run_get_task(task_id: str) -> str:
    # 尝试获取指定ID的任务
    try:
        # 调用get_task返回任务详情
        return get_task(task_id)
    # 捕获任务文件不存在的异常
    except FileNotFoundError:
        # 返回未找到任务的错误提示
        return f"错误:未找到任务 {task_id}"


# 定义run_claim_task函数,认领指定任务,返回字符串
def run_claim_task(task_id: str) -> str:
    # 以agent为负责人认领该任务并返回结果
    return claim_task(task_id, owner="agent")


# 定义run_complete_task函数,完成指定任务,返回字符串
def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
    return complete_task(task_id)


# 定义调度定时(cron)任务的函数
def run_schedule_cron(
    cron: str,  # cron表达式
    prompt: str,  # 提示词
    recurring: bool = True,  # 是否循环
    durable: bool = True,  # 是否持久化
) -> str:  # 返回结果
    # 调用 schedule_job 安排定时任务,返回结果
    result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
    if isinstance(result, str):
        # 返回错误提示
        return f"错误:{result}"
    # 返回调度成功信息,包括 id、表达式和 prompt
    return f"已调度 {result.id}: '{cron}' → {prompt}"


# 定义列出所有 cron 定时任务的函数
def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
    with cron_lock:
        jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
    if not jobs:
        return "暂无 cron 任务。使用 schedule_cron 添加。"
    # 初始化结果字符串列表
    lines = []
    # 遍历所有定时任务
    for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
        tag = "循环" if j.recurring else "单次"
        # 根据 durable 标记区分“持久化”或“会话”
        dur = "持久化" if j.durable else "会话"
        # 拼接任务的信息字符串并加入列表
        lines.append(f"  {j.id}: '{j.cron}' → {j.prompt[:40]} [{tag}, {dur}]")
    # 返回所有任务拼接后的字符串
    return "\n".join(lines)


# 定义取消定时任务的函数
def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
    return cancel_job(job_id)

# 定义函数,启动一个队友 agent 线程
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
    # 调用 spawn_teammate_thread 启动队友 agent,传递名字、角色和 prompt
    return spawn_teammate_thread(name, role, prompt)

# 定义函数,通过消息总线发送消息给指定对象
def run_send_message(to: str, content: str) -> str:
    # 发送方固定为当前会话身份,不可伪造
    from_agent = current_agent.get()
    # 使用 BUS 发送消息
    BUS.send(from_agent, to, content)
    if to != LEAD_NAME and not is_teammate_running(to):
        # 返回已写入收件箱但队友未运行的提示
        return (
            f"已从 {from_agent} 写入 {to} 的收件箱,但该队友未在运行。"
            f"请 spawn_teammate 重启后才会被读取。"
        )
    # 返回发送结果的字符串说明
    return f"已从 {from_agent} 发送给 {to}"


# 定义函数,仅允许读取当前 Agent 自己的收件箱(Lead 只能读 lead)
def run_check_inbox() -> str:
    # 当前会话身份
    name = current_agent.get()
    # Lead 与队友都只能消费自己的收件箱,避免抢走对方消息
    msgs = consume_inbox(name)
    # 如果收件箱消息为空,返回提示信息
    if not msgs:
        return f"({name} 的收件箱为空)"
    # 如果收件箱有消息,格式化这些消息并返回
    return format_inbox_messages(msgs)
# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
    "todo_write": run_todo_write,
    "load_skill": load_skill,  # 按名称加载技能的完整内容
    "create_task": run_create_task,  # 创建新任务
    "list_tasks": run_list_tasks,  # 列出所有任务
    "get_task": run_get_task,  # 按 ID 获取任务完整详情
    "claim_task": run_claim_task,  # 认领 pending 任务,设置 owner 并改为 in_progress
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "schedule_cron": run_schedule_cron,  # 调度定时任务
    "list_crons": run_list_crons,  # 列出所有定时任务
    "cancel_cron": run_cancel_cron,  # 取消定时任务
    "spawn_teammate": run_spawn_teammate,  # 在后台线程启动队友 Agent。
    "send_message": run_send_message,  # 通过 MessageBus 向队友发送消息。
    "check_inbox": run_check_inbox,  # 仅检查当前 Agent 自己的收件箱。
    'request_shutdown': run_request_shutdown,#请求队友优雅关闭。
+   'request_plan': run_request_plan,#要求队友提交计划供审核。
+   'submit_plan': run_submit_plan,#向 Lead 提交计划待审批。
+   'review_plan': run_review_plan,#按 request_id 批准或拒绝已提交的计划。
}

16.3.4. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(
    name: str, description: str, properties: dict, required: list[str]
) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        "type": "function",
        # 定义函数的具体内容
        "function": {
            # 函数名称
            "name": name,
            # 函数描述
            "description": description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    _fn_tool(
        "bash",
        "执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。",
        {
            "command": {"type": "string"},
            "run_in_background": {"type": "boolean", "default": False},
        },
        ["command"],
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool(
        "read_file",
        "读取文件内容。",
        {"path": {"type": "string"}, "limit": {"type": "integer"}},
        ["path"],
    ),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool(
        "write_file",
        "将内容写入文件。",
        {"path": {"type": "string"}, "content": {"type": "string"}},
        ["path", "content"],
    ),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool(
        "edit_file",
        "在文件中精确替换一段文本(仅替换一次)。",
        {
            "path": {"type": "string"},
            "old_text": {"type": "string"},
            "new_text": {"type": "string"},
        },
        ["path", "old_text", "new_text"],
    ),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool(
        "glob", "按 glob 模式查找文件。", {"pattern": {"type": "string"}}, ["pattern"]
    ),  # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool(
        "send_message",
        "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
        {
            "to": {"type": "string"},
            "content": {"type": "string"},
        },
        ["to", "content"],
    ),
    _fn_tool(
        "check_inbox",
        "检查自己的收件箱(队友回信)。",
        {},
        [],
    ),
    # 定义 submit_plan 工具:向 Lead 提交计划待审批。
+   _fn_tool(
+       'submit_plan',
+       '向 Lead 提交计划待审批。',
+       {
+           'from_name': {'type': 'string'},
+           'plan': {'type': 'string'}
+       },
+       ['from_name', 'plan']
+   )
]
TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "spawn_subagent",
        "启动子 Agent 处理复杂子任务。仅返回最终结论。",
        {"description": {"type": "string"}},
        ["description"],
    ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    ),
    _fn_tool(
        "compact", "摘要较早对话以释放上下文空间。", {"focus": {"type": "string"}}, []
    ),
    _fn_tool(
        "create_task",
        "创建新任务,可选 blockedBy 依赖。",
        {
            "subject": {"type": "string"},
            "description": {"type": "string"},
            "blockedBy": {"type": "array", "items": {"type": "string"}},
        },
        ["subject"],
    ),
    _fn_tool("list_tasks", "列出所有任务的状态、负责人与依赖。", {}, []),
    _fn_tool(
        "get_task",
        "按 ID 获取任务完整详情。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "claim_task",
        "认领 pending 任务,设置 owner 并改为 in_progress。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "complete_task",
        "完成 in_progress 任务,并报告下游解阻任务。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "schedule_cron",
        "调度 cron 任务。cron 为 5 段:分 时 日 月 周。",
        {
            "cron": {"type": "string", "description": "5 段 cron 表达式"},
            "prompt": {"type": "string", "description": "触发时注入的消息"},
            "recurring": {"type": "boolean", "description": "true=循环,false=单次"},
            "durable": {"type": "boolean", "description": "true=持久化到磁盘"},
        },
        ["cron", "prompt"],
    ),
    _fn_tool("list_crons", "列出所有已注册的 cron 任务。", {}, []),
    _fn_tool(
        "cancel_cron",
        "按 ID 取消 cron 任务。",
        {"job_id": {"type": "string"}},
        ["job_id"],
    ),
    _fn_tool(
        "spawn_teammate",
        "启动自主队友 Agent。",
        {
            "name": {"type": "string"},
            "role": {"type": "string"},
            "prompt": {"type": "string"},
        },
        ["name", "role", "prompt"],
    ),
     # 定义 request_shutdown 工具:请求队友优雅关闭
    _fn_tool(
        'request_shutdown',
        '请求队友优雅关闭。',
        {'teammate': {'type': 'string'}},
        ['teammate'],
    ),
    # 定义 request_plan 工具:要求队友提交计划供审核
+   _fn_tool(
+       'request_plan',
+       '要求队友提交计划供审核。',
+       {'teammate': {'type': 'string'}, 'task': {'type': 'string'}},
+       ['teammate', 'task'],
+   ),
    # 定义 review_plan 工具:按 request_id 批准或拒绝已提交的计划
+   _fn_tool(
+       'review_plan',
+       '按 request_id 批准或拒绝已提交的计划。',
+       {
+           'request_id': {'type': 'string'},
+           'approve': {'type': 'boolean'},
+           'feedback': {'type': 'string'},
+       },
+       ['request_id', 'approve'],
+   )
]
TEAMMATE_TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    )
]

17. Autonomous Agents — 自己看板,自己认领 #

"自己看板,自己认领" — idle 时轮询 inbox + .tasks/,有活就 claim,不依赖 Lead 挨个派活。

本节对应教程 s17,在 s16 协议与 idle 之上引入 队友自治。s16 的队友能握手关机、能等 inbox,但「下一个干什么」仍靠 Lead 发消息或初始 prompt。任务看板上若有 10 个未认领任务,Lead 得手动交代 10 次——不可扩展。本节让队友在空闲阶段自己扫看板、认领可做任务,做完再 idle,形成自组织循环。

本节要解决什么

场景 s16(协议 + idle) s17(自治)
任务来源 Lead 初始 prompt / send_message 同上 + 看板自动认领
idle 做什么 只轮询 inbox / 关机握手 inbox 优先,其次 scan_unclaimed_tasks
认领方式 Lead 或队友手动 claim_task idle 内自动 claim_task(owner=队友名)
生命周期 WORK → IDLE → 超时/关机退出 外层反复 WORK ⇄ IDLE,直到超时或 shutdown
扩展性 Lead 成为分配瓶颈 多队友并行扫同一看板,有活就干

没有自治,团队规模一大,Lead 的上下文会被「派活」占满;有了自治,Lead 只需 create_task + spawn_teammate,后续由队友自组织消化。

三样新增 / 强化机制

机制 作用
scan_unclaimed_tasks() 扫描 .tasks/:pending、无 owner、且 can_start(依赖已完成)
claim_task 的 owner 检查 已被认领则拒绝,减轻「后写覆盖」;教学版无文件锁
idle_poll 扩展 无 inbox 时扫看板 → 认领成功则注入 <auto-claimed> → 返回 'work'

优先级固定:inbox(含 shutdown)> 任务板 > 继续睡;超时返回 'timeout',进入 SHUTDOWN 向 Lead 发 summary。

队友三阶段生命周期

┌──────────── WORK ────────────┐
│ inbox → LLM → 工具(最多     │
│ WORK_MAX_ROUNDS 轮)         │
│ 无 tool_use → 离开 WORK      │
└──────────────┬───────────────┘
               ▼
┌──────────── IDLE ────────────┐
│ 每 IDLE_POLL_INTERVAL 秒:   │
│  ① read_inbox                │
│     · shutdown → 'shutdown'  │
│     · 普通消息 → 'work'      │
│  ② scan_unclaimed_tasks      │
│     · claim 成功 → 'work'    │
│  ③ 满 IDLE_TIMEOUT →         │
│     'timeout'                │
└──────────────┬───────────────┘
               ▼
         SHUTDOWN:BUS.send(result) → 退出

外层 while True 让 WORK 与 IDLE 交替:认领新任务后回到 WORK,干完再 idle,直到超时或关机——这才是「自主队友」,而不是「跑完初始 prompt 就消失」。

自动认领条件(三道闸)

条件 含义
status == pending 尚未开始
owner is None 无人认领
can_start(task_id) blockedBy 依赖全部 completed

认领成功后:owner = 队友名,status = in_progress,并向队友 messages 注入:

<auto-claimed>任务 task_xxx: 主题</auto-claimed>

LLM 下一轮 WORK 就能看到任务,继续调 read_file / write_file / complete_task 等工具。

与 s12 任务系统、s16 协议的关系

概念 角色
s12 .tasks/ + DAG 持久化目标与依赖;本节消费看板,不重造任务模型
s16 协议 / MessageBus 关机与计划审批仍生效;idle 中 inbox 优先于认领
s17 自治 把「谁来做」从 Lead 派发,下沉到队友空闲策略

Lead 仍可用 send_message 插队;request_shutdown 在 IDLE 阶段照样能打断轮询并优雅退出。

相对 s16 的变化

文件 变化
tasks.py 新增 scan_unclaimed_tasks;claim_task 增加「已有 owner 则拒绝」
teams.py idle_poll 在无 inbox 时扫看板并自动认领;spawn 的 system 提示可认领任务
prompt.py identity 写明:idle 轮询看板、create_task 后队友可自动认领
tools/schema.py spawn_teammate 描述改为「自主队友(idle 轮询看板、自动认领)」

s16 的 ProtocolState / request_shutdown / MessageBus 全部保留;自治是 idle 策略的增强,不是另起炉灶。

试试这些 prompt:

  1. 创建两个任务:① 写 schema.sql(users 表)② 写 seed.sql(依赖第一个)。再 spawn 一名 alice,不要给她具体指令,只说「等任务」。
  2. spawn bob 和 carol 两个队友,再 create_task 三条互不依赖的小任务,观察谁自动认领了哪条。

观察重点:控制台是否出现 [idle] alice 自动认领: ...?.tasks/*.json 的 owner / status 是否变为队友名与 in_progress?Lead 不发 send_message 时队友是否仍开始干活?

时序图

从 Lead 建任务、派队友,到 idle 自动认领再回到 WORK:

sequenceDiagram participant Lead as agent_loop (Lead) participant Tasks as .tasks/ participant TM as Teammate 线程 participant Idle as idle_poll participant Scan as scan_unclaimed_tasks participant Claim as claim_task participant LLM as LLM Lead->>Tasks: create_task("写 schema.sql") Note over Tasks: status=pending, owner=null Lead->>TM: spawn_teammate(alice, role, "待命") TM->>LLM: WORK:处理初始 prompt LLM-->>TM: 无 tool_use(或干完) TM->>Idle: 进入 IDLE loop 每 IDLE_POLL_INTERVAL(如 5s) Idle->>Idle: read_inbox(alice) alt 有 shutdown_request Idle-->>TM: return "shutdown" else 有普通 inbox Idle->>TM: 注入 inbox → return "work" else 收件箱空 Idle->>Scan: scan_unclaimed_tasks() Scan->>Tasks: 筛 pending ∧ ¬owner ∧ can_start Scan-->>Idle: [task_xxx, ...] Idle->>Claim: claim_task(task_xxx, "alice") Claim->>Tasks: owner=alice, status=in_progress Claim-->>Idle: 已认领 task_xxx Idle->>TM: messages += auto-claimed Idle-->>TM: return "work" end end Note over TM,LLM: 回到外层 while → 再次进入 WORK TM->>LLM: 看到 auto-claimed 任务 LLM-->>TM: tool_calls(write_file / complete_task …) TM->>Tasks: complete_task → 可能解阻下游 TM->>Idle: 再进入 IDLE(可认领下一个) opt 满 IDLE_TIMEOUT 无活 Idle-->>TM: return "timeout" TM->>Lead: BUS.send(result summary) end

说明:inbox 永远优先于看板,保证关机握手不被认领逻辑饿死。教学版按任务列表顺序取第一个可认领项,无文件锁——多队友同时 idle 时仍可能竞态,真实 Claude Code 用 proper-lockfile 保护 claim。s18 将在认领之上引入 worktree,让并行队友各有独立工作目录。

17.1. prompt.py #

prompt.py

from config import WORKDIR

# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR, MEMORY_INDEX, TEXT_ENCODING

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    "identity": (
        f"你是一个编程 Agent。直接行动,不要解释。"
        f"你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
        f"所有破坏性操作需要用户批准。"
        f"开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。"
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
        f"bash 支持 run_in_background 参数以在后台运行耗时命令。"
        f"定时任务可使用 schedule_cron / list_crons / cancel_cron。"
        f"遇到复杂子问题时,可使用 spawn_teammate 委派队友。"
        f"teammate团队协作可使用 spawn_teammate / send_message / check_inbox。"
        f"request_plan 要求队友 submit_plan 后,用 review_plan(request_id, approve) 批准或拒绝;"
        f"任务结束或需回收资源时用 request_shutdown 请求队友优雅退出。"
+       f"团队协作:spawn_teammate 启动自主队友(idle 时轮询看板并自动认领任务);"
+       f"create_task 创建任务后队友可在 idle 阶段自动认领;"
+       f"send_message 向队友发消息;check_inbox 查看队友回信(含协议响应状态)。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    "workspace": f"工作目录:{WORKDIR}",
    # 'skill' 键,指明需要完整技术文档时的指引
    "skill": "需要完整技术说明时,使用 load_skill 加载相关文档。",
    # 'memory' 键,指明记忆的使用方式
    "memory": "下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。",
}


# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str, memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS["identity"], PROMPT_SECTIONS["workspace"]]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f"可用技能:\n{skills}")
        sections.append(PROMPT_SECTIONS["skill"])
        # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f"可用记忆:\n{memories}")
        sections.append(PROMPT_SECTIONS["memory"])
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return "\n\n".join(sections)


# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ""
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return "\n".join(
        f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()
    )


# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ""
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors="replace").strip()


# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None


# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt


# 定义子任务的系统提示语
SUB_SYSTEM = (
    f"你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。"
    "你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
    "完成分配给你的任务,然后返回简洁摘要。不要继续委派。"
)

17.2. tasks.py #

tasks.py

# 导入random模块用于生成随机数
import random
# 导入time模块用于获取当前时间戳
import time
# 导入json模块用于处理JSON数据
import json
# 从dataclasses模块导入dataclass和asdict用于数据结构定义和转换
from dataclasses import dataclass,asdict
# 从pathlib模块导入Path用于路径操作
from pathlib import Path
# 从config.py导入任务目录和文本编码设置
from config import TASKS_DIR, TEXT_ENCODING

# 用于表示任务的数据类
@dataclass
class Task:
    # 任务ID
    id: str
    # 任务主题
    subject: str
    # 任务描述
    description: str
    # 任务状态
    status: str
    # 任务负责人
    owner: str | None
    # 任务依赖列表
    blockedBy: list[str]

# 根据任务ID生成任务文件路径
def _task_path(task_id: str) -> Path:
    return TASKS_DIR / f'{task_id}.json'

# 保存Task对象到文件
def save_task(task: Task):
    _task_path(task.id).write_text(
        json.dumps(asdict(task), indent=2, ensure_ascii=False),
        encoding=TEXT_ENCODING,
    )

# 创建一个新的任务并保存
def create_task(subject: str, description: str = '', blockedBy: list[str] | None = None) -> Task:
    task = Task(
        # 生成唯一的任务ID
        id=f'task_{int(time.time())}_{random.randint(0, 9999):04d}',
        # 设置任务主题
        subject=subject,
        # 设置任务描述
        description=description,
        # 任务初始状态为pending
        status='pending',
        # 初始负责人为空
        owner=None,
        # 设置依赖列表,为空则默认[]
        blockedBy=blockedBy or [],
    )
    # 存储任务到文件
    save_task(task)
    # 返回任务对象
    return task

# 获取所有任务列表
def list_tasks() -> list[Task]:
    return [
        # 读取每个任务文件并转为Task对象
        Task(**json.loads(p.read_text(encoding=TEXT_ENCODING)))
        # 查找所有任务json文件,并排序
        for p in sorted(TASKS_DIR.glob('task_*.json'))
    ]

# 加载指定ID的任务
def load_task(task_id: str) -> Task:
    return Task(**json.loads(_task_path(task_id).read_text(encoding=TEXT_ENCODING)))

# 获取任务的JSON字符串
def get_task(task_id: str) -> str:
    return json.dumps(asdict(load_task(task_id)), indent=2, ensure_ascii=False)

# 判断任务是否可以开始(依赖完成且存在)
def can_start(task_id: str) -> bool:
    # 加载当前任务信息
    task = load_task(task_id)
    # 遍历所有依赖任务
    for dep_id in task.blockedBy:
        # 依赖任务文件不存在则不可开始
        if not _task_path(dep_id).exists():
            return False
        # 依赖任务未完成也不可开始
        if load_task(dep_id).status != 'completed':
            return False
    # 所有依赖都满足
    return True

# 认领任务
def claim_task(task_id: str, owner: str = 'agent') -> str:
    # 加载任务
    task = load_task(task_id)
    # 如果任务状态不是pending, 则无法认领
    if task.status != 'pending':
        return f'任务 {task_id} 状态为 {task.status},无法认领'
    # 如果任务已被认领(owner不为空),返回被谁认领的提示
+   if task.owner:
+       return f'任务 {task_id} 已被 {task.owner} 认领'
    # 如果任务被依赖阻塞, 则无法认领
    if not can_start(task_id):
        # 找出所有未完成的依赖
        deps = [
            d for d in task.blockedBy
            if not _task_path(d).exists() or load_task(d).status != 'completed'
        ]
        return f'被阻塞,依赖: {deps}'
    # 设置负责人
    task.owner = owner
    # 设置任务状态为进行中
    task.status = 'in_progress'
    # 保存任务
    save_task(task)
    # 在控制台输出认领提示
    print(f'  \x1b[36m[认领] {task.subject} → in_progress(负责人: {owner})\x1b[0m')
    # 返回认领结果字符串
    return f'已认领 {task.id}({task.subject})'    

# 完成任务
def complete_task(task_id: str) -> str:
    # 加载任务
    task = load_task(task_id)
    # 如果任务不是进行中状态, 则无法完成
    if task.status != 'in_progress':
        return f'任务 {task_id} 状态为 {task.status},无法完成'
    # 设置任务状态为已完成
    task.status = 'completed'
    # 保存任务
    save_task(task)
    # 查找因本任务解锁、现在可以开始的所有等待任务
    unblocked = [
        t.subject for t in list_tasks()
        if t.status == 'pending' and t.blockedBy and can_start(t.id)
    ]
    # 在控制台输出任务完成信息
    print(f'  \x1b[32m[完成] {task.subject} ✓\x1b[0m')
    # 构造返回信息
    msg = f'已完成 {task.id}({task.subject})'
    # 如果有已解锁的任务,则信息中增加这些任务
    if unblocked:
        msg += f"\n已解阻: {', '.join(unblocked)}"
        print(f"  \x1b[33m[解阻] {', '.join(unblocked)}\x1b[0m")
    # 返回最终的信息
    return msg

# 查找 pending、无 owner、依赖已全部完成的任务
# 定义一个返回未被认领且所有依赖已完成任务的函数
+def scan_unclaimed_tasks() -> list[Task]:
    # 遍历所有任务,筛选出状态为pending、没有负责人且可开始的任务
+   return [
        # 对每个任务t进行判断并加入返回列表
+       t for t in list_tasks()
        # 筛选条件:任务状态为pending,负责人为空,依赖任务均已完成
+       if t.status == 'pending' and not t.owner and can_start(t.id)
+   ]

17.3. teams.py #

teams.py


# 导入json模块,用于处理JSON数据
import json
# 导入threading模块,用于多线程
import threading
# 导入time模块,用于时间处理
import time
# 当前 Agent 身份(Lead 主线程默认 lead;队友线程启动时设为队友名)
from contextvars import ContextVar
# 导入dataclass模块,用于定义数据类
from dataclasses import dataclass, field
# 从config模块导入常量和对象
from config import (
    WORKDIR,  # 工作目录
    client,  # 大语言模型客户端
    MODEL_ID, # 主模型名称
    DEFAULT_MAX_TOKENS,# 默认最大token数
    MAILBOX_DIR,   # 邮箱目录
    TEXT_ENCODING # 文本编码方式
)
# 从tools.schema模块导入队友工具列表
from tools.schema import TEAMMATE_TOOLS
# 从history模块导入repair_message_chain函数
from history import repair_message_chain
# 从utils模块导入assistant_message_dict方法
from utils import assistant_message_dict
# 从tasks模块导入scan_unclaimed_tasks, claim_task   
+from tasks import scan_unclaimed_tasks, claim_task
# 定义主管(lead)的名字
LEAD_NAME = "lead"
# 导入random模块,用于生成 request_id
import random
# 队友 LLM 调用最大轮次(防止无限循环)
TEAMMATE_MAX_ROUNDS = 50
# 空闲超时时间(单位:秒)
IDLE_TIMEOUT = 60
# 空闲轮询时间间隔(单位:秒)
IDLE_POLL_INTERVAL = 5
# 当前调用工具的 Agent 名称
current_agent: ContextVar[str] = ContextVar("current_agent", default="lead")
# active_teammates: 队友名 → 线程对象
active_teammates: dict[str, threading.Thread] = {}
# agent 最大工作回合数
WORK_MAX_ROUNDS = 10
# MessageBus 文件读写锁
_bus_lock = threading.Lock()
# 使用dataclass装饰器定义一个协议状态的数据结构
@dataclass
class ProtocolState:
    # 请求的唯一标识符
    request_id: str
    # 协议类型,可能为 shutdown 或 plan_approval
    type: str       # shutdown | plan_approval
    # 请求发送者
    sender: str
    # 请求目标对象
    target: str
    # 状态,可能为 pending、approved 或 rejected
    status: str     # pending | approved | rejected
    # 附加数据/信息
    payload: str
    # 创建时间,默认为当前时间
    created_at: float = field(default_factory=time.time)

# 用于存储所有挂起的协议请求,键为请求ID,值为协议状态对象
pending_requests: dict[str, ProtocolState] = {}

# 生成新的唯一请求ID
def new_request_id() -> str:
    # 随机生成6位数字,格式化为 req_xxxxxx 的字符串
    return f'req_{random.randint(0, 999999):06d}'
# 消息总线类,用于管理不同agent间消息传递
class MessageBus:
    """基于文件的消息总线。每个 Agent 一个 .jsonl 收件箱,读取即消费。"""
    # 发送消息的方法
    def send(
        self,
        from_agent: str,# 发送者
        to_agent: str,# 接收者
        content: str,# 消息内容
        msg_type: str = "message",# 消息类型
        metadata: dict | None = None, # 附加元数据,默认为 None
    ):
        # 构造消息内容的字典
        msg = {
            "from": from_agent,  # 发送者
            "to": to_agent,  # 接收者
            "content": content,  # 消息内容
            "type": msg_type,  # 消息类型
            "ts": time.time(),  # 时间戳
            'metadata': metadata or {},            # 元数据,默认为空字典
        }
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
        with _bus_lock:
            # 以追加模式写入收件箱
            with open(inbox, "a", encoding=TEXT_ENCODING) as f:
                # 将消息写为json字符串,每条一行
                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
        # 控制台打印消息发送信息
        print(
            f"  \x1b[33m[总线] {from_agent} → {to_agent}[{msg_type}]: {content[:50]}\x1b[0m"
        )

    # 读取某agent收件箱的方法(与 send 共用锁,避免读写竞态丢信)
    def read_inbox(self, agent: str) -> list[dict]:
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{agent}.jsonl"
        with _bus_lock:
            # 如果收件箱文件不存在,则返回空列表
            if not inbox.exists():
                return []
            # 读取所有消息,每行解析为json字典
            msgs = [
                json.loads(line)
                for line in inbox.read_text(encoding=TEXT_ENCODING).splitlines()
                if line.strip()
            ]
            # 读取后删除收件箱文件
            inbox.unlink()
        # 返回消息列表
        return msgs


# 实例化消息总线对象
BUS = MessageBus()

# 获取队友 LLM 上下文,只取最新 tail 条消息,并修复 tool 链
def _teammate_llm_context(messages: list, tail: int = 20) -> list:
    # 如果消息数量大于 tail,则取最后 tail 条,否则全部取
    window = messages[-tail:] if len(messages) > tail else list(messages)
    # 修复 tool 链,防止 API 错误,返回修复后的窗口消息
    return repair_message_chain(window)



# 处理队友的收件箱消息,将协议消息(如关机批复、计划审批等)和普通消息区分开
def _process_teammate_inbox(
    teammate_name: str,      # 队友名称
    inbox: list[dict],       # 收件箱消息列表
    messages: list           # 对话消息列表
) -> tuple[bool, list[dict]]:
    # 标记是否需要终止(收到关机请求)
    should_stop = False
    # 用于保存非协议消息
    non_protocol = []
    # 遍历收件箱中的每一条消息
    for msg in inbox:
        # 获取消息类型,默认为 'message'
        msg_type = msg.get('type', 'message')
        # 获取元数据字典,默认为空字典
        meta = msg.get('metadata', {})
        # 获取请求 ID,默认为空字符串
        req_id = meta.get('request_id', '')
        # 如果收到关机请求类型的协议消息
        if msg_type == 'shutdown_request':
            # 回复 Lead,说明已同意关闭
            BUS.send(
                teammate_name,            # 当前队友名称
                LEAD_NAME,                # Lead 名称
                '正在优雅关闭。',           # 消息内容
                'shutdown_response',      # 消息类型
                {'request_id': req_id, 'approve': True},  # 元数据,附带请求 ID 和批准信号
            )
            # 打印紫色的协议日志,说明已同意关闭
            print(f'  \x1b[35m[协议] {teammate_name} 已同意关闭({req_id})\x1b[0m')
            # 标记 should_stop 为 True
            should_stop = True
            # 跳出 for 循环,后续消息不再处理
            break
        # 如果收到计划审批响应
        if msg_type == 'plan_approval_response':
            # 获取是否批准
            approve = meta.get('approve', False)
            # 如果批准
            if approve:
                # 向对话消息列表添加“计划已批准”的提示消息
                messages.append({
                    'role': 'user',
                    'content': '[计划已批准] 请继续执行任务。',
                })
            else:
                # 否则添加“计划被拒绝”与反馈内容
                messages.append({
                    'role': 'user',
                    'content': f"[计划被拒绝] 反馈: {msg['content']}",
                })
            # 忽略后续代码,继续处理下条收件箱消息
            continue
        # 普通消息添加到 non_protocol 列表
        non_protocol.append(msg)
    # 返回是否需要停止循环和所有未被协议处理的普通消息
    return should_stop, non_protocol

# 空闲轮询函数,用于处理队友的空闲状态
def idle_poll(agent_name: str, messages: list) -> str:
    # 轮询 IDLE_TIMEOUT 秒,分为若干小轮,每一轮暂停 IDLE_POLL_INTERVAL 秒
    for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
        # 暂停 IDLE_POLL_INTERVAL 秒
        time.sleep(IDLE_POLL_INTERVAL)

        # 读取 agent_name 的 inbox 消息
        inbox = BUS.read_inbox(agent_name)
        # 如果 inbox 非空,说明有新消息
        if inbox:
            # 遍历收件箱中的每一条消息
            for msg in inbox:
                # 判断消息类型是否为关机请求
                if msg.get('type') == 'shutdown_request':
                    # 获取该消息的 request_id,如果没有则为空字符串
                    req_id = msg.get('metadata', {}).get('request_id', '')
                    # 回复 Lead,表示已同意关闭
                    BUS.send(
                        agent_name,
                        LEAD_NAME,
                        '正在优雅关闭。',
                        'shutdown_response',
                        {'request_id': req_id, 'approve': True},
                    )
                    # 打印紫色的协议日志,表示在 idle 时同意关闭
                    print(
                        f'  \x1b[35m[协议] {agent_name} 在 idle 时同意关闭({req_id})\x1b[0m'
                    )
                    # 返回 'shutdown',表示关闭
                    return 'shutdown'

            # 将收到的 inbox 消息以 json 格式写入 messages
            messages.append({
                'role': 'user',
                'content': '<inbox>' + json.dumps(inbox, ensure_ascii=False) + '</inbox>',
            })
            # 打印收到 inbox 消息的提示
            print(f'  \x1b[36m[idle] {agent_name} 收到 inbox 消息\x1b[0m')
            # 返回 'work',表示进入工作状态
            return 'work'
         # 检查是否有未被认领的任务
+       unclaimed = scan_unclaimed_tasks()
        # 如果存在未认领的任务
+       if unclaimed:
            # 取第一个未认领任务
+           task = unclaimed[0]
            # 尝试用 agent_name 认领该任务
+           result = claim_task(task.id, agent_name)
            # 如果认领成功(返回值以"已认领"开头)
+           if result.startswith('已认领'):
                # 将自动认领的信息记入 messages
+               messages.append({
+                   'role': 'user',
+                   'content': (
+                       f'<auto-claimed>任务 {task.id}: '
+                       f'{task.subject}</auto-claimed>'
+                   ),
+               })
                # 打印自动认领任务的绿色提示
+               print(f'  \x1b[32m[idle] {agent_name} 自动认领: {task.subject}\x1b[0m')
                # 返回 'work',表示进入工作状态
+               return 'work'
            # 如果认领失败,打印黄色失败提示
+           print(f'  \x1b[33m[idle] {agent_name} 认领失败: {result}\x1b[0m')
    # 如果超时轮询结束仍未有新消息或任务,打印红色超时提示
    print(f'  \x1b[31m[idle] {agent_name} 超时({IDLE_TIMEOUT}s)\x1b[0m')
    # 返回 'timeout',表示空闲超时
    return 'timeout'

# 启动一个队友线程函数
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
    # 如果请求启动的名字与 Lead 名字重复,则返回错误
    if name == LEAD_NAME:
        return f"错误:不能使用保留名 '{LEAD_NAME}'"
    # 查找当前名字的队友线程是否存在
    existing = active_teammates.get(name)
    # 如果该线程已经存在并且存活,则提示已存在
    if existing and existing.is_alive():
        return f"队友 '{name}' 已存在且仍在运行"
    # 如果线程对象存在但未存活,将其从 active_teammates 移除
    if existing:
        active_teammates.pop(name, None)
        # 打印黄色日志说明旧线程被移除,可以重新启动
        print(f"  \x1b[33m[队友] {name} 旧线程已退出,允许重新启动\x1b[0m")
    # 构建 system prompt,指示 AI 队友身份及工作指令
    system = (
        f"你是 '{name}',角色为 {role}。"
        f"工作目录: {WORKDIR}。使用 Windows cmd 命令。"
        f"检查收件箱中的协议消息(shutdown_request、plan_approval_response等)。"
        f"需要 Lead 审批时,使用 submit_plan 提交计划。"
+       f"使用工具完成任务。你可以从看板列出并认领任务。"
    )
    # 队友线程主执行函数
    def run():
        # 延迟导入,避免与 handlers 循环依赖
        from tools.executor import execute_tool
        from tools.schema import TOOLS
        # 绑定当前线程的 Agent 身份,防止伪造  from_agent
        identity_token = current_agent.set(name)
        # 初始化消息,prompt作为第一条user消息
        messages = [{"role": "user", "content": prompt}]
        # 用于记录退出原因
        exit_reason = ""
         # try-finally 保证安全清理退出
        try:
            # 无限循环,直到线程被停止
            while True:
                # 若消息数量不超过 3 条,插入身份声明消息
                if len(messages) <= 3:
                    messages.insert(0, {
                        'role': 'user',
                        'content': (
                            f"<identity>你是 '{name}',角色: {role}。"
                            f"请继续你的工作。</identity>"
                        ),
                    })   
                # 初始化是否退出循环标志
                should_shutdown = False  
                # 进入最大循环轮数限制
                for _ in range(WORK_MAX_ROUNDS):
                    # 读取当前队友的收件箱消息
                    inbox = BUS.read_inbox(name)    
                    # 如果收件箱有消息,则进行处理
                    if inbox:
                        # 处理协议消息和非协议消息
                        should_stop, non_protocol = _process_teammate_inbox(
                            name, inbox, messages,
                        )
                        # 若收到关闭信号,则设置标志并跳出循环
                        if should_stop:
                            should_shutdown = True
                            break
                        # 如果有非协议消息,将其添加到对话消息列表
                        if non_protocol:
                            messages.append({
                                'role': 'user',
                                'content': (
                                    f'<inbox>{json.dumps(non_protocol, ensure_ascii=False)}</inbox>'
                                ),
                            })  
                    # 通过 OpenAI 客户端请求 LLM 产生回复
                    try:
                        response = client.chat.completions.create(
                            model=MODEL_ID,
                            messages=[
                                {'role': 'system', 'content': system},
                                *_teammate_llm_context(messages),
                            ],
                            tools=TOOLS,
                            max_tokens=DEFAULT_MAX_TOKENS,
                        )
                    # 捕捉 API 调用异常,记录错误与退出原因
                    except Exception as e:
                        exit_reason = f'LLM 错误: {type(e).__name__}: {e}'
                        print(f'  \x1b[31m[队友] {name} {exit_reason}\x1b[0m')
                        should_shutdown = True
                        break   
                     # 获取 assistant 角色的回复消息
                    assistant = response.choices[0].message
                    # 将 assistant 消息加入消息列表
                    messages.append(assistant_message_dict(assistant))

                    # 如果 assistant 没有调用任何工具,跳出当前大循环
                    if not assistant.tool_calls:
                        break

                    # 遍历所有工具调用,执行每一个工具
                    for tool_call in assistant.tool_calls:
                        # 获取工具名称
                        tname = tool_call.function.name
                        # 解析工具参数
                        args = json.loads(tool_call.function.arguments or '{}')
                        # 执行工具(含 worktree cwd 切换)
                        output = execute_tool(tname, args)
                        # 回复工具调用的结果消息
                        messages.append({
                            'role': 'tool',
                            'tool_call_id': tool_call.id,
                            'content': output,
                        })
                # 如果应当退出主循环,则跳出外层 while
                if should_shutdown:
                    break     
                # 进入空闲轮询(自动认领时可设置 wt_ctx)
                idle_result = idle_poll(name, messages)
                # 如果收到关闭信号,则跳出循环
                if idle_result == 'shutdown':
                    break
                # 如果长时间未响应,设置超时退出原因
                if idle_result == 'timeout':
                    exit_reason = f'idle 超时({IDLE_TIMEOUT}s)'
                    break
            # 组织总结性回复,默认优先用 exit_reason
            summary = exit_reason or '完成。'
            # 从最后的 assistant 消息中找一条有内容的作为总结
            for msg in reversed(messages):
                if msg.get('role') == 'assistant' and msg.get('content'):
                    content = msg['content']
                    if isinstance(content, str) and content.strip():
                        summary = content
                        break

            # 向 Lead 汇报最终结果消息
            BUS.send(name, LEAD_NAME, summary, 'result')
            # 控制台输出队友结束日志
            print(f'  \x1b[32m[队友] {name} 已结束\x1b[0m')    
        finally:
            current_agent.reset(identity_token)
            active_teammates.pop(name, None)   
    # 创建线程对象,目标为 run 函数,设置为守护线程
    thread = threading.Thread(target=run, daemon=True)
    # 将该线程注册到 active_teammates 字典
    active_teammates[name] = thread
    # 启动线程
    thread.start()
    # 启动后打印青色控制台日志
    print(f"  \x1b[36m[队友] 已启动 {name},角色 {role}\x1b[0m")
    return f"队友 '{name}' 已启动,角色 {role}"         

# 将收件箱消息格式化为文本字符串(含 type / request_id,便于 review_plan)
def format_inbox_messages(msgs: list[dict]) -> str:
    lines = []
    for m in msgs:
         # 从消息字典中获取 'metadata' 字段,没有则默认为空字典
        meta = m.get('metadata', {})
        # 从 metadata 字典中获取 'request_id' 字段,没有则默认为空字符串
        req_id = meta.get('request_id', '')
        # 如果request_id存在,则格式为“[类型 req:request_id]”,否则为“[类型]”
        tag = f" [{m.get('type', 'message')} req:{req_id}]" if req_id else f" [{m.get('type', 'message')}]"
        # 生成包含来源、标签和内容(截断到前200个字符)的字符串并加入lines列表
        lines.append(f"来自 {m['from']}{tag}: {m['content'][:200]}")
    # 将所有格式化好的消息行用换行拼接,最前面加上“[收件箱]”标题,作为最终返回的字符串
    return "[收件箱]\n" + "\n".join(lines)


# 匹配响应,根据 request_id 关联并校验响应类型
def match_response(response_type: str, request_id: str, approve: bool) -> None:
    # 通过 request_id 获取协议状态
    state = pending_requests.get(request_id)
    # 未找到对应协议请求
    if not state:
        print(f'  \x1b[31m[协议] 未知 request_id: {request_id}\x1b[0m')
        return
    # 校验 shutdown 类型的请求响应类型是否正确
    if state.type == 'shutdown' and response_type != 'shutdown_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 shutdown_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 校验 plan_approval 类型的请求响应类型是否正确
    if state.type == 'plan_approval' and response_type != 'plan_approval_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 plan_approval_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 判断该请求状态是否已处理过,避免重复处理
    if state.status != 'pending':
        print(f'  \x1b[33m[协议] {request_id} 已是 {state.status},忽略重复响应\x1b[0m')
        return
    # 根据approve参数设置状态为通过或拒绝
    state.status = 'approved' if approve else 'rejected'
    # 选择显示的icon(勾或叉)
    icon = '✓' if approve else '✗'
    # 通过或拒绝对应不同颜色
    color = '32' if approve else '31'
    # 打印协议处理结果信息
    print(
        f'  \x1b[{color}m[协议] {state.type} {icon} '
        f'({request_id}: {state.status})\x1b[0m'
    )

# 定义函数,读取 Lead 收件箱。参数 route_protocol 表示是否需要路由协议响应。
def consume_lead_inbox(route_protocol: bool = True) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(LEAD_NAME)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 如果需要路由协议响应
    if route_protocol:
        # 遍历所有消息
        for msg in msgs:
            # 从消息中获取 metadata,默认为空字典
            meta = msg.get('metadata', {})
            # 从 metadata 中获取 request_id,默认为空字符串
            req_id = meta.get('request_id', '')
            # 获取消息类型
            msg_type = msg.get('type', '')
            # 如果有 request_id 且消息类型以 "_response" 结尾
            if req_id and msg_type.endswith('_response'):
                # 从 metadata 中获取 approve 字段,默认为 False
                approve = meta.get('approve', False)
                # 调用 match_response 方法,路由协议响应
                match_response(msg_type, req_id, approve)
    # 返回读取到的所有消息
    return msgs
    s
# 注入lead的收件箱消息到对话消息列表
def inject_lead_inbox(messages: list) -> int:
    # 调用consume_lead_inbox读取lead收件箱消息,开启路由协议
    inbox = consume_lead_inbox(route_protocol=True)
    # 如果没有消息,返回0
    if not inbox:
        return
    # 把收件箱内容格式化为一条user消息,添加到对话消息列表
    messages.append({'role': 'user', 'content': format_inbox_messages(inbox)})
    # 控制台打印注入了多少条消息
    print(f'  \x1b[33m[收件箱] 已注入 {len(inbox)} 条消息\x1b[0m')

# 判断指定名字的队友线程是否在运行
def is_teammate_running(name: str) -> bool:
    # 从 active_teammates 字典中获取指定名字的线程对象
    thread = active_teammates.get(name)
    # 判断线程对象是否存在且线程是否存活
    return thread is not None and thread.is_alive()


# 定义函数,读取 Lead 收件箱。
def consume_inbox(agent_name: str) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(agent_name)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 返回读取到的所有消息
    return msgs

# 请求优雅关闭某队友,向其发起关机协议消息
def run_request_shutdown(teammate: str) -> str:
    # 生成新的唯一请求 ID
    req_id = new_request_id()
    # 在 pending_requests 字典中记录关机请求的 protocol 状态
    pending_requests[req_id] = ProtocolState(
        request_id=req_id,   # 请求编号
        type='shutdown',     # 协议类型
        sender=LEAD_NAME,    # 发起者为 Lead
        target=teammate,     # 目标为指定队友名
        status='pending',    # 当前状态为等待处理
        payload='',          # 没有关联负载
    )
    # 向队友发送关机请求消息,包括元数据中的请求 ID
    BUS.send(LEAD_NAME, teammate, '请优雅关闭。', 'shutdown_request', {'request_id': req_id})
    # 打印带颜色的控制台日志,显示已发送关机请求
    print(f'  \x1b[35m[协议] shutdown_request → {teammate}({req_id})\x1b[0m')
    # 正常情况下返回已发送请求的说明
    return f'已向 {teammate} 发送关闭请求(req: {req_id})'

# 要求队友提交计划,即发消息让队友编写任务计划
def run_request_plan(teammate: str, task: str) -> str:
    # 向队友收件箱发送请求,要求其提交计划,消息类型为普通 message
    BUS.send(LEAD_NAME, teammate, f'请提交计划: {task}', 'message')
    # 否则返回已成功请求队友提交计划
    return f'已要求 {teammate} 提交计划'


# 队友向 Lead 提交计划以待审批
def run_submit_plan(from_name: str, plan: str) -> str:
    # 函数说明文档
    """队友向 Lead 提交计划待审批。"""
    # 生成计划审批请求的新 request_id
    req_id = new_request_id()
    # 在 pending_requests 保存本次请求的状态对象
    pending_requests[req_id] = ProtocolState(
        request_id=req_id,      # 当前 request_id
        type='plan_approval',   # 协议类型为“计划审批”
        sender=from_name,       # 谁发的
        target=LEAD_NAME,       # 发给 Lead
        status='pending',       # 当前状态为等待审批
        payload=plan,           # 计划内容
    )
    # 通过 BUS 发送计划审批请求协议消息,content 是计划内容
    BUS.send(from_name, LEAD_NAME, plan, 'plan_approval_request', {'request_id': req_id})
    # 返回提示文本,包含 request_id
    return f'计划已提交({req_id})。等待审批...'


# Lead 对队友提交的计划进行审批(批准或拒绝),并进行响应
def run_review_plan(request_id: str, approve: bool, feedback: str = '') -> str:
    # 从 pending_requests 字典中查找请求状态对象
    state = pending_requests.get(request_id)
    # 如果找不到该请求,返回提示
    if not state:
        return f'未找到请求 {request_id}'
    # 如果该请求已经不在待处理状态,说明已操作过,返回对应状态
    if state.status != 'pending':
        return f'请求 {request_id} 已是 {state.status}'
    # 根据 approve 设定当前请求的最终状态
    state.status = 'approved' if approve else 'rejected'
    # 向队友发回计划审批协议响应,带上审批反馈和结果
    BUS.send(
        LEAD_NAME,#发送者
        state.sender,#接收者
        feedback or ('已批准' if approve else '已拒绝'),#消息内容
        'plan_approval_response',#消息类型
        {'request_id': request_id, 'approve': approve},#元数据
    )
    # 设定审批通过或者拒绝的标志字符
    icon = '✓' if approve else '✗'
    # 控制台输出审批过程日志,带颜色
    print(f'  \x1b[32m[协议] 计划 {icon}({request_id})\x1b[0m')
    # 返回描述审批结果的字符串
    return f"计划{'已批准' if approve else '已拒绝'}({request_id})"

17.4. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入操作系统相关模块
import glob as g
# 导入subprocess模块,用于执行子进程
import subprocess

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output, safe_path

# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING, WORKDIR

# 从config模块导入文本编码配置
from config import TEXT_ENCODING, WORKDIR

# 从skills模块导入load_skill函数
from skills import load_skill

# 从tasks模块导入create_task函数
from tasks import create_task, list_tasks, get_task, claim_task, complete_task

# 从cron模块导入schedule_job, cancel_job, scheduled_jobs, cron_lock函数
from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock

# 导入操作系统相关模块
import glob as g

# 从 teams 模块导入 spawn_teammate_thread,BUS, LEAD_NAME, format_inbox_messages
from teams import (spawn_teammate_thread,current_agent,BUS,LEAD_NAME,is_teammate_running,format_inbox_messages,consume_inbox,
run_request_shutdown,
run_request_plan,#要求队友提交计划供审核。
run_submit_plan,#向 Lead 提交计划待审批。
run_review_plan,#按 request_id 批准或拒绝已提交的计划。
)

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str, run_in_background: bool = False) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == "nt" and command.strip().lower() == "date":
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = "date /t & time /t"
    # 定义危险命令的列表
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return "错误:危险命令已被拦截"
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,  # 要执行的命令
            shell=True,  # 在shell中执行
            cwd=os.getcwd(),  # 当前工作目录设置为当前路径
            capture_output=True,  # 捕获标准输出和标准错误
            timeout=120,  # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b"") + (r.stderr or b"")).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else "(无输出)"
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return "错误:超时(120 秒)"
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f"错误:{e}"


# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f"...(还有 {len(lines) - limit} 行)"]
        # 将行列表拼接为字符串并返回
        return "\n".join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f"已写入 {len(content)} 字节到 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f"错误:在 {path} 中未找到指定文本"
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(
            text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING
        )
        # 返回编辑成功的提示
        return f"已编辑 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return "\n".join(results) if results else "(无匹配)"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
CURRENT_TODOS: list[dict] = []


# 定义run_todo_write函数,参数为todos列表,返回字符串
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历todos列表,获取每个任务及其索引
    for i, t in enumerate(todos):
        # 如果任务中缺少content或status字段
        if "content" not in t or "status" not in t:
            # 返回错误提示,指出缺少字段的位置
            return f"错误:todos[{i}] 缺少 content 或 status"
        # 如果任务的status不是允许的三种状态
        if t["status"] not in ("pending", "in_progress", "completed"):
            # 返回错误提示,指出状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 校验全部通过后,更新全局任务列表
    CURRENT_TODOS = todos
    # 初始化显示用的lines列表,第一行为标题,并加黄颜色
    lines = ["\n\x1b[33m## 当前任务\x1b[0m"]
    # 遍历所有当前任务
    for t in CURRENT_TODOS:
        # 根据任务状态,选择不同的彩色标签
        icon = {
            "pending": "\x1b[33m等待中\x1b[0m",
            "in_progress": "\x1b[36m处理中\x1b[0m",
            "completed": "\x1b[32m已完成\x1b[0m",
        }[t["status"]]
        # 将格式化后的任务内容和标签加入lines
        lines.append(f"  [{icon}] {t['content']}")
    # 将所有内容组合成字符串打印到标准输出
    print("\n".join(lines))
    # 返回已更新任务数的字符串提示
    return f"已更新 {len(CURRENT_TODOS)} 个任务"


# 定义run_create_task函数,用于创建新任务
def run_create_task(
    # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
    subject: str,
    description: str = "",
    blockedBy: list[str] | None = None,
    # 函数返回类型为字符串
) -> str:
    # 调用create_task函数创建任务对象
    task = create_task(subject, description, blockedBy)
    # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
    # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
    # 返回已创建任务的ID、主题及依赖信息提示
    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
def run_list_tasks() -> str:
    # 调用list_tasks获取所有任务列表
    tasks = list_tasks()
    # 如果任务列表为空
    if not tasks:
        # 返回暂无任务的提示信息
        return "暂无任务。使用 create_task 添加。"
    # 初始化用于存储显示行的空列表
    lines = []
    # 遍历所有任务
    for t in tasks:
        # 根据任务状态获取对应的中文状态标签
        icon = {
            # pending状态对应“等待中”
            "pending": "等待中",
            # in_progress状态对应“处理中”
            "in_progress": "处理中",
            # completed状态对应“已完成”
            "completed": "已完成",
            # 按任务状态取值,未知状态则返回问号
        }.get(t.status, "?")
        # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
        # 若任务有负责人则格式化负责人信息,否则为空字符串
        owner = f" [{t.owner}]" if t.owner else ""
        # 将格式化后的任务信息行加入lines列表
        lines.append(f"  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}")
    # 将所有行用换行符拼接成字符串后返回
    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
def run_get_task(task_id: str) -> str:
    # 尝试获取指定ID的任务
    try:
        # 调用get_task返回任务详情
        return get_task(task_id)
    # 捕获任务文件不存在的异常
    except FileNotFoundError:
        # 返回未找到任务的错误提示
        return f"错误:未找到任务 {task_id}"


# 定义run_claim_task函数,认领指定任务,返回字符串
+def run_claim_task(task_id: str, owner: str = "lead") -> str:
    # 以agent为负责人认领该任务并返回结果
+   return claim_task(task_id, owner=owner or LEAD_NAME)


# 定义run_complete_task函数,完成指定任务,返回字符串
def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
    return complete_task(task_id)


# 定义调度定时(cron)任务的函数
def run_schedule_cron(
    cron: str,  # cron表达式
    prompt: str,  # 提示词
    recurring: bool = True,  # 是否循环
    durable: bool = True,  # 是否持久化
) -> str:  # 返回结果
    # 调用 schedule_job 安排定时任务,返回结果
    result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
    if isinstance(result, str):
        # 返回错误提示
        return f"错误:{result}"
    # 返回调度成功信息,包括 id、表达式和 prompt
    return f"已调度 {result.id}: '{cron}' → {prompt}"


# 定义列出所有 cron 定时任务的函数
def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
    with cron_lock:
        jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
    if not jobs:
        return "暂无 cron 任务。使用 schedule_cron 添加。"
    # 初始化结果字符串列表
    lines = []
    # 遍历所有定时任务
    for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
        tag = "循环" if j.recurring else "单次"
        # 根据 durable 标记区分“持久化”或“会话”
        dur = "持久化" if j.durable else "会话"
        # 拼接任务的信息字符串并加入列表
        lines.append(f"  {j.id}: '{j.cron}' → {j.prompt[:40]} [{tag}, {dur}]")
    # 返回所有任务拼接后的字符串
    return "\n".join(lines)


# 定义取消定时任务的函数
def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
    return cancel_job(job_id)

# 定义函数,启动一个队友 agent 线程
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
    # 调用 spawn_teammate_thread 启动队友 agent,传递名字、角色和 prompt
    return spawn_teammate_thread(name, role, prompt)

# 定义函数,通过消息总线发送消息给指定对象
def run_send_message(to: str, content: str) -> str:
    # 发送方固定为当前会话身份,不可伪造
    from_agent = current_agent.get()
    # 使用 BUS 发送消息
    BUS.send(from_agent, to, content)
    if to != LEAD_NAME and not is_teammate_running(to):
        # 返回已写入收件箱但队友未运行的提示
        return (
            f"已从 {from_agent} 写入 {to} 的收件箱,但该队友未在运行。"
            f"请 spawn_teammate 重启后才会被读取。"
        )
    # 返回发送结果的字符串说明
    return f"已从 {from_agent} 发送给 {to}"


# 定义函数,仅允许读取当前 Agent 自己的收件箱(Lead 只能读 lead)
def run_check_inbox() -> str:
    # 当前会话身份
    name = current_agent.get()
    # Lead 与队友都只能消费自己的收件箱,避免抢走对方消息
    msgs = consume_inbox(name)
    # 如果收件箱消息为空,返回提示信息
    if not msgs:
        return f"({name} 的收件箱为空)"
    # 如果收件箱有消息,格式化这些消息并返回
    return format_inbox_messages(msgs)
# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
    "todo_write": run_todo_write,
    "load_skill": load_skill,  # 按名称加载技能的完整内容
    "create_task": run_create_task,  # 创建新任务
    "list_tasks": run_list_tasks,  # 列出所有任务
    "get_task": run_get_task,  # 按 ID 获取任务完整详情
    "claim_task": run_claim_task,  # 认领 pending 任务,设置 owner 并改为 in_progress
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "schedule_cron": run_schedule_cron,  # 调度定时任务
    "list_crons": run_list_crons,  # 列出所有定时任务
    "cancel_cron": run_cancel_cron,  # 取消定时任务
    "spawn_teammate": run_spawn_teammate,  # 在后台线程启动队友 Agent。
    "send_message": run_send_message,  # 通过 MessageBus 向队友发送消息。
    "check_inbox": run_check_inbox,  # 仅检查当前 Agent 自己的收件箱。
    'request_shutdown': run_request_shutdown,#请求队友优雅关闭。
    'request_plan': run_request_plan,#要求队友提交计划供审核。
    'submit_plan': run_submit_plan,#向 Lead 提交计划待审批。
    'review_plan': run_review_plan,#按 request_id 批准或拒绝已提交的计划。
}

17.5. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(
    name: str, description: str, properties: dict, required: list[str]
) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        "type": "function",
        # 定义函数的具体内容
        "function": {
            # 函数名称
            "name": name,
            # 函数描述
            "description": description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    _fn_tool(
        "bash",
        "执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。",
        {
            "command": {"type": "string"},
            "run_in_background": {"type": "boolean", "default": False},
        },
        ["command"],
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool(
        "read_file",
        "读取文件内容。",
        {"path": {"type": "string"}, "limit": {"type": "integer"}},
        ["path"],
    ),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool(
        "write_file",
        "将内容写入文件。",
        {"path": {"type": "string"}, "content": {"type": "string"}},
        ["path", "content"],
    ),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool(
        "edit_file",
        "在文件中精确替换一段文本(仅替换一次)。",
        {
            "path": {"type": "string"},
            "old_text": {"type": "string"},
            "new_text": {"type": "string"},
        },
        ["path", "old_text", "new_text"],
    ),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool(
        "glob", "按 glob 模式查找文件。", {"pattern": {"type": "string"}}, ["pattern"]
    ),  # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool(
        "send_message",
        "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
        {
            "to": {"type": "string"},
            "content": {"type": "string"},
        },
        ["to", "content"],
    ),
    _fn_tool(
        "check_inbox",
        "检查自己的收件箱(队友回信)。",
        {},
        [],
    ),
    # 定义 submit_plan 工具:向 Lead 提交计划待审批。
    _fn_tool(
        'submit_plan',
        '向 Lead 提交计划待审批。',
        {
            'from_name': {'type': 'string'},
            'plan': {'type': 'string'}
        },
        ['from_name', 'plan']
    )
]
TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "spawn_subagent",
        "启动子 Agent 处理复杂子任务。仅返回最终结论。",
        {"description": {"type": "string"}},
        ["description"],
    ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    ),
    _fn_tool(
        "compact", "摘要较早对话以释放上下文空间。", {"focus": {"type": "string"}}, []
    ),
    _fn_tool(
        "create_task",
        "创建新任务,可选 blockedBy 依赖。",
        {
            "subject": {"type": "string"},
            "description": {"type": "string"},
            "blockedBy": {"type": "array", "items": {"type": "string"}},
        },
        ["subject"],
    ),
    _fn_tool("list_tasks", "列出所有任务的状态、负责人与依赖。", {}, []),
    _fn_tool(
        "get_task",
        "按 ID 获取任务完整详情。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "claim_task",
+       "认领 pending 任务,设置 owner 并改为 in_progress。owner 为认领者 Agent 名称,默认 lead。",
+       {"task_id": {"type": "string"},'owner': {'type': 'string', 'description': '认领者 Agent 名称,默认 lead'},},
        ["task_id"],
    ),
    _fn_tool(
        "complete_task",
        "完成 in_progress 任务,并报告下游解阻任务。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "schedule_cron",
        "调度 cron 任务。cron 为 5 段:分 时 日 月 周。",
        {
            "cron": {"type": "string", "description": "5 段 cron 表达式"},
            "prompt": {"type": "string", "description": "触发时注入的消息"},
            "recurring": {"type": "boolean", "description": "true=循环,false=单次"},
            "durable": {"type": "boolean", "description": "true=持久化到磁盘"},
        },
        ["cron", "prompt"],
    ),
    _fn_tool("list_crons", "列出所有已注册的 cron 任务。", {}, []),
    _fn_tool(
        "cancel_cron",
        "按 ID 取消 cron 任务。",
        {"job_id": {"type": "string"}},
        ["job_id"],
    ),
    _fn_tool(
        "spawn_teammate",
+       "启动自主队友 Agent(idle 轮询看板、自动认领任务)。",
        {
            "name": {"type": "string"},
            "role": {"type": "string"},
            "prompt": {"type": "string"},
        },
        ["name", "role", "prompt"],
    ),
     # 定义 request_shutdown 工具:请求队友优雅关闭
    _fn_tool(
        'request_shutdown',
        '请求队友优雅关闭。',
        {'teammate': {'type': 'string'}},
        ['teammate'],
    ),
    # 定义 request_plan 工具:要求队友提交计划供审核
    _fn_tool(
        'request_plan',
        '要求队友提交计划供审核。',
        {'teammate': {'type': 'string'}, 'task': {'type': 'string'}},
        ['teammate', 'task'],
    ),
    # 定义 review_plan 工具:按 request_id 批准或拒绝已提交的计划
    _fn_tool(
        'review_plan',
        '按 request_id 批准或拒绝已提交的计划。',
        {
            'request_id': {'type': 'string'},
            'approve': {'type': 'boolean'},
            'feedback': {'type': 'string'},
        },
        ['request_id', 'approve'],
    )
]
TEAMMATE_TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    )
]

18. Worktree Isolation — 各干各的,互不干扰 #

"任务管目标,worktree 管目录" — create_worktree 建独立分支目录,认领时切 wt_ctx,并行改码互不覆盖。

本节对应教程 s18,在 s17 自治认领之上引入 目录隔离。s15–s17 解决了「谁干什么」(任务板)和「怎么通信」(MessageBus / 协议),但 Alice 与 Bob 仍在同一 WORKDIR 里 write_file("config.py")——互相覆盖,且难以分清改动归属、无法干净回滚。本节用 Git worktree 给每个任务(或每路并行)一个独立工作目录与分支。

本节要解决什么

场景 s17(同目录自治) s18(worktree)
写同一相对路径 后写覆盖先写 各写 .worktrees/{name}/ 下自己的副本
改动归属 难分谁改了什么 每 worktree 独立分支 wt/{name}
回滚 / 审查 混在主工作区 keep_worktree 留分支 review,或 remove_worktree 清理
认领之后 只设 owner / in_progress 额外设置 wt_ctx.path,工具在该 cwd 执行
路径安全 safe_path 相对 WORKDIR safe_path(p, cwd=worktree),越界相对当前 base 校验

没有 worktree,并行队友越多,文件冲突越严重;有了绑定,Lead 仍用 create_task 管目标,用 create_worktree 管「在哪干」。

核心能力

能力 作用
create_worktree(name, task_id?) git worktree add → .worktrees/{name} + 分支 wt/{name};可选绑定任务
bind_task_to_worktree 只写 task.worktree,不改 status(仍 pending,等认领)
remove_worktree 删除目录与分支;有未提交改动默认拒绝,需 discard_changes=true
keep_worktree 保留目录/分支供人工审查,记事件日志
validate_worktree_name 仅允许 [A-Za-z0-9._-]{1,64},拒绝 . / .. / 路径穿越

事件写入 .worktrees/events.jsonl(create / remove / keep),便于审计。

任务 ↔ Worktree 绑定

Lead:
  create_task("重构认证")           → task_xxx (pending, worktree=null)
  create_worktree("auth", task_xxx) → .worktrees/auth + wt/auth
                                    → task.worktree = "auth"(仍 pending)

Teammate idle:
  scan → claim(task_xxx) → in_progress, owner=alice
  若 task.worktree 有值 → wt_ctx["path"] = .worktrees/auth
  bash / read / write / edit 均以该 path 为 cwd / safe_path base

绑定与认领解耦:Lead 可先建好多对「任务+目录」,队友 idle 自动认领时才切目录——与 s17 自治自然衔接。

队友侧:wt_ctx 切换 cwd

spawn_teammate_thread
  wt_ctx = {"path": None}

认领成功且 task.worktree 存在
  → wt_ctx["path"] = str(WORKTREES_DIR / task.worktree)

execute_tool(..., cwd=wt_ctx["path"])
  → run_bash(..., cwd=...)
  → safe_path(path, cwd=...)   # 相对路径落在 worktree 内

complete_task 后
  → wt_ctx["path"] = None      # 清回,避免串到下一任务

教学版用每线程一个 wt_ctx 字典传 cwd;真实 Claude Code 更接近 process.chdir / cwdOverride。Lead 主循环默认仍在 WORKDIR,不受队友 worktree 影响。

收尾:Keep 还是 Remove

选择 行为
keep_worktree 留目录与 wt/{name},人工 review / 合并
remove_worktree 无改动可删;有未提交文件或提交时默认拒绝
discard_changes=true 强制删除(显式确认丢弃)

不自动 complete_task——任务完成仍由队友显式调用;worktree 生命周期与任务状态机正交。

相对 s17 的变化

文件 变化
worktrees.py 新增:校验、创建/绑定、keep/remove、事件日志
config.py WORKTREES_DIR = .worktrees/,启动时 mkdir
tasks.py `Task.worktree: str \ None`
teams.py wt_ctx;idle / 手动认领时切换 path;system 提示「绑定 worktree 则工具在该目录执行」
tools/handlers.py bash/读写支持 cwd;注册三个 worktree 工具
tools/schema.py create_worktree / remove_worktree / keep_worktree
utils.py safe_path(p, cwd=None),基准可为 worktree
prompt.py identity 补充并行改码目录隔离说明

s17 的看板认领、s16 协议、MessageBus 全部保留;worktree 是「在哪执行」的一层,不改「谁认领」。

试试这些 prompt:

创建两个任务,第一个任务是在README.md里添加一首诗,第二个任务是个任务在README.md里添加一道小学数学题,为这二个任务分别创建 worktree(用 task_id 绑定)。启动 alice 和 bob。观察他们自动认领任务,并在各自隔离的目录中工作。

观察重点:两个 worktree 的 git status 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?remove_worktree 对有改动的 worktree 是否拒绝?.tasks/ 中的任务在绑定后状态是否仍为 pending?

观察重点:.worktrees/ 下是否出现独立目录与 wt/* 分支?tasks/*.json 的 worktree 字段?控制台 [绑定] / [worktree] / [idle] ... 自动认领 且 auto-claimed 是否带工作目录提示?

时序图

从创建并绑定 worktree,到队友认领切换目录、写文件、收尾:

sequenceDiagram participant Lead as agent_loop (Lead) participant WT as worktrees.py participant Git as git worktree participant Tasks as .tasks/ participant TM as Teammate (alice) participant Ctx as wt_ctx participant FS as .worktrees/auth/ Lead->>Tasks: create_task("重构认证") Note over Tasks: pending, worktree=null Lead->>WT: create_worktree("auth", task_id) WT->>WT: validate_worktree_name WT->>Git: worktree add .worktrees/auth -b wt/auth Git-->>WT: ok WT->>Tasks: bind → task.worktree="auth" Note over Tasks: 仍 pending(不改 status) WT->>WT: log_event(create) → events.jsonl WT-->>Lead: worktree 已创建 Lead->>TM: spawn_teammate(alice, "待命") Note over TM,Ctx: wt_ctx.path = None TM->>TM: idle_poll → scan_unclaimed_tasks TM->>Tasks: claim_task(..., "alice") Tasks-->>TM: 已认领(in_progress, owner=alice) TM->>Ctx: path = .worktrees/auth TM->>TM: messages += auto-claimed(含工作目录) TM->>TM: WORK:write_file("config.py", ...) TM->>FS: safe_path(..., cwd=auth) 写入 Note over FS: 主仓库 WORKDIR/config.py 不被改写 TM->>Tasks: complete_task TM->>Ctx: path = None alt 保留审查 Lead->>WT: keep_worktree("auth") WT->>WT: log_event(keep) else 清理 Lead->>WT: remove_worktree("auth") WT->>Git: 检查未提交改动 alt 有改动且未 discard WT-->>Lead: 拒绝删除,提示 discard 或 keep else 可删 / discard_changes WT->>Git: worktree remove + branch -D end end

说明:绑定不推进任务状态,认领才切 wt_ctx——这样 Lead 可批量预置「任务+目录」,队友按 s17 策略自组织消费。safe_path 的 base 随 cwd 变化,防止队友用 ../ 写回主工作区。教学版依赖本机已初始化的 git 仓库;真实 CC 还有更完整的 EnterWorktree / 隔离策略。s19 将引入 MCP,把外部工具以标准协议挂进同一工具池。

18.1. worktrees.py #

worktrees.py


# worktree 隔离:创建 / 绑定 / 删除 / 保留 / 事件日志
# 引入 json 标准库
import json
# 引入正则表达式库
import re
# 引入子进程库,用于执行外部命令
import subprocess
# 引入时间模块
import time
# 引入 Path 对象用于文件路径操作
from pathlib import Path

# 从 config 模块导入主目录、worktree 目录、文本编码
from config import WORKDIR, WORKTREES_DIR, TEXT_ENCODING
# 从 tasks 模块导入任务的载入与保存函数
from tasks import load_task, save_task
# 合法 worktree 名称的正则表达式:只允许字母、数字、点、下划线、连字符,长度 1 到 64
VALID_WT_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
# 定义校验 worktree 名称的函数,类型提示:输入字符串,返回字符串或 None
def validate_worktree_name(name: str) -> str | None:
    # 校验名称;合法返回 None,非法返回错误信息。
    """校验名称;合法返回 None,非法返回错误信息。"""
    # 如果名称为空,返回错误信息
    if not name:
        return 'worktree 名称不能为空'
    # 如果名称为 . 或 ..,不是有效名称
    if name in ('.', '..'):
        return f"'{name}' 不是有效的 worktree 名称"
    # 如果名称不符合正则表达式,返回格式说明
    if not VALID_WT_NAME.match(name):
        return (
            f"无效的 worktree 名称 '{name}':"
            '仅允许字母、数字、点、下划线、连字符(1-64 字符)'
        )
    # 合法则返回 None
    return None

# 定义运行 git 命令的函数,返回(是否成功,输出内容)元组
def run_git(args: list[str]) -> tuple[bool, str]:
    # 在主仓库执行 git 命令,返回 (成功, 输出)。
    """在主仓库执行 git 命令,返回 (成功, 输出)。"""
    # 捕获异常处理超时
    try:
        # 执行 git 命令,设置当前目录、捕获输出、超时30秒
        r = subprocess.run(
            ['git'] + args,
            cwd=WORKDIR,
            capture_output=True,
            timeout=30,
        )
        # 延迟导入工具模块的解码函数
        from utils import decode_subprocess_output
        # 解码输出并合并 stdout 和 stderr,去除两侧空白
        out = decode_subprocess_output((r.stdout or b'') + (r.stderr or b'')).strip()
        # 最多截取前 5000 个字符,否则返回(无输出)
        out = out[:5000] if out else '(无输出)'
        # 返回命令是否成功及输出
        return r.returncode == 0, out
    # 捕获超时异常,返回失败和对应错误信息
    except subprocess.TimeoutExpired:
        return False, '错误:git 超时'
# 定义任务绑定到 worktree 的函数
def bind_task_to_worktree(task_id: str, worktree_name: str) -> None:
    # 仅写入 task.worktree,不改变 status(保持 pending)。
    """仅写入 task.worktree,不改变 status(保持 pending)。"""
    # 加载指定任务
    task = load_task(task_id)
    # 设置任务的 worktree 字段
    task.worktree = worktree_name
    # 保存任务
    save_task(task)
    # 打印绑定信息(黄色)
    print(f'  \x1b[33m[绑定] {task.subject} → worktree:{worktree_name}\x1b[0m')
# 定义事件日志写入函数
def log_event(event_type: str, worktree_name: str, task_id: str = '') -> None:
    # 构造事件字典
    event = {
        'type': event_type,
        'worktree': worktree_name,
        'task_id': task_id,
        'ts': time.time(),
    }
    # 定义事件日志文件路径
    events_file = WORKTREES_DIR / 'events.jsonl'
    # 以追加方式打开事件日志文件
    with open(events_file, 'a', encoding=TEXT_ENCODING) as f:
        # 写入 json 序列化的事件,末尾加换行
        f.write(json.dumps(event, ensure_ascii=False) + '\n')

# 定义创建 worktree 的函数,可同时绑定任务
def create_worktree(name: str, task_id: str = '') -> str:
    # 校验 worktree 名称
    err = validate_worktree_name(name)
    # 如果校验不通过,返回错误信息
    if err:
        return f'错误:{err}'
    # 构造 worktree 路径
    path = WORKTREES_DIR / name
    # 如果路径已存在,返回已存在的信息
    if path.exists():
        return f"worktree '{name}' 已存在于 {path}"
    # 使用 git 添加 worktree,带分支名
    ok, result = run_git(['worktree', 'add', str(path), '-b', f'wt/{name}', 'HEAD'])
    # 如果命令失败,返回 git 错误信息
    if not ok:
        return f'Git 错误:{result}'
    # 若有任务,绑定该 worktree
    if task_id:
        bind_task_to_worktree(task_id, name)
    # 记录事件日志
    log_event('create', name, task_id)
    # 打印创建信息(黄色)
    print(f'  \x1b[33m[worktree] 已创建: {name} @ {path}\x1b[0m')
    # 返回已创建的文本结果
    return f"worktree '{name}' 已创建于 {path}"

# 运行创建 worktree 的接口函数
def run_create_worktree(name: str, task_id: str = '') -> str:
    # 调用真正的创建函数
    return create_worktree(name, task_id)


# 定义查询 worktree 修改/未提交状态的函数
def _count_worktree_changes(path: Path) -> tuple[int, int]:
    # 返回 (未提交文件数, 未推送提交数);失败返回 (-1, -1)。
    """返回 (未提交文件数, 未推送提交数);失败返回 (-1, -1)。"""
    # 异常捕获处理
    try:
        # 运行 git status 获取未提交文件
        r1 = subprocess.run(
            ['git', 'status', '--porcelain'],
            cwd=path,
            capture_output=True,
            timeout=10,
        )
        # 解码状态输出
        from utils import decode_subprocess_output
        status_out = decode_subprocess_output(r1.stdout).strip()
        # 统计非空的变更行数
        files = len([line for line in status_out.splitlines() if line.strip()])
        # 运行 git log 查询未推送提交数
        r2 = subprocess.run(
            ['git', 'log', '@{push}..HEAD', '--oneline'],
            cwd=path,
            capture_output=True,
            timeout=10,
        )
        # 解码日志输出
        log_out = decode_subprocess_output(r2.stdout).strip()
        # 统计未推送提交的行数
        commits = len([line for line in log_out.splitlines() if line.strip()])
        # 返回未提交文件数与未推送提交数
        return files, commits
    # 发生异常时返回 -1, -1
    except Exception:
        return -1, -1

# 定义删除 worktree 的函数,支持强制丢弃更改
def remove_worktree(name: str, discard_changes: bool = False) -> str:
    # 校验 worktree 名称
    err = validate_worktree_name(name)
    # 校验不通过返回错误信息
    if err:
        return err
    # 构造 worktree 路径
    path = WORKTREES_DIR / name
    # 路径不存在则提示未找到
    if not path.exists():
        return f"未找到 worktree '{name}'"
    # 若不是强制丢弃,检查状态
    if not discard_changes:
        files, commits = _count_worktree_changes(path)
        # 状态不可用,提示需要强制删除
        if files < 0:
            return (
                f"无法验证 worktree '{name}' 状态。"
                '请设 discard_changes=true 强制删除。'
            )
        # 有未提交或未推送,提示需强制或保留
        if files > 0 or commits > 0:
            return (
                f"worktree '{name}' 有 {files} 个未提交文件、"
                f'{commits} 个未推送提交。'
                '请设 discard_changes=true 强制删除,'
                '或使用 keep_worktree 保留供审查。'
            )
    # 删除 worktree(目录),--force 强制
    ok1, _ = run_git(['worktree', 'remove', str(path), '--force'])
    # 删除失败则返回错误
    if not ok1:
        return f"删除 worktree '{name}' 目录失败"
    # 删除关联分支
    run_git(['branch', '-D', f'wt/{name}'])
    # 记录删除事件
    log_event('remove', name)
    # 打印删除信息(黄色)
    print(f'  \x1b[33m[worktree] 已删除: {name}\x1b[0m')
    # 返回删除结果描述
    return f"worktree '{name}' 已删除"

# 运行删除 worktree 的接口函数
def run_remove_worktree(name: str, discard_changes: bool = False) -> str:
    # 调用真正的删除函数
    return remove_worktree(name, discard_changes)


# 定义保留 worktree 供审查的函数
def keep_worktree(name: str) -> str:
    # 校验 worktree 名称
    err = validate_worktree_name(name)
    # 校验失败返回错误信息
    if err:
        return err
    # 记录保留事件
    log_event('keep', name)
    # 打印保留信息(青色)
    print(f'  \x1b[36m[worktree] 已保留: {name}\x1b[0m')
    # 返回保留结果描述
    return f"worktree '{name}' 已保留供审查(分支: wt/{name})"

# 运行保留 worktree 的接口函数
def run_keep_worktree(name: str) -> str:
    # 调用真正的保留函数
    return keep_worktree(name)

18.2. config.py #

config.py

# 导入操作系统相关的模块
import os

# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv

# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ["MODEL_ID"]
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
    base_url=os.getenv("OPENAI_BASE_URL"),
)
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# Change Code Page 设置命令行编码为UTF-8,UTF-8对应的代码页编号是65001,GBK 对应的代码页编号是 936
os.system("chcp 65001")
# 设置文本编码为UTF-8
TEXT_ENCODING = "utf-8"

# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / "skills"

# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results"
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
# 设置记忆目录为工作目录下的 .memory 目录
MEMORY_DIR = WORKDIR / ".memory"
# 创建记忆目录,如果目录不存在
MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
# 设置记忆合并阈值为10
CONSOLIDATE_THRESHOLD = 10
# 设置最大重试次数为10
MAX_RETRIES = 10
# 设置基础延迟时间为500毫秒
BASE_DELAY_MS = 500
# 定义连续发生529错误的最大次数
MAX_CONSECUTIVE_529 = 3
# 从环境变量中获取备用模型的名称
FALLBACK_MODEL = os.getenv("FALLBACK_MODEL")
# 定义升级后的最大token数
ESCALATED_MAX_TOKENS = 64000
# 定义最大恢复重试次数为3
MAX_RECOVERY_RETRIES = 3
# 定义续写提示
CONTINUATION_PROMPT = (
    "输出 token 上限已达到。直接继续 — 不要道歉或复述,从思路中断处接上。"
)
# 设置任务目录为工作目录下的 .tasks 目录
TASKS_DIR = WORKDIR / ".tasks"
# 创建任务目录,如果目录不存在
TASKS_DIR.mkdir(exist_ok=True)
# 定时任务持久化文件
DURABLE_PATH = WORKDIR / '.scheduled_tasks.json'
# 队友消息邮箱目录
MAILBOX_DIR = WORKDIR / '.mailboxes'
# 创建队友消息邮箱目录,如果目录不存在
MAILBOX_DIR.mkdir(exist_ok=True)
# git worktree 隔离目录
+WORKTREES_DIR = WORKDIR.parent / ".worktrees"
# 创建 worktree 目录,如果目录不存在
+WORKTREES_DIR.mkdir(exist_ok=True)

18.3. prompt.py #

prompt.py

from config import WORKDIR

# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR, MEMORY_INDEX, TEXT_ENCODING

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    "identity": (
        f"你是一个编程 Agent。直接行动,不要解释。"
        f"你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
        f"所有破坏性操作需要用户批准。"
        f"开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。"
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
        f"bash 支持 run_in_background 参数以在后台运行耗时命令。"
        f"定时任务可使用 schedule_cron / list_crons / cancel_cron。"
        f"遇到复杂子问题时,可使用 spawn_teammate 委派队友。"
        f"teammate团队协作可使用 spawn_teammate / send_message / check_inbox。"
        f"request_plan 要求队友 submit_plan 后,用 review_plan(request_id, approve) 批准或拒绝;"
        f"任务结束或需回收资源时用 request_shutdown 请求队友优雅退出。"
        f"团队协作:spawn_teammate 启动自主队友(idle 时轮询看板并自动认领任务);"
        f"create_task 创建任务后队友可在 idle 阶段自动认领;"
        f"send_message 向队友发消息;check_inbox 查看队友回信(含协议响应状态)。"
+       f"并行改码目录隔离:create_worktree(name, task_id?) 创建独立目录与分支;"
+       f"完成后 remove_worktree 或 keep_worktree 保留供审查。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    "workspace": f"工作目录:{WORKDIR}",
    # 'skill' 键,指明需要完整技术文档时的指引
    "skill": "需要完整技术说明时,使用 load_skill 加载相关文档。",
    # 'memory' 键,指明记忆的使用方式
    "memory": "下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。",
}


# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str, memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS["identity"], PROMPT_SECTIONS["workspace"]]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f"可用技能:\n{skills}")
        sections.append(PROMPT_SECTIONS["skill"])
        # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f"可用记忆:\n{memories}")
        sections.append(PROMPT_SECTIONS["memory"])
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return "\n\n".join(sections)


# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ""
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return "\n".join(
        f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()
    )


# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ""
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors="replace").strip()


# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None


# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt


# 定义子任务的系统提示语
SUB_SYSTEM = (
    f"你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。"
    "你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
    "完成分配给你的任务,然后返回简洁摘要。不要继续委派。"
)

18.4. tasks.py #

tasks.py

# 导入random模块用于生成随机数
import random
# 导入time模块用于获取当前时间戳
import time
# 导入json模块用于处理JSON数据
import json
# 从dataclasses模块导入dataclass和asdict用于数据结构定义和转换
from dataclasses import dataclass,asdict
# 从pathlib模块导入Path用于路径操作
from pathlib import Path
# 从config.py导入任务目录和文本编码设置
from config import TASKS_DIR, TEXT_ENCODING

# 用于表示任务的数据类
@dataclass
class Task:
    # 任务ID
    id: str
    # 任务主题
    subject: str
    # 任务描述
    description: str
    # 任务状态
    status: str
    # 任务负责人
    owner: str | None
    # 任务依赖列表
    blockedBy: list[str]
    # 绑定的 git worktree 名称
+   worktree: str | None = None

# 根据任务ID生成任务文件路径
def _task_path(task_id: str) -> Path:
    return TASKS_DIR / f'{task_id}.json'

# 保存Task对象到文件
def save_task(task: Task):
    _task_path(task.id).write_text(
        json.dumps(asdict(task), indent=2, ensure_ascii=False),
        encoding=TEXT_ENCODING,
    )

# 创建一个新的任务并保存
def create_task(subject: str, description: str = '', blockedBy: list[str] | None = None) -> Task:
    task = Task(
        # 生成唯一的任务ID
        id=f'task_{int(time.time())}_{random.randint(0, 9999):04d}',
        # 设置任务主题
        subject=subject,
        # 设置任务描述
        description=description,
        # 任务初始状态为pending
        status='pending',
        # 初始负责人为空
        owner=None,
        # 设置依赖列表,为空则默认[]
        blockedBy=blockedBy or [],
    )
    # 存储任务到文件
    save_task(task)
    # 返回任务对象
    return task

# 获取所有任务列表
def list_tasks() -> list[Task]:
    return [
        # 读取每个任务文件并转为Task对象
        Task(**json.loads(p.read_text(encoding=TEXT_ENCODING)))
        # 查找所有任务json文件,并排序
        for p in sorted(TASKS_DIR.glob('task_*.json'))
    ]

# 加载指定ID的任务
def load_task(task_id: str) -> Task:
    return Task(**json.loads(_task_path(task_id).read_text(encoding=TEXT_ENCODING)))

# 获取任务的JSON字符串
def get_task(task_id: str) -> str:
    return json.dumps(asdict(load_task(task_id)), indent=2, ensure_ascii=False)

# 判断任务是否可以开始(依赖完成且存在)
def can_start(task_id: str) -> bool:
    # 加载当前任务信息
    task = load_task(task_id)
    # 遍历所有依赖任务
    for dep_id in task.blockedBy:
        # 依赖任务文件不存在则不可开始
        if not _task_path(dep_id).exists():
            return False
        # 依赖任务未完成也不可开始
        if load_task(dep_id).status != 'completed':
            return False
    # 所有依赖都满足
    return True

# 认领任务
def claim_task(task_id: str, owner: str = 'agent') -> str:
    # 加载任务
    task = load_task(task_id)
    # 如果任务状态不是pending, 则无法认领
    if task.status != 'pending':
        return f'任务 {task_id} 状态为 {task.status},无法认领'
    # 如果任务已被认领(owner不为空),返回被谁认领的提示
    if task.owner:
        return f'任务 {task_id} 已被 {task.owner} 认领'
    # 如果任务被依赖阻塞, 则无法认领
    if not can_start(task_id):
        # 找出所有未完成的依赖
        deps = [
            d for d in task.blockedBy
            if not _task_path(d).exists() or load_task(d).status != 'completed'
        ]
        return f'被阻塞,依赖: {deps}'
    # 设置负责人
    task.owner = owner
    # 设置任务状态为进行中
    task.status = 'in_progress'
    # 保存任务
    save_task(task)
    # 在控制台输出认领提示
    print(f'  \x1b[36m[认领] {task.subject} → in_progress(负责人: {owner})\x1b[0m')
    # 返回认领结果字符串
    return f'已认领 {task.id}({task.subject})'    

# 完成任务
def complete_task(task_id: str) -> str:
    # 加载任务
    task = load_task(task_id)
    # 如果任务不是进行中状态, 则无法完成
    if task.status != 'in_progress':
        return f'任务 {task_id} 状态为 {task.status},无法完成'
    # 设置任务状态为已完成
    task.status = 'completed'
    # 保存任务
    save_task(task)
    # 查找因本任务解锁、现在可以开始的所有等待任务
    unblocked = [
        t.subject for t in list_tasks()
        if t.status == 'pending' and t.blockedBy and can_start(t.id)
    ]
    # 在控制台输出任务完成信息
    print(f'  \x1b[32m[完成] {task.subject} ✓\x1b[0m')
    # 构造返回信息
    msg = f'已完成 {task.id}({task.subject})'
    # 如果有已解锁的任务,则信息中增加这些任务
    if unblocked:
        msg += f"\n已解阻: {', '.join(unblocked)}"
        print(f"  \x1b[33m[解阻] {', '.join(unblocked)}\x1b[0m")
    # 返回最终的信息
    return msg

# 查找 pending、无 owner、依赖已全部完成的任务
# 定义一个返回未被认领且所有依赖已完成任务的函数
def scan_unclaimed_tasks() -> list[Task]:
    # 遍历所有任务,筛选出状态为pending、没有负责人且可开始的任务
    return [
        # 对每个任务t进行判断并加入返回列表
        t for t in list_tasks()
        # 筛选条件:任务状态为pending,负责人为空,依赖任务均已完成
        if t.status == 'pending' and not t.owner and can_start(t.id)
    ]

18.5. teams.py #

teams.py


# 导入json模块,用于处理JSON数据
import json
# 导入threading模块,用于多线程
import threading
# 导入time模块,用于时间处理
import time
+from pathlib import Path
# 当前 Agent 身份(Lead 主线程默认 lead;队友线程启动时设为队友名)
from contextvars import ContextVar
# 导入dataclass模块,用于定义数据类
from dataclasses import dataclass, field
# 从config模块导入常量和对象
from config import (
    WORKDIR,  # 工作目录
    client,  # 大语言模型客户端
    MODEL_ID, # 主模型名称
    DEFAULT_MAX_TOKENS,# 默认最大token数
    MAILBOX_DIR,   # 邮箱目录
+   TEXT_ENCODING, # 文本编码方式
+   WORKTREES_DIR # 工作树目录
)
# 从tools.schema模块导入队友工具列表
from tools.schema import TEAMMATE_TOOLS
# 从history模块导入repair_message_chain函数
from history import repair_message_chain
# 从utils模块导入assistant_message_dict方法
from utils import assistant_message_dict
# 从tasks模块导入scan_unclaimed_tasks, claim_task   
+from tasks import scan_unclaimed_tasks, claim_task, load_task,complete_task  
# 定义主管(lead)的名字
LEAD_NAME = "lead"
# 导入random模块,用于生成 request_id
import random
# 队友 LLM 调用最大轮次(防止无限循环)
TEAMMATE_MAX_ROUNDS = 50
# 空闲超时时间(单位:秒)
IDLE_TIMEOUT = 60
# 空闲轮询时间间隔(单位:秒)
IDLE_POLL_INTERVAL = 5
# 当前调用工具的 Agent 名称
current_agent: ContextVar[str] = ContextVar("current_agent", default="lead")
# active_teammates: 队友名 → 线程对象
active_teammates: dict[str, threading.Thread] = {}
# agent 最大工作回合数
WORK_MAX_ROUNDS = 10
# MessageBus 文件读写锁
_bus_lock = threading.Lock()
# 使用dataclass装饰器定义一个协议状态的数据结构
@dataclass
class ProtocolState:
    # 请求的唯一标识符
    request_id: str
    # 协议类型,可能为 shutdown 或 plan_approval
    type: str       # shutdown | plan_approval
    # 请求发送者
    sender: str
    # 请求目标对象
    target: str
    # 状态,可能为 pending、approved 或 rejected
    status: str     # pending | approved | rejected
    # 附加数据/信息
    payload: str
    # 创建时间,默认为当前时间
    created_at: float = field(default_factory=time.time)

# 用于存储所有挂起的协议请求,键为请求ID,值为协议状态对象
pending_requests: dict[str, ProtocolState] = {}

# 生成新的唯一请求ID
def new_request_id() -> str:
    # 随机生成6位数字,格式化为 req_xxxxxx 的字符串
    return f'req_{random.randint(0, 999999):06d}'
# 消息总线类,用于管理不同agent间消息传递
class MessageBus:
    """基于文件的消息总线。每个 Agent 一个 .jsonl 收件箱,读取即消费。"""
    # 发送消息的方法
    def send(
        self,
        from_agent: str,# 发送者
        to_agent: str,# 接收者
        content: str,# 消息内容
        msg_type: str = "message",# 消息类型
        metadata: dict | None = None, # 附加元数据,默认为 None
    ):
        # 构造消息内容的字典
        msg = {
            "from": from_agent,  # 发送者
            "to": to_agent,  # 接收者
            "content": content,  # 消息内容
            "type": msg_type,  # 消息类型
            "ts": time.time(),  # 时间戳
            'metadata': metadata or {},            # 元数据,默认为空字典
        }
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
        with _bus_lock:
            # 以追加模式写入收件箱
            with open(inbox, "a", encoding=TEXT_ENCODING) as f:
                # 将消息写为json字符串,每条一行
                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
        # 控制台打印消息发送信息
        print(
            f"  \x1b[33m[总线] {from_agent} → {to_agent}[{msg_type}]: {content[:50]}\x1b[0m"
        )

    # 读取某agent收件箱的方法(与 send 共用锁,避免读写竞态丢信)
    def read_inbox(self, agent: str) -> list[dict]:
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{agent}.jsonl"
        with _bus_lock:
            # 如果收件箱文件不存在,则返回空列表
            if not inbox.exists():
                return []
            # 读取所有消息,每行解析为json字典
            msgs = [
                json.loads(line)
                for line in inbox.read_text(encoding=TEXT_ENCODING).splitlines()
                if line.strip()
            ]
            # 读取后删除收件箱文件
            inbox.unlink()
        # 返回消息列表
        return msgs


# 实例化消息总线对象
BUS = MessageBus()

# 获取队友 LLM 上下文,只取最新 tail 条消息,并修复 tool 链
def _teammate_llm_context(messages: list, tail: int = 20) -> list:
    # 如果消息数量大于 tail,则取最后 tail 条,否则全部取
    window = messages[-tail:] if len(messages) > tail else list(messages)
    # 修复 tool 链,防止 API 错误,返回修复后的窗口消息
    return repair_message_chain(window)



# 处理队友的收件箱消息,将协议消息(如关机批复、计划审批等)和普通消息区分开
def _process_teammate_inbox(
    teammate_name: str,      # 队友名称
    inbox: list[dict],       # 收件箱消息列表
    messages: list           # 对话消息列表
) -> tuple[bool, list[dict]]:
    # 标记是否需要终止(收到关机请求)
    should_stop = False
    # 用于保存非协议消息
    non_protocol = []
    # 遍历收件箱中的每一条消息
    for msg in inbox:
        # 获取消息类型,默认为 'message'
        msg_type = msg.get('type', 'message')
        # 获取元数据字典,默认为空字典
        meta = msg.get('metadata', {})
        # 获取请求 ID,默认为空字符串
        req_id = meta.get('request_id', '')
        # 如果收到关机请求类型的协议消息
        if msg_type == 'shutdown_request':
            # 回复 Lead,说明已同意关闭
            BUS.send(
                teammate_name,            # 当前队友名称
                LEAD_NAME,                # Lead 名称
                '正在优雅关闭。',           # 消息内容
                'shutdown_response',      # 消息类型
                {'request_id': req_id, 'approve': True},  # 元数据,附带请求 ID 和批准信号
            )
            # 打印紫色的协议日志,说明已同意关闭
            print(f'  \x1b[35m[协议] {teammate_name} 已同意关闭({req_id})\x1b[0m')
            # 标记 should_stop 为 True
            should_stop = True
            # 跳出 for 循环,后续消息不再处理
            break
        # 如果收到计划审批响应
        if msg_type == 'plan_approval_response':
            # 获取是否批准
            approve = meta.get('approve', False)
            # 如果批准
            if approve:
                # 向对话消息列表添加“计划已批准”的提示消息
                messages.append({
                    'role': 'user',
                    'content': '[计划已批准] 请继续执行任务。',
                })
            else:
                # 否则添加“计划被拒绝”与反馈内容
                messages.append({
                    'role': 'user',
                    'content': f"[计划被拒绝] 反馈: {msg['content']}",
                })
            # 忽略后续代码,继续处理下条收件箱消息
            continue
        # 普通消息添加到 non_protocol 列表
        non_protocol.append(msg)
    # 返回是否需要停止循环和所有未被协议处理的普通消息
    return should_stop, non_protocol

# 空闲轮询函数,用于处理队友的空闲状态
+def idle_poll(agent_name: str, messages: list, wt_ctx: dict | None = None) -> str:
    # 轮询 IDLE_TIMEOUT 秒,分为若干小轮,每一轮暂停 IDLE_POLL_INTERVAL 秒
    for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
        # 暂停 IDLE_POLL_INTERVAL 秒
        time.sleep(IDLE_POLL_INTERVAL)

        # 读取 agent_name 的 inbox 消息
        inbox = BUS.read_inbox(agent_name)
        # 如果 inbox 非空,说明有新消息
        if inbox:
            # 遍历收件箱中的每一条消息
            for msg in inbox:
                # 判断消息类型是否为关机请求
                if msg.get('type') == 'shutdown_request':
                    # 获取该消息的 request_id,如果没有则为空字符串
                    req_id = msg.get('metadata', {}).get('request_id', '')
                    # 回复 Lead,表示已同意关闭
                    BUS.send(
                        agent_name,
                        LEAD_NAME,
                        '正在优雅关闭。',
                        'shutdown_response',
                        {'request_id': req_id, 'approve': True},
                    )
                    # 打印紫色的协议日志,表示在 idle 时同意关闭
                    print(
                        f'  \x1b[35m[协议] {agent_name} 在 idle 时同意关闭({req_id})\x1b[0m'
                    )
                    # 返回 'shutdown',表示关闭
                    return 'shutdown'

            # 将收到的 inbox 消息以 json 格式写入 messages
            messages.append({
                'role': 'user',
                'content': '<inbox>' + json.dumps(inbox, ensure_ascii=False) + '</inbox>',
            })
            # 打印收到 inbox 消息的提示
            print(f'  \x1b[36m[idle] {agent_name} 收到 inbox 消息\x1b[0m')
            # 返回 'work',表示进入工作状态
            return 'work'
         # 检查是否有未被认领的任务
        unclaimed = scan_unclaimed_tasks()
        # 如果存在未认领的任务
        if unclaimed:
            # 取第一个未认领任务
            task = unclaimed[0]
            # 尝试用 agent_name 认领该任务
            result = claim_task(task.id, agent_name)
            # 如果认领成功(返回值以"已认领"开头)
            if result.startswith('已认领'):
                # 如果任务有 worktree 字段
+               if task.worktree:
                    # 构造工作目录路径
+                   wt_path = WORKTREES_DIR / task.worktree
                    # 如果传入了 wt_ctx 则保存工作目录路径
+                   if wt_ctx is not None:
+                       wt_ctx['path'] = str(wt_path)
                # 如果没有 worktree 但 wt_ctx 存在则设置为 None
+               elif wt_ctx is not None:
+                   wt_ctx['path'] = None
                # 将自动认领的信息记入 messages
                messages.append({
                    'role': 'user',
                    'content': (
                        f'<auto-claimed>任务 {task.id}: '
                        f'{task.subject}</auto-claimed>'
                    ),
                })
                # 打印自动认领任务的绿色提示
                print(f'  \x1b[32m[idle] {agent_name} 自动认领: {task.subject}\x1b[0m')
                # 返回 'work',表示进入工作状态
                return 'work'
            # 如果认领失败,打印黄色失败提示
            print(f'  \x1b[33m[idle] {agent_name} 认领失败: {result}\x1b[0m')
    # 如果超时轮询结束仍未有新消息或任务,打印红色超时提示
    print(f'  \x1b[31m[idle] {agent_name} 超时({IDLE_TIMEOUT}s)\x1b[0m')
    # 返回 'timeout',表示空闲超时
    return 'timeout'

# 启动一个队友线程函数
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
    # 如果请求启动的名字与 Lead 名字重复,则返回错误
    if name == LEAD_NAME:
        return f"错误:不能使用保留名 '{LEAD_NAME}'"
    # 查找当前名字的队友线程是否存在
    existing = active_teammates.get(name)
    # 如果该线程已经存在并且存活,则提示已存在
    if existing and existing.is_alive():
        return f"队友 '{name}' 已存在且仍在运行"
    # 如果线程对象存在但未存活,将其从 active_teammates 移除
    if existing:
        active_teammates.pop(name, None)
        # 打印黄色日志说明旧线程被移除,可以重新启动
        print(f"  \x1b[33m[队友] {name} 旧线程已退出,允许重新启动\x1b[0m")
    # 构建 system prompt,指示 AI 队友身份及工作指令
    system = (
        f"你是 '{name}',角色为 {role}。"
        f"工作目录: {WORKDIR}。使用 Windows cmd 命令。"
        f"检查收件箱中的协议消息(shutdown_request、plan_approval_response等)。"
        f"需要 Lead 审批时,使用 submit_plan 提交计划。"
        f"使用工具完成任务。你可以从看板列出并认领任务。"
+       f"若任务绑定了 worktree,bash/read/write/edit/glob 会在该目录下执行。"
    )
    # 队友线程主执行函数
    def run():
        # 延迟导入,避免与 handlers 循环依赖
        from tools.executor import execute_tool
+       from tools.handlers import  run_bash, run_read, run_write, run_edit, run_glob
        from tools.schema import TOOLS
         # worktree 工作目录上下文(认领绑定任务后自动切换)
+       wt_ctx: dict[str, str | None] = {'path': None}
        # 定义一个函数用于获取当前 worktree 工作目录的 Path 对象,如果没有设置则返回 None
+       def _wt_cwd() -> Path | None:
            # 从 wt_ctx 字典中获取 'path' 的值
+           p = wt_ctx['path']
            # 如果 p 存在则返回 Path(p),否则返回 None
+           return Path(p) if p else None
         # 定义一个函数,用于处理队友线程的具体工具调用逻辑
+       def teammate_execute_tool(tname: str, args: dict) -> str:
            # 获取当前工作目录
+           cwd = _wt_cwd()
            # 判断工具类型是否为 'bash' 命令行
+           if tname == 'bash':
                # 执行 bash 命令行工具
+               return run_bash(
                    # 从参数获取命令字符串
+                   args.get('command', ''),
                    # 是否在后台运行
+                   run_in_background=bool(args.get('run_in_background', False)),
                    # 指定工作目录
+                   cwd=cwd,
+               )
            # 判断工具类型为 'read_file',读取文件
+           if tname == 'read_file':
                # 调用文件读取工具,带参数 limit 和工作目录
+               return run_read(args.get('path', ''), limit=args.get('limit'), cwd=cwd)
            # 判断工具类型为 'write_file',写入文件
+           if tname == 'write_file':
                # 调用文件写入工具,传入路径和内容
+               return run_write(args.get('path', ''), args.get('content', ''), cwd=cwd)
            # 判断工具类型为 'edit_file',编辑文件内容
+           if tname == 'edit_file':
                # 调用编辑文件工具,替换旧内容为新内容
+               return run_edit(
+                   args.get('path', ''),
+                   args.get('old_text', ''),
+                   args.get('new_text', ''),
+                   cwd=cwd,
+               )
            # 判断工具类型为 'glob',查找匹配文件
+           if tname == 'glob':
                # 通过模式匹配查找文件路径
+               return run_glob(args.get('pattern', ''), cwd=cwd)
            # 判断工具类型为 'claim_task',认领任务
+           if tname == 'claim_task':
                # 调用认领任务方法
+               result = claim_task(args.get('task_id', ''), owner=name)
                # 如果认领成功,则尝试切换 worktree 工作目录
+               if result.startswith('已认领'):
                    # 载入任务信息
+                   task = load_task(args['task_id'])
                    # 如果任务绑定专用 worktree,更新 worktree 目录路径
+                   wt_ctx['path'] = (
+                       str(WORKTREES_DIR / task.worktree) if task.worktree else None
+                   )
                # 返回认领结果
+               return result
            # 判断工具类型为 'complete_task',完成任务
+           if tname == 'complete_task':
                # 调用完成任务方法
+               result = complete_task(args.get('task_id', ''))
                # 完成后清空当前 worktree 路径
+               wt_ctx['path'] = None
                # 返回任务完成结果
+               return result
            # 如果是其它类型工具,通用工具执行接口
+           return execute_tool(tname, args)
        # 绑定当前线程的 Agent 身份,防止伪造  from_agent
        identity_token = current_agent.set(name)
        # 初始化消息,prompt作为第一条user消息
        messages = [{"role": "user", "content": prompt}]
        # 用于记录退出原因
        exit_reason = ""
         # try-finally 保证安全清理退出
        try:
            # 无限循环,直到线程被停止
            while True:
                # 若消息数量不超过 3 条,插入身份声明消息
                if len(messages) <= 3:
                    messages.insert(0, {
                        'role': 'user',
                        'content': (
                            f"<identity>你是 '{name}',角色: {role}。"
                            f"请继续你的工作。</identity>"
                        ),
                    })   
                # 初始化是否退出循环标志
                should_shutdown = False  
                # 进入最大循环轮数限制
                for _ in range(WORK_MAX_ROUNDS):
                    # 读取当前队友的收件箱消息
                    inbox = BUS.read_inbox(name)    
                    # 如果收件箱有消息,则进行处理
                    if inbox:
                        # 处理协议消息和非协议消息
                        should_stop, non_protocol = _process_teammate_inbox(
                            name, inbox, messages,
                        )
                        # 若收到关闭信号,则设置标志并跳出循环
                        if should_stop:
                            should_shutdown = True
                            break
                        # 如果有非协议消息,将其添加到对话消息列表
                        if non_protocol:
                            messages.append({
                                'role': 'user',
                                'content': (
                                    f'<inbox>{json.dumps(non_protocol, ensure_ascii=False)}</inbox>'
                                ),
                            })  
                    # 通过 OpenAI 客户端请求 LLM 产生回复
                    try:
                        response = client.chat.completions.create(
                            model=MODEL_ID,
                            messages=[
                                {'role': 'system', 'content': system},
                                *_teammate_llm_context(messages),
                            ],
                            tools=TOOLS,
                            max_tokens=DEFAULT_MAX_TOKENS,
                        )
                    # 捕捉 API 调用异常,记录错误与退出原因
                    except Exception as e:
                        exit_reason = f'LLM 错误: {type(e).__name__}: {e}'
                        print(f'  \x1b[31m[队友] {name} {exit_reason}\x1b[0m')
                        should_shutdown = True
                        break   
                     # 获取 assistant 角色的回复消息
                    assistant = response.choices[0].message
                    # 将 assistant 消息加入消息列表
                    messages.append(assistant_message_dict(assistant))

                    # 如果 assistant 没有调用任何工具,跳出当前大循环
                    if not assistant.tool_calls:
                        break

                    # 遍历所有工具调用,执行每一个工具
                    for tool_call in assistant.tool_calls:
                        # 获取工具名称
                        tname = tool_call.function.name
                        # 解析工具参数
                        args = json.loads(tool_call.function.arguments or '{}')
                        # 执行工具(含 worktree cwd 切换)
+                       output = teammate_execute_tool(tname, args)
                        # 回复工具调用的结果消息
                        messages.append({
                            'role': 'tool',
                            'tool_call_id': tool_call.id,
                            'content': output,
                        })
                # 如果应当退出主循环,则跳出外层 while
                if should_shutdown:
                    break     
                # 进入空闲轮询(自动认领时可设置 wt_ctx)
+               idle_result = idle_poll(name, messages, wt_ctx)
                # 如果收到关闭信号,则跳出循环
                if idle_result == 'shutdown':
                    break
                # 如果长时间未响应,设置超时退出原因
                if idle_result == 'timeout':
                    exit_reason = f'idle 超时({IDLE_TIMEOUT}s)'
                    break
            # 组织总结性回复,默认优先用 exit_reason
            summary = exit_reason or '完成。'
            # 从最后的 assistant 消息中找一条有内容的作为总结
            for msg in reversed(messages):
                if msg.get('role') == 'assistant' and msg.get('content'):
                    content = msg['content']
                    if isinstance(content, str) and content.strip():
                        summary = content
                        break

            # 向 Lead 汇报最终结果消息
            BUS.send(name, LEAD_NAME, summary, 'result')
            # 控制台输出队友结束日志
            print(f'  \x1b[32m[队友] {name} 已结束\x1b[0m')    
        finally:
            current_agent.reset(identity_token)
            active_teammates.pop(name, None)   
    # 创建线程对象,目标为 run 函数,设置为守护线程
    thread = threading.Thread(target=run, daemon=True)
    # 将该线程注册到 active_teammates 字典
    active_teammates[name] = thread
    # 启动线程
    thread.start()
    # 启动后打印青色控制台日志
    print(f"  \x1b[36m[队友] 已启动 {name},角色 {role}\x1b[0m")
    return f"队友 '{name}' 已启动,角色 {role}"         

# 将收件箱消息格式化为文本字符串(含 type / request_id,便于 review_plan)
def format_inbox_messages(msgs: list[dict]) -> str:
    lines = []
    for m in msgs:
         # 从消息字典中获取 'metadata' 字段,没有则默认为空字典
        meta = m.get('metadata', {})
        # 从 metadata 字典中获取 'request_id' 字段,没有则默认为空字符串
        req_id = meta.get('request_id', '')
        # 如果request_id存在,则格式为“[类型 req:request_id]”,否则为“[类型]”
        tag = f" [{m.get('type', 'message')} req:{req_id}]" if req_id else f" [{m.get('type', 'message')}]"
        # 生成包含来源、标签和内容(截断到前200个字符)的字符串并加入lines列表
        lines.append(f"来自 {m['from']}{tag}: {m['content'][:200]}")
    # 将所有格式化好的消息行用换行拼接,最前面加上“[收件箱]”标题,作为最终返回的字符串
    return "[收件箱]\n" + "\n".join(lines)


# 匹配响应,根据 request_id 关联并校验响应类型
def match_response(response_type: str, request_id: str, approve: bool) -> None:
    # 通过 request_id 获取协议状态
    state = pending_requests.get(request_id)
    # 未找到对应协议请求
    if not state:
        print(f'  \x1b[31m[协议] 未知 request_id: {request_id}\x1b[0m')
        return
    # 校验 shutdown 类型的请求响应类型是否正确
    if state.type == 'shutdown' and response_type != 'shutdown_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 shutdown_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 校验 plan_approval 类型的请求响应类型是否正确
    if state.type == 'plan_approval' and response_type != 'plan_approval_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 plan_approval_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 判断该请求状态是否已处理过,避免重复处理
    if state.status != 'pending':
        print(f'  \x1b[33m[协议] {request_id} 已是 {state.status},忽略重复响应\x1b[0m')
        return
    # 根据approve参数设置状态为通过或拒绝
    state.status = 'approved' if approve else 'rejected'
    # 选择显示的icon(勾或叉)
    icon = '✓' if approve else '✗'
    # 通过或拒绝对应不同颜色
    color = '32' if approve else '31'
    # 打印协议处理结果信息
    print(
        f'  \x1b[{color}m[协议] {state.type} {icon} '
        f'({request_id}: {state.status})\x1b[0m'
    )

# 定义函数,读取 Lead 收件箱。参数 route_protocol 表示是否需要路由协议响应。
def consume_lead_inbox(route_protocol: bool = True) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(LEAD_NAME)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 如果需要路由协议响应
    if route_protocol:
        # 遍历所有消息
        for msg in msgs:
            # 从消息中获取 metadata,默认为空字典
            meta = msg.get('metadata', {})
            # 从 metadata 中获取 request_id,默认为空字符串
            req_id = meta.get('request_id', '')
            # 获取消息类型
            msg_type = msg.get('type', '')
            # 如果有 request_id 且消息类型以 "_response" 结尾
            if req_id and msg_type.endswith('_response'):
                # 从 metadata 中获取 approve 字段,默认为 False
                approve = meta.get('approve', False)
                # 调用 match_response 方法,路由协议响应
                match_response(msg_type, req_id, approve)
    # 返回读取到的所有消息
    return msgs
    s
# 注入lead的收件箱消息到对话消息列表
def inject_lead_inbox(messages: list) -> int:
    # 调用consume_lead_inbox读取lead收件箱消息,开启路由协议
    inbox = consume_lead_inbox(route_protocol=True)
    # 如果没有消息,返回0
    if not inbox:
        return
    # 把收件箱内容格式化为一条user消息,添加到对话消息列表
    messages.append({'role': 'user', 'content': format_inbox_messages(inbox)})
    # 控制台打印注入了多少条消息
    print(f'  \x1b[33m[收件箱] 已注入 {len(inbox)} 条消息\x1b[0m')

# 判断指定名字的队友线程是否在运行
def is_teammate_running(name: str) -> bool:
    # 从 active_teammates 字典中获取指定名字的线程对象
    thread = active_teammates.get(name)
    # 判断线程对象是否存在且线程是否存活
    return thread is not None and thread.is_alive()


# 定义函数,读取 Lead 收件箱。
def consume_inbox(agent_name: str) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(agent_name)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # 返回读取到的所有消息
    return msgs

# 请求优雅关闭某队友,向其发起关机协议消息
def run_request_shutdown(teammate: str) -> str:
    # 生成新的唯一请求 ID
    req_id = new_request_id()
    # 在 pending_requests 字典中记录关机请求的 protocol 状态
    pending_requests[req_id] = ProtocolState(
        request_id=req_id,   # 请求编号
        type='shutdown',     # 协议类型
        sender=LEAD_NAME,    # 发起者为 Lead
        target=teammate,     # 目标为指定队友名
        status='pending',    # 当前状态为等待处理
        payload='',          # 没有关联负载
    )
    # 向队友发送关机请求消息,包括元数据中的请求 ID
    BUS.send(LEAD_NAME, teammate, '请优雅关闭。', 'shutdown_request', {'request_id': req_id})
    # 打印带颜色的控制台日志,显示已发送关机请求
    print(f'  \x1b[35m[协议] shutdown_request → {teammate}({req_id})\x1b[0m')
    # 正常情况下返回已发送请求的说明
    return f'已向 {teammate} 发送关闭请求(req: {req_id})'

# 要求队友提交计划,即发消息让队友编写任务计划
def run_request_plan(teammate: str, task: str) -> str:
    # 向队友收件箱发送请求,要求其提交计划,消息类型为普通 message
    BUS.send(LEAD_NAME, teammate, f'请提交计划: {task}', 'message')
    # 否则返回已成功请求队友提交计划
    return f'已要求 {teammate} 提交计划'


# 队友向 Lead 提交计划以待审批
def run_submit_plan(from_name: str, plan: str) -> str:
    # 函数说明文档
    """队友向 Lead 提交计划待审批。"""
    # 生成计划审批请求的新 request_id
    req_id = new_request_id()
    # 在 pending_requests 保存本次请求的状态对象
    pending_requests[req_id] = ProtocolState(
        request_id=req_id,      # 当前 request_id
        type='plan_approval',   # 协议类型为“计划审批”
        sender=from_name,       # 谁发的
        target=LEAD_NAME,       # 发给 Lead
        status='pending',       # 当前状态为等待审批
        payload=plan,           # 计划内容
    )
    # 通过 BUS 发送计划审批请求协议消息,content 是计划内容
    BUS.send(from_name, LEAD_NAME, plan, 'plan_approval_request', {'request_id': req_id})
    # 返回提示文本,包含 request_id
    return f'计划已提交({req_id})。等待审批...'


# Lead 对队友提交的计划进行审批(批准或拒绝),并进行响应
def run_review_plan(request_id: str, approve: bool, feedback: str = '') -> str:
    # 从 pending_requests 字典中查找请求状态对象
    state = pending_requests.get(request_id)
    # 如果找不到该请求,返回提示
    if not state:
        return f'未找到请求 {request_id}'
    # 如果该请求已经不在待处理状态,说明已操作过,返回对应状态
    if state.status != 'pending':
        return f'请求 {request_id} 已是 {state.status}'
    # 根据 approve 设定当前请求的最终状态
    state.status = 'approved' if approve else 'rejected'
    # 向队友发回计划审批协议响应,带上审批反馈和结果
    BUS.send(
        LEAD_NAME,#发送者
        state.sender,#接收者
        feedback or ('已批准' if approve else '已拒绝'),#消息内容
        'plan_approval_response',#消息类型
        {'request_id': request_id, 'approve': approve},#元数据
    )
    # 设定审批通过或者拒绝的标志字符
    icon = '✓' if approve else '✗'
    # 控制台输出审批过程日志,带颜色
    print(f'  \x1b[32m[协议] 计划 {icon}({request_id})\x1b[0m')
    # 返回描述审批结果的字符串
    return f"计划{'已批准' if approve else '已拒绝'}({request_id})"

18.6. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入操作系统相关模块
import glob as g
# 导入subprocess模块,用于执行子进程
import subprocess

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output, safe_path

# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING, WORKDIR

# 从config模块导入文本编码配置
from config import TEXT_ENCODING, WORKDIR

# 从skills模块导入load_skill函数
from skills import load_skill

# 从tasks模块导入create_task函数
from tasks import create_task, list_tasks, get_task, claim_task, complete_task

# 从cron模块导入schedule_job, cancel_job, scheduled_jobs, cron_lock函数
from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock
+from pathlib import Path
# 导入操作系统相关模块
import glob as g

+from worktrees import (
+   run_create_worktree,
+   run_remove_worktree,
+   run_keep_worktree,
+)
# 从 teams 模块导入 spawn_teammate_thread,BUS, LEAD_NAME, format_inbox_messages
from teams import (spawn_teammate_thread,current_agent,BUS,LEAD_NAME,is_teammate_running,format_inbox_messages,consume_inbox,
run_request_shutdown,
run_request_plan,#要求队友提交计划供审核。
run_submit_plan,#向 Lead 提交计划待审批。
run_review_plan,#按 request_id 批准或拒绝已提交的计划。
)

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
+def run_bash(command: str, run_in_background: bool = False, cwd: Path | None = None) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == "nt" and command.strip().lower() == "date":
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = "date /t & time /t"
    # 定义危险命令的列表
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return "错误:危险命令已被拦截"
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,  # 要执行的命令
            shell=True,  # 在shell中执行
+           cwd=str(cwd) if cwd else os.getcwd(),  # 当前工作目录设置为当前路径
            capture_output=True,  # 捕获标准输出和标准错误
            timeout=120,  # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b"") + (r.stderr or b"")).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else "(无输出)"
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return "错误:超时(120 秒)"
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f"错误:{e}"


# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
+def run_read(path: str, limit: int | None = None, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
+       lines = safe_path(path, cwd).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f"...(还有 {len(lines) - limit} 行)"]
        # 将行列表拼接为字符串并返回
        return "\n".join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义写文件函数,参数为路径和内容
+def run_write(path: str, content: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
+       file_path = safe_path(path, cwd)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f"已写入 {len(content)} 字节到 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
+def run_edit(path: str, old_text: str, new_text: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
+       file_path = safe_path(path, cwd)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f"错误:在 {path} 中未找到指定文本"
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(
            text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING
        )
        # 返回编辑成功的提示
        return f"已编辑 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义glob通配符路径匹配函数,参数为模式
+def run_glob(pattern: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 使用cwd作为根目录查找pattern,未指定时回退到WORKDIR
+       root = cwd if cwd is not None else WORKDIR
        # 遍历所有匹配到的路径,根目录为WORKDIR
+       for match in g.glob(pattern, root_dir=root):
            # 检查匹配到的路径是否相对WORKDIR安全
+           if (root / match).resolve().is_relative_to(root):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return "\n".join(results) if results else "(无匹配)"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
CURRENT_TODOS: list[dict] = []


# 定义run_todo_write函数,参数为todos列表,返回字符串
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历todos列表,获取每个任务及其索引
    for i, t in enumerate(todos):
        # 如果任务中缺少content或status字段
        if "content" not in t or "status" not in t:
            # 返回错误提示,指出缺少字段的位置
            return f"错误:todos[{i}] 缺少 content 或 status"
        # 如果任务的status不是允许的三种状态
        if t["status"] not in ("pending", "in_progress", "completed"):
            # 返回错误提示,指出状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 校验全部通过后,更新全局任务列表
    CURRENT_TODOS = todos
    # 初始化显示用的lines列表,第一行为标题,并加黄颜色
    lines = ["\n\x1b[33m## 当前任务\x1b[0m"]
    # 遍历所有当前任务
    for t in CURRENT_TODOS:
        # 根据任务状态,选择不同的彩色标签
        icon = {
            "pending": "\x1b[33m等待中\x1b[0m",
            "in_progress": "\x1b[36m处理中\x1b[0m",
            "completed": "\x1b[32m已完成\x1b[0m",
        }[t["status"]]
        # 将格式化后的任务内容和标签加入lines
        lines.append(f"  [{icon}] {t['content']}")
    # 将所有内容组合成字符串打印到标准输出
    print("\n".join(lines))
    # 返回已更新任务数的字符串提示
    return f"已更新 {len(CURRENT_TODOS)} 个任务"


# 定义run_create_task函数,用于创建新任务
def run_create_task(
    # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
    subject: str,
    description: str = "",
    blockedBy: list[str] | None = None,
    # 函数返回类型为字符串
) -> str:
    # 调用create_task函数创建任务对象
    task = create_task(subject, description, blockedBy)
    # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
    # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
    # 返回已创建任务的ID、主题及依赖信息提示
    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
def run_list_tasks() -> str:
    # 调用list_tasks获取所有任务列表
    tasks = list_tasks()
    # 如果任务列表为空
    if not tasks:
        # 返回暂无任务的提示信息
        return "暂无任务。使用 create_task 添加。"
    # 初始化用于存储显示行的空列表
    lines = []
    # 遍历所有任务
    for t in tasks:
        # 根据任务状态获取对应的中文状态标签
        icon = {
            # pending状态对应“等待中”
            "pending": "等待中",
            # in_progress状态对应“处理中”
            "in_progress": "处理中",
            # completed状态对应“已完成”
            "completed": "已完成",
            # 按任务状态取值,未知状态则返回问号
        }.get(t.status, "?")
        # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
        # 若任务有负责人则格式化负责人信息,否则为空字符串
        owner = f" [{t.owner}]" if t.owner else ""
        # 如果有绑定 worktree,追加显示
+       wt = f' (wt:{t.worktree})' if t.worktree else ''
        # 拼接每条任务信息并加入结果列表
+       lines.append(f'  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}{wt}')
    # 将所有行用换行符拼接成字符串后返回
    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
def run_get_task(task_id: str) -> str:
    # 尝试获取指定ID的任务
    try:
        # 调用get_task返回任务详情
        return get_task(task_id)
    # 捕获任务文件不存在的异常
    except FileNotFoundError:
        # 返回未找到任务的错误提示
        return f"错误:未找到任务 {task_id}"


# 定义run_claim_task函数,认领指定任务,返回字符串
def run_claim_task(task_id: str, owner: str = "lead") -> str:
    # 以agent为负责人认领该任务并返回结果
    return claim_task(task_id, owner=owner or LEAD_NAME)


# 定义run_complete_task函数,完成指定任务,返回字符串
def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
    return complete_task(task_id)


# 定义调度定时(cron)任务的函数
def run_schedule_cron(
    cron: str,  # cron表达式
    prompt: str,  # 提示词
    recurring: bool = True,  # 是否循环
    durable: bool = True,  # 是否持久化
) -> str:  # 返回结果
    # 调用 schedule_job 安排定时任务,返回结果
    result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
    if isinstance(result, str):
        # 返回错误提示
        return f"错误:{result}"
    # 返回调度成功信息,包括 id、表达式和 prompt
    return f"已调度 {result.id}: '{cron}' → {prompt}"


# 定义列出所有 cron 定时任务的函数
def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
    with cron_lock:
        jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
    if not jobs:
        return "暂无 cron 任务。使用 schedule_cron 添加。"
    # 初始化结果字符串列表
    lines = []
    # 遍历所有定时任务
    for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
        tag = "循环" if j.recurring else "单次"
        # 根据 durable 标记区分“持久化”或“会话”
        dur = "持久化" if j.durable else "会话"
        # 拼接任务的信息字符串并加入列表
        lines.append(f"  {j.id}: '{j.cron}' → {j.prompt[:40]} [{tag}, {dur}]")
    # 返回所有任务拼接后的字符串
    return "\n".join(lines)


# 定义取消定时任务的函数
def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
    return cancel_job(job_id)

# 定义函数,启动一个队友 agent 线程
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
    # 调用 spawn_teammate_thread 启动队友 agent,传递名字、角色和 prompt
    return spawn_teammate_thread(name, role, prompt)

# 定义函数,通过消息总线发送消息给指定对象
def run_send_message(to: str, content: str) -> str:
    # 发送方固定为当前会话身份,不可伪造
    from_agent = current_agent.get()
    # 使用 BUS 发送消息
    BUS.send(from_agent, to, content)
    if to != LEAD_NAME and not is_teammate_running(to):
        # 返回已写入收件箱但队友未运行的提示
        return (
            f"已从 {from_agent} 写入 {to} 的收件箱,但该队友未在运行。"
            f"请 spawn_teammate 重启后才会被读取。"
        )
    # 返回发送结果的字符串说明
    return f"已从 {from_agent} 发送给 {to}"


# 定义函数,仅允许读取当前 Agent 自己的收件箱(Lead 只能读 lead)
def run_check_inbox() -> str:
    # 当前会话身份
    name = current_agent.get()
    # Lead 与队友都只能消费自己的收件箱,避免抢走对方消息
    msgs = consume_inbox(name)
    # 如果收件箱消息为空,返回提示信息
    if not msgs:
        return f"({name} 的收件箱为空)"
    # 如果收件箱有消息,格式化这些消息并返回
    return format_inbox_messages(msgs)
# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
    "todo_write": run_todo_write,
    "load_skill": load_skill,  # 按名称加载技能的完整内容
    "create_task": run_create_task,  # 创建新任务
    "list_tasks": run_list_tasks,  # 列出所有任务
    "get_task": run_get_task,  # 按 ID 获取任务完整详情
    "claim_task": run_claim_task,  # 认领 pending 任务,设置 owner 并改为 in_progress
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "schedule_cron": run_schedule_cron,  # 调度定时任务
    "list_crons": run_list_crons,  # 列出所有定时任务
    "cancel_cron": run_cancel_cron,  # 取消定时任务
    "spawn_teammate": run_spawn_teammate,  # 在后台线程启动队友 Agent。
    "send_message": run_send_message,  # 通过 MessageBus 向队友发送消息。
    "check_inbox": run_check_inbox,  # 仅检查当前 Agent 自己的收件箱。
    'request_shutdown': run_request_shutdown,#请求队友优雅关闭。
    'request_plan': run_request_plan,#要求队友提交计划供审核。
    'submit_plan': run_submit_plan,#向 Lead 提交计划待审批。
    'review_plan': run_review_plan,#按 request_id 批准或拒绝已提交的计划。
+   'create_worktree': run_create_worktree,#创建隔离 git worktree
+   'remove_worktree': run_remove_worktree,#删除 worktree
+   'keep_worktree': run_keep_worktree,#保留 worktree 供审查
}

18.7. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(
    name: str, description: str, properties: dict, required: list[str]
) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        "type": "function",
        # 定义函数的具体内容
        "function": {
            # 函数名称
            "name": name,
            # 函数描述
            "description": description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    _fn_tool(
        "bash",
        "执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。",
        {
            "command": {"type": "string"},
            "run_in_background": {"type": "boolean", "default": False},
        },
        ["command"],
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool(
        "read_file",
        "读取文件内容。",
        {"path": {"type": "string"}, "limit": {"type": "integer"}},
        ["path"],
    ),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool(
        "write_file",
        "将内容写入文件。",
        {"path": {"type": "string"}, "content": {"type": "string"}},
        ["path", "content"],
    ),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool(
        "edit_file",
        "在文件中精确替换一段文本(仅替换一次)。",
        {
            "path": {"type": "string"},
            "old_text": {"type": "string"},
            "new_text": {"type": "string"},
        },
        ["path", "old_text", "new_text"],
    ),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool(
        "glob", "按 glob 模式查找文件。", {"pattern": {"type": "string"}}, ["pattern"]
    ),  # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool(
        "send_message",
        "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
        {
            "to": {"type": "string"},
            "content": {"type": "string"},
        },
        ["to", "content"],
    ),
    _fn_tool(
        "check_inbox",
        "检查自己的收件箱(队友回信)。",
        {},
        [],
    ),
    # 定义 submit_plan 工具:向 Lead 提交计划待审批。
    _fn_tool(
        'submit_plan',
        '向 Lead 提交计划待审批。',
        {
            'from_name': {'type': 'string'},
            'plan': {'type': 'string'}
        },
        ['from_name', 'plan']
    )
]
TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "spawn_subagent",
        "启动子 Agent 处理复杂子任务。仅返回最终结论。",
        {"description": {"type": "string"}},
        ["description"],
    ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    ),
    _fn_tool(
        "compact", "摘要较早对话以释放上下文空间。", {"focus": {"type": "string"}}, []
    ),
    _fn_tool(
        "create_task",
        "创建新任务,可选 blockedBy 依赖。",
        {
            "subject": {"type": "string"},
            "description": {"type": "string"},
            "blockedBy": {"type": "array", "items": {"type": "string"}},
        },
        ["subject"],
    ),
    _fn_tool("list_tasks", "列出所有任务的状态、负责人与依赖。", {}, []),
    _fn_tool(
        "get_task",
        "按 ID 获取任务完整详情。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "claim_task",
        "认领 pending 任务,设置 owner 并改为 in_progress。owner 为认领者 Agent 名称,默认 lead。",
        {"task_id": {"type": "string"},'owner': {'type': 'string', 'description': '认领者 Agent 名称,默认 lead'},},
        ["task_id"],
    ),
    _fn_tool(
        "complete_task",
        "完成 in_progress 任务,并报告下游解阻任务。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "schedule_cron",
        "调度 cron 任务。cron 为 5 段:分 时 日 月 周。",
        {
            "cron": {"type": "string", "description": "5 段 cron 表达式"},
            "prompt": {"type": "string", "description": "触发时注入的消息"},
            "recurring": {"type": "boolean", "description": "true=循环,false=单次"},
            "durable": {"type": "boolean", "description": "true=持久化到磁盘"},
        },
        ["cron", "prompt"],
    ),
    _fn_tool("list_crons", "列出所有已注册的 cron 任务。", {}, []),
    _fn_tool(
        "cancel_cron",
        "按 ID 取消 cron 任务。",
        {"job_id": {"type": "string"}},
        ["job_id"],
    ),
    _fn_tool(
        "spawn_teammate",
        "启动自主队友 Agent(idle 轮询看板、自动认领任务)。",
        {
            "name": {"type": "string"},
            "role": {"type": "string"},
            "prompt": {"type": "string"},
        },
        ["name", "role", "prompt"],
    ),
     # 定义 request_shutdown 工具:请求队友优雅关闭
    _fn_tool(
        'request_shutdown',
        '请求队友优雅关闭。',
        {'teammate': {'type': 'string'}},
        ['teammate'],
    ),
    # 定义 request_plan 工具:要求队友提交计划供审核
    _fn_tool(
        'request_plan',
        '要求队友提交计划供审核。',
        {'teammate': {'type': 'string'}, 'task': {'type': 'string'}},
        ['teammate', 'task'],
    ),
    # 定义 review_plan 工具:按 request_id 批准或拒绝已提交的计划
    _fn_tool(
        'review_plan',
        '按 request_id 批准或拒绝已提交的计划。',
        {
            'request_id': {'type': 'string'},
            'approve': {'type': 'boolean'},
            'feedback': {'type': 'string'},
        },
        ['request_id', 'approve'],
+   ),
+   _fn_tool(
+       'create_worktree',
+       '创建隔离的 git worktree 及独立分支 wt/{name}。可选 task_id 绑定任务(不改任务状态)。',
+       {
+           'name': {'type': 'string', 'description': 'worktree 名称,仅 [A-Za-z0-9._-]{1,64}'},
+           'task_id': {'type': 'string', 'description': '可选,绑定到该任务'},
+       },
+       ['name'],
+   ),
+   _fn_tool(
+       'remove_worktree',
+       '删除 worktree。有未提交变更时拒绝,除非 discard_changes=true。',
+       {
+           'name': {'type': 'string'},
+           'discard_changes': {'type': 'boolean', 'description': '强制丢弃未提交改动'},
+       },
+       ['name'],
+   ),
+   _fn_tool(
+       'keep_worktree',
+       '保留 worktree 供人工审查(不删除目录与分支)。',
+       {'name': {'type': 'string'}},
+       ['name'],
+   ),
]
TEAMMATE_TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    )
]

18.8. utils.py #

utils.py

# 从 pathlib 库中导入 Path 类,用于管理和操作文件路径
from pathlib import Path

# 从 config 模块中导入 WORKDIR 变量,表示工作目录路径
from config import WORKDIR


# 定义一个函数assistant_message_dict,参数为message,返回一个字典
def assistant_message_dict(message) -> dict:
    # 使用model_dump方法转换message对象为字典,排除值为None的项
    data = message.model_dump(exclude_none=True)
    # 将字典中的'role'字段设置为'assistant'
    data["role"] = "assistant"
    # 返回处理后的字典
    return data


# 定义一个函数decode_subprocess_output,参数为data(字节类型或None),返回字符串类型
def decode_subprocess_output(data: bytes | None) -> str:
    # 如果data为None或者为空字节,则返回空字符串
    if not data:
        return ""
    # 依次尝试三种编码方式进行解码
    for encoding in ("utf-8", "gbk", "cp936"):
        try:
            # 使用当前编码方式尝试解码,成功则返回结果
            return data.decode(encoding)
        # 如果解码时出现UnicodeDecodeError,则继续尝试下一个编码
        except UnicodeDecodeError:
            continue
    # 如果以上编码都无法解码,则使用utf-8编码并使用replace策略处理错误,并返回结果
    return data.decode("utf-8", errors="replace")


# 定义一个名为safe_path的函数,接收一个字符串参数p,返回值类型为Path
+def safe_path(p: str, cwd: Path | None = None) -> Path:
+   base = cwd or WORKDIR
    # 通过将WORKDIR与p拼接,并调用resolve方法,获得绝对路径对象
+   path = (base / p).resolve()
    # 判断path路径是否在WORKDIR工作区内,如果不是则抛出异常
+   if not path.is_relative_to(base.resolve()):
        # 抛出ValueError异常,提示路径超出工作区
        raise ValueError(f"路径超出工作区:{p}")
    # 返回最终安全生成的路径对象
    return path


# 定义一个extract_text函数,参数为content,返回字符串类型
def extract_text(content) -> str:
    # 如果content为None,返回空字符串
    if content is None:
        return ""
    # 如果content是字符串类型,直接返回
    if isinstance(content, str):
        return content
    # 否则,将content转换为字符串后返回
    return str(content)


# 定义parse_frontmatter函数,参数为text,返回一个元组(字典,字符串)
def parse_frontmatter(text: str) -> tuple[dict, str]:
    # 如果text不是以'---'开头,则直接返回空字典和原始文本
    if not text.startswith("---"):
        return {}, text
    # 用'---'分割文本,最多分割2次,得到3段内容
    parts = text.split("---", 2)
    # 如果分割出来的部分不足3个,说明无有效frontmatter,返回空字典和原始文本
    if len(parts) < 3:
        return {}, text
    # 新建一个空字典,用于存储frontmatter的键值对
    meta = {}
    # 遍历frontmatter内容区域的每一行
    for line in parts[1].strip().splitlines():
        # 如果该行包含冒号,认为是key:value格式
        if ":" in line:
            # 以冒号分割该行成键和值(只分一次)
            k, v = line.split(":", 1)
            # 去掉键和值两端空白,并将值两端的引号去除,存入字典
            meta[k.strip()] = v.strip().strip('"').strip("'")
    # 返回已解析好的meta字典和去除空白后的正文内容
    return meta, parts[2].strip()


# 定义一个llm_text函数,接收response对象,返回字符串类型
def llm_text(response) -> str:
    # 获取response的第一个choice的message的content字段,如果为空则用'',去除首尾空白后返回
    return (response.choices[0].message.content or "").strip()


# 定义一个message_text函数,接收一个字典类型的msg参数,返回字符串
def message_text(msg: dict) -> str:
    # 从msg字典中获取'content'字段,若没有则默认为空字符串
    content = msg.get("content", "")
    # 如果content是字符串类型,直接返回
    if isinstance(content, str):
        return content
    # 否则将content转换为字符串类型返回
    return str(content)

19. MCP Tools — 外接工具,标准协议 #

"外接工具,标准协议" — connect_mcp 发现外部能力,assemble_tool_pool 拼进同一工具池,模型只看到 mcp__server__tool。

本节对应教程 s19,在 s18 worktree 之上引入 MCP(Model Context Protocol)插件层。s01–s18 的工具全是手写 builtin(bash、读写、任务、团队、worktree)。若要接 Jira / 部署系统 / 文档库,为每个服务再写一套 handler 不可扩展。本节用标准「发现 + 调用」协议:外部服务实现 tools/list 与 tools/call,Agent 即可动态接入,无需关心服务用什么语言写。

本节要解决什么

场景 s18(仅 builtin) s19(MCP)
工具来源 代码里写死的 TOOLS / TOOL_HANDLERS builtin + 运行时发现的外部工具
扩展方式 改仓库、加 handler connect_mcp(name) 连接 server
命名冲突 全局唯一手写名 mcp__{server}__{tool} 前缀隔离
调用路径 execute_tool → 固定字典 同一入口,handlers 池可含 MCP 闭包
模型可见性 固定工具列表 每轮 assemble_tool_pool;连接后立即重建

没有 MCP,外部能力永远要「翻译」成手写工具;有了 MCP,Harness 只负责连接与组装,模型不需要知道工具是谁实现的。

核心概念

概念 作用
MCPClient Agent 端:模拟 tools/list(register)与 tools/call(call_tool)
MOCK_SERVERS 教学用 mock:docs(search / get_version)、deploy(trigger / status)
connect_mcp 按名连接 server,写入 mcp_clients,返回已发现工具列表
normalize_mcp_name 非法字符 → _,避免注入与命名冲突
assemble_tool_pool TOOLS + 所有已连接 MCP → (tools, handlers)
connected_mcp_summary 追加到 system prompt,列出当前 mcp__… 工具

教学版用 Python mock handler 代替真实 stdio JSON-RPC 子进程,便于无外部依赖跑通全流程。

命名与组装

connect_mcp("docs")
  → mcp_clients["docs"] = MCPClient(search, get_version)

assemble_tool_pool():
  tools    = BUILTIN ∪ [
    mcp__docs__search,
    mcp__docs__get_version,
    ...
  ]
  handlers = BUILTIN ∪ {
    "mcp__docs__search": λ **kw → client.call_tool("search", kw),
    ...
  }

前缀规则:mcp__{normalize(server)}__{normalize(tool)}。description 可带「(只读)」等标注,便于权限/提示区分;真实 CC 用结构化 tool annotations。

挂进主循环的两处关键点

agent_loop 每轮开头:
  tools, handlers = assemble_tool_pool()     # 动态池
  system = get_system_prompt()
  system += connected_mcp_summary()          # 让模型知道已连接哪些

call_llm(..., tools=tools)                   # 不再写死 TOOLS

执行工具:
  execute_tool(name, args, handlers=handlers)
  # MCP handler 含 **kwargs → 透传全部参数

若本轮调用了 connect_mcp:
  tools, handlers = assemble_tool_pool()     # 同轮后续 / 下一轮立刻可用

工具池一变,旧的「固定 tools 列表」缓存就失效——因此每轮重建池,并把 MCP 摘要打进 system,避免模型仍以为只有 builtin。

权限与队友范围(教学简化)

角色 工具集
Lead builtin + connect_mcp + 已连接的 mcp__*
Teammate 仍用固定精简子集(bash / 读写 / 消息 / 任务等),不含 MCP

真实 Claude Code 中子 Agent 可继承父级 MCP 配置;本节刻意只给 Lead,降低演示复杂度。

相对 s18 的变化

文件 变化
mcp.py 新增:MCPClient、mock server、connect_mcp、assemble_tool_pool、connected_mcp_summary
agent.py 每轮组装工具池;注入 MCP 摘要;connect_mcp 后立即重建;call_llm / execute_tool 传入动态 pool
llm.py call_llm(..., tools=None),可覆盖默认 TOOLS
tools/executor.py execute_tool(..., handlers=);**kwargs 透传支持 MCP 动态 schema
tools/handlers.py / schema.py 注册 connect_mcp
prompt.py identity 说明外部工具与 mcp__ 前缀

s18 的 worktree、s17 自治、s16 协议 全部保留;MCP 是工具池的动态扩展层,不改循环骨架。

试试这些 prompt:

  1. 连接到 docs MCP 服务器,搜索 authentication,再查 API 版本。
  2. 连接 deploy,查看 api-gateway 状态,然后触发部署。
  3. 同时连接 docs 和 deploy,列出当前所有可用工具(含 mcp__ 前缀)。

观察重点:控制台 [mcp] 已连接: ...?下一轮 LLM 的 tool_call 是否出现 mcp__docs__search?两个 server 能否并存?execute_tool 是否把参数透传到 mock handler?

时序图

从连接 MCP 到模型调用带前缀的外部工具:

sequenceDiagram participant User as 用户 participant Lead as agent_loop participant Pool as assemble_tool_pool participant MCP as mcp.py / MCPClient participant LLM as call_llm participant Exec as execute_tool User->>Lead: 连接 docs 并搜索 authentication Lead->>Pool: assemble_tool_pool() Pool-->>Lead: tools=BUILTIN(尚无 MCP) Lead->>LLM: tools=BUILTIN + connect_mcp LLM-->>Lead: tool_call: connect_mcp("docs") Lead->>MCP: connect_mcp("docs") MCP->>MCP: MOCK_SERVERS["docs"]() MCP->>MCP: register(search, get_version) MCP->>MCP: mcp_clients["docs"] = client MCP-->>Lead: 已连接,发现 search, get_version Lead->>Pool: assemble_tool_pool()(同轮重建) Pool-->>Lead: 含 mcp__docs__search 等 Lead->>LLM: 下一轮 / 同轮后续,tools 含 mcp__* Note over Lead: system += connected_mcp_summary() LLM-->>Lead: tool_call: mcp__docs__search(query=...) Lead->>Exec: execute_tool(..., handlers=动态池) Exec->>MCP: client.call_tool("search", args) MCP-->>Exec: [docs] 找到 3 条... Exec-->>Lead: tool_result Lead->>LLM: 继续推理 / 回复用户

说明:教学版用 mock 代替真实 MCP transport(stdio / SSE 等);名称一律经 normalize_mcp_name。execute_tool 对含 **kwargs 的 handler 透传全部参数,才能适配外部动态 schema。连接后必须重建工具池,否则模型下一轮仍看不到新工具。s20 将补上 Lead 收尾屏障,避免队友 result 迟到时本轮收不到。

19.1. mcp.py #

mcp.py

# 导入正则表达式模块
import re
# 导入类型提示 Callable,用于表示可调用对象
from typing import Callable

# 已连接的 MCP 客户端集合,键为字符串,值为 MCPClient 实例
mcp_clients: dict[str, 'MCPClient'] = {}

# 定义非法字符的正则表达式(非 a-zA-Z0-9_-),用于名称标准化
_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')


# 定义 MCPClient 类,在 MCP 服务器上发现和调用工具
class MCPClient:
    """在 MCP 服务器上发现并调用工具。"""

    # 初始化方法,传入客户端名称
    def __init__(self, name: str):
        # 保存客户端名称
        self.name = name
        # 初始化工具列表,元素为字典
        self.tools: list[dict] = []
        # 初始化工具处理函数字典,键为工具名,值为处理函数
        self._handlers: dict[str, Callable] = {}

    # 注册方法,注册工具定义和处理函数
    def register(self, tool_defs: list[dict], handlers: dict[str, Callable]) -> None:
        """ tools/list 发现。"""
        # 保存工具定义
        self.tools = tool_defs
        # 保存工具处理函数
        self._handlers = handlers

    # 工具调用方法, tools/call
    def call_tool(self, tool_name: str, args: dict) -> str:
        """ tools/call。"""
        # 获取对应工具的处理函数
        handler = self._handlers.get(tool_name)
        # 如果没有找到处理函数,则返回错误信息
        if not handler:
            return f"MCP 错误:未知工具 '{tool_name}'"
        try:
            # 调用处理函数,并将参数解包
            return str(handler(**args))
        except Exception as e:
            # 如果调用出错,返回异常信息
            return f'MCP 错误:{e}'


# 工具名标准化,将非法字符替换为下划线
def normalize_mcp_name(name: str) -> str:
    # 使用正则表达式进行替换
    return _DISALLOWED_CHARS.sub('_', name)


# 构造 mock "docs" 服务器,返回对应 MCPClient
def _mock_server_docs() -> MCPClient:
    # 创建 MCPClient 实例,名称为 'docs'
    client = MCPClient('docs')
    # 注册工具定义及处理函数
    client.register(
        tool_defs=[
            {
                'name': 'search',
                'description': '搜索文档。(只读)',
                'inputSchema': {
                    'type': 'object',
                    'properties': {'query': {'type': 'string'}},
                    'required': ['query'],
                },
            },
            {
                'name': 'get_version',
                'description': '获取 API 版本。(只读)',
                'inputSchema': {'type': 'object', 'properties': {}, 'required': []},
            },
        ],
        handlers={
            'search': lambda query: f"[docs] 找到 3 条与 '{query}' 相关的结果",
            'get_version': lambda: '[docs] API v2.1.0',
        },
    )
    # 返回 mock 的 client 实例
    return client


# 构造 mock "deploy" 服务器,返回对应 MCPClient
def _mock_server_deploy() -> MCPClient:
    # 创建 MCPClient 实例,名称为 'deploy'
    client = MCPClient('deploy')
    # 注册工具定义及处理函数
    client.register(
        tool_defs=[
            {
                'name': 'trigger',
                'description': '触发部署。',
                'inputSchema': {
                    'type': 'object',
                    'properties': {'service': {'type': 'string'}},
                    'required': ['service'],
                },
            },
            {
                'name': 'status',
                'description': '查询部署状态。(只读)',
                'inputSchema': {
                    'type': 'object',
                    'properties': {'service': {'type': 'string'}},
                    'required': ['service'],
                },
            },
        ],
        handlers={
            'trigger': lambda service: f'[deploy] 已触发: {service}',
            'status': lambda service: f'[deploy] {service}: 运行中 (v1.4.2)',
        },
    )
    # 返回 mock 的 client 实例
    return client


# MOCK_SERVERS 字典,服务器名到工厂函数的映射
MOCK_SERVERS: dict[str, Callable[[], MCPClient]] = {
    'docs': _mock_server_docs,
    'deploy': _mock_server_deploy,
}


# 连接指定名称的 MCP 服务器
def connect_mcp(name: str) -> str:
    # 如果服务器已连接,直接返回已连接提示
    if name in mcp_clients:
        return f"MCP 服务器 '{name}' 已连接"
    # 根据名称获取对应的工厂函数
    factory = MOCK_SERVERS.get(name)
    # 如果名称找不到,则提示可用服务器名
    if not factory:
        available = ', '.join(MOCK_SERVERS.keys())
        return f"未知服务器 '{name}'。可用: {available}"
    # 创建 MCPClient 实例
    mcp_client = factory()
    # 存入已连接客户端集合
    mcp_clients[name] = mcp_client
    # 提取所有工具的名称
    tool_names = [t['name'] for t in mcp_client.tools]
    # 控制台打印已连接信息(着色)
    print(f'  \x1b[31m[mcp] 已连接: {name} → {tool_names}\x1b[0m')
    # 返回连接成功和工具信息
    return (
        f"已连接 MCP 服务器 '{name}'。"
        f"发现 {len(mcp_client.tools)} 个工具: {', '.join(tool_names)}"
    )


# 将 mcp 工具定义转换为 openai 格式的规范
def _mcp_tool_to_openai(prefixed: str, tool_def: dict) -> dict:
    # 从 tools.schema 导入 _fn_tool 方法
    from tools.schema import _fn_tool
    # 获取工具的输入 schema
    schema = tool_def.get('inputSchema', {})
    # 调用 _fn_tool 生成 openai 所需的工具描述
    return _fn_tool(
        prefixed,
        tool_def.get('description', ''),
        schema.get('properties', {}),
        schema.get('required', []),
    )


# 合并 builtin 工具和所有已连接 MCP 工具,返回统一的工具池和处理函数字典
def assemble_tool_pool() -> tuple[list[dict], dict]:
    """合并 builtin 与所有已连接 MCP 工具为统一池。"""
    # 导入内置工具定义
    from tools.schema import TOOLS
    # 导入内置工具处理函数
    from tools.handlers import TOOL_HANDLERS

    # 拷贝内置工具定义列表
    tools = list(TOOLS)
    # 拷贝内置工具处理函数字典
    handlers = dict(TOOL_HANDLERS)
    # 遍历所有已连接的 MCP 服务器
    for server_name, mcp_client in mcp_clients.items():
        # 标准化服务器名称
        safe_server = normalize_mcp_name(server_name)
        # 遍历当前服务器的所有工具
        for tool_def in mcp_client.tools:
            # 标准化工具名称
            safe_tool = normalize_mcp_name(tool_def['name'])
            # 拼接带前缀的工具名
            prefixed = f'mcp__{safe_server}__{safe_tool}'
            # 加入到工具总列表
            tools.append(_mcp_tool_to_openai(prefixed, tool_def))

            # 定义工厂函数返回专属 handler
            def _make_handler(client: MCPClient, tname: str):
                # 生成一个闭包 handler,实际调用 MCP 工具
                def _handler(**kwargs):
                    return client.call_tool(tname, kwargs)
                return _handler

            # 将处理函数加入 handlers 字典
            handlers[prefixed] = _make_handler(mcp_client, tool_def['name'])
    # 返回合并后的工具列表和处理函数字典
    return tools, handlers


# 获取当前已连接 MCP 服务器及其工具名摘要,用于 system prompt
def connected_mcp_summary() -> str:
    """供 system prompt 追加:当前已连接 MCP 及带前缀的工具名。"""
    # 若无连接的 mcp 客户端,返回空
    if not mcp_clients:
        return ''
    # 初始化摘要行列表,首行为说明
    lines = ['已连接 MCP 服务器(工具名带 mcp__server__tool 前缀):']
    # 遍历所有已连接的 mcp 客户端
    for server_name, mcp_client in mcp_clients.items():
        # 标准化服务器名
        safe_server = normalize_mcp_name(server_name)
        # 遍历服务器的每个工具
        for tool_def in mcp_client.tools:
            # 标准化工具名
            safe_tool = normalize_mcp_name(tool_def['name'])
            # 拼出全前缀工具名
            prefixed = f'mcp__{safe_server}__{safe_tool}'
            # 获取工具描述
            desc = tool_def.get('description', '')
            # 追加行到摘要列表
            lines.append(f'- {prefixed}: {desc}')
    # 返回摘要文本,按行连接
    return '\n'.join(lines)


# 调用 connect_mcp 的包装函数
def run_connect_mcp(name: str) -> str:
    # 调用 connect_mcp 并返回结果
    return connect_mcp(name)

19.2. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json

# 从config模块导入默认最大token数和主模型
from config import (
    DEFAULT_MAX_TOKENS,
    MODEL_ID,
    CONTEXT_LIMIT,
    ESCALATED_MAX_TOKENS,
    MAX_RECOVERY_RETRIES,
    CONTINUATION_PROMPT,
)

# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict

# 从llm模块导入call_llm函数
from llm import call_llm, is_prompt_too_long_error, RecoveryState, with_retry

# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt

# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从teams模块导入inject_lead_inbox函数
from teams import inject_lead_inbox
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
from history import (
    tool_result_budget,
    snip_compact,
    micro_compact,
    estimate_size,
    compact_history,
    repair_message_chain,
    reactive_compact,
)
# 从mcp模块导入assemble_tool_pool和connected_mcp_summary函数
+from mcp import assemble_tool_pool, connected_mcp_summary
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks

# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict, message_text

# 从memory模块导入load_memories函数
from memory import load_memories, extract_memories, consolidate_memories
# 从background模块导入should_run_background,start_background_task,collect_background_results函数
from background import should_run_background,start_background_task,collect_background_results
# 从cron模块导入consume_cron_queue函数
from cron import consume_cron_queue
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0


# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
    state = RecoveryState()
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 开始循环,直到遇到return退出
    while True:
         # 每轮重建工具池(connect_mcp 后 MCP 工具才能进入下一轮 LLM)
+       tools, handlers = assemble_tool_pool()
        # 调用consume_cron_queue函数,获取需要执行的定时任务,并赋值给fired
        fired = consume_cron_queue()
        # 遍历所有被触发的定时任务job
        for job in fired:
            # 将定时任务的信息以用户消息的形式追加到messages列表
           messages.append({'role': 'user', 'content': f'[定时任务] {job.prompt}'})
            # 打印注入的cron任务,内容为job的prompt前50个字符,使用紫色高亮输出
           print(f'  \x1b[35m[注入 cron] {job.prompt[:50]}\x1b[0m')
        # 注入lead inbox
        inject_lead_inbox(messages)   
        # 从后台收集通知消息(如果有的话)
        bg_notifications = collect_background_results()
        # 如果收集到了后台通知
        if bg_notifications:
            # 将收集到的后台通知以用户消息格式追加到messages列表
           messages.append({'role': 'user', 'content': '\n\n'.join(bg_notifications)})
            # 打印注入后台通知的数量并以绿色高亮显示
           print(f'  \x1b[32m[注入] {len(bg_notifications)} 条后台通知\x1b[0m')
        # 获取系统提示词
        system = get_system_prompt()
        # 追加当前已连接 MCP 工具列表(避免 prompt 缓存拿不到新工具说明)
+       mcp_summary = connected_mcp_summary()
+       if mcp_summary:
+           system += '\n\n' + mcp_summary
        # 加载有关历史消息的记忆内容
        memories_content = load_memories(messages)
        # 如果记忆内容存在
        if memories_content:
            # 将记忆内容追加到系统提示词后,前面加两个换行符
            system += "\n\n" + memories_content
        # 创建一个用于存储消息压缩前内容的列表
        pre_compress = [
            # 对于messages中的每一个元素m,如果m是字典,则
            {"role": m.get("role", ""), "content": message_text(m)}
            # 遍历messages列表,只处理那些是字典类型的元素
            for m in messages
            if isinstance(m, dict)
        ]
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
        messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
        messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
        messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
        if estimate_size(messages) > CONTEXT_LIMIT:
            print("[自动压缩]")
            messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
        messages[:] = repair_message_chain(messages)
        # 如果距离上次 todo 写入的轮数大于等于 3 且消息列表不为空
        #if rounds_since_todo >= 3 and messages:
        #    # 在消息列表中添加一条用户的提醒,提示助手更新 todo 列表
        #    messages.append(
        #        {
        #            "role": "user",
        #            "content": "<reminder>请更新你的 todo 列表。</reminder>",
        #        }
        #    )
        #    print(f"\x1b[33m> 请更新你的 todo 列表。\x1b[0m")
        #    # 轮数计数器 rounds_since_todo 复位为 0
        #    rounds_since_todo = 0
        # 尝试执行以下代码块
        try:
            response = with_retry(
+               lambda max_tokens=max_tokens, model=state.current_model, tools=tools: call_llm(
+                   system, messages, max_tokens, model, tools=tools
                ),
                state,
            )
        # 捕获所有异常并命名为e
        except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
            if is_prompt_too_long_error(e):
                # 如果还没有尝试过reactive_compact方法进行压缩
               if not state.has_attempted_reactive_compact:
                    # 使用reactive_compact进行消息压缩
                   messages[:] = reactive_compact(messages)
                    # 标记已经尝试过reactive_compact
                   state.has_attempted_reactive_compact = True
                    # 继续while循环,重新尝试
                   continue
                # 如果压缩后仍然过长,则打印错误提示(红色字体)
               print('  \x1b[31m[不可恢复] compact 后仍然过长\x1b[0m')
                # 在消息列表中加入assistant角色的错误消息,提示上下文过大
               messages.append({'role': 'assistant', 'content': '[错误] 上下文过大,无法继续。'})
                # 终止函数执行
               return
            # 获取异常的类型名称
            name = type(e).__name__
            # 打印不可恢复的错误信息,取错误内容的前100个字符(红色字体)
            print(f'  \x1b[31m[不可恢复] {name}: {str(e)[:100]}\x1b[0m')
            # 在消息列表中添加assistant角色的错误信息,包含异常类型和前200字符内容
            messages.append({'role': 'assistant', 'content': f'[错误] {name}: {str(e)[:200]}'})
            # 终止函数执行
            return
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 判断回复是否因达到最大长度被截断
        if choice.finish_reason == 'length':
            # 如果还未升级max_tokens
           if not state.has_escalated:
                # 升级max_tokens至更大值
               max_tokens = ESCALATED_MAX_TOKENS
                # 标记已升级
               state.has_escalated = True
                # 打印升级提示
               print(f'  \x1b[33m[max_tokens] 升级 {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\x1b[0m')
                # 重新进入循环再次请求
               continue
            # 将助手的消息以dict形式加入消息列表
           messages.append(assistant_message_dict(choice.message))
            # 如果助手回复里包含工具调用
           if choice.message.tool_calls:
                # 遍历所有工具调用
               for tool_call in choice.message.tool_calls:
                    # 添加一条 tool 消息,提示输出被截断未执行
                   messages.append({
                       'role': 'tool',
                       'tool_call_id': tool_call.id,
                       'content': '[输出被截断,未能执行工具]',
                   })
                # 跳出本次循环,重新开始
               continue
            # 如果还在允许的最大恢复次数范围内
           if state.recovery_count < MAX_RECOVERY_RETRIES:
                # 添加一条用户消息,提示助手续写回复
               messages.append({'role': 'user', 'content': CONTINUATION_PROMPT})
                # 恢复计数加一
               state.recovery_count += 1
                # 打印续写提示
               print(f'  \x1b[33m[max_tokens] 续写 {state.recovery_count}/{MAX_RECOVERY_RETRIES}\x1b[0m')
                # 进入下一个循环尝试续写
               continue
            # 已达最大恢复重试次数,打印告警
           print('  \x1b[31m[max_tokens] 已达恢复上限\x1b[0m')
            # 终止函数执行
           return
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 提取记忆
            extract_memories(pre_compress)
            # 合并记忆
            consolidate_memories()
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks("Stop", messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({"role": "user", "content": force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f"\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m")
            # 如果工具名称是'compact'
            if name == "compact":
                # 调用compact_history函数,对messages列表进行消息压缩处理
                messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
                break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks("PreToolUse", name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": str(blocked),
                    }
                )
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 判断是否应该以后台任务方式运行工具
            if should_run_background(name, args):
                # 启动后台任务,并获取后台任务ID
               bg_id = start_background_task(tool_call.id, name, args)
                # 组织后台任务已启动的输出消息,包括任务ID、命令、通知方式
               output = (
                   f'[后台任务 {bg_id} 已启动] '
                   f'命令: {args.get("command", "")}。'
                   f'完成后将通过 task_notification 通知。'
               )
              # 如果不是后台运行,则直接同步执行工具
            else:
               try:
                    # 执行工具函数,并获取输出
+                  output = execute_tool(name, args, handlers=handlers)
               except Exception as e:
                    # 如果执行过程中发生异常,将异常信息作为输出内容
                   output = f'错误:{type(e).__name__}: {e}'
             # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks("PostToolUse", name, args, output)
            # connect_mcp 后立即重建工具池,供本轮后续 / 下一轮使用
+           if name == 'connect_mcp':
+               tools, handlers = assemble_tool_pool()
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == "todo_write":
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )

19.3. llm.py #

llm.py

from openai import APIStatusError, RateLimitError
import random
import time

# 从config模块中导入client对象
from config import (
    client,
    MODEL_ID,
    MAX_RETRIES,
    BASE_DELAY_MS,
    MAX_CONSECUTIVE_529,
    FALLBACK_MODEL,
)

# 从tools.schema模块中导入TOOLS常量
from tools.schema import TOOLS


# 定义call_llm函数,参数包括system(系统消息)、messages(消息列表)、max_tokens(最大token数)、model(模型名)
+def call_llm(system: str, messages: list, max_tokens: int, model: str, tools=None):
    # 调用client.chat.completions.create方法生成响应,传入模型名、拼接的消息、工具集合和最大token数
    return client.chat.completions.create(
        model=model,
        # 将系统提示和传入的消息列表组合成messages参数
        messages=[{"role": "system", "content": system}, *messages],
        # 传入工具集合
+       tools=tools if tools is not None else TOOLS,
        # 传入最大允许的token数
        max_tokens=max_tokens,
    )


# 定义一个函数,用于判断异常是否为提示过长相关错误
def is_prompt_too_long_error(e: Exception) -> bool:
    # 将异常对象转换为字符串,并转换为小写
    msg = str(e).lower()
    # 返回一个布尔值,判断是否包含与“提示过长”相关的各种关键字
    return (
        # 检查字符串中是否有 'prompt' 且有 'long'
        ("prompt" in msg and "long" in msg)
        # 检查是否有 'prompt_is_too_long'
        or "prompt_is_too_long" in msg
        # 检查是否有 'context_length_exceeded'
        or "context_length_exceeded" in msg
        # 检查是否有 'max_context_window'
        or "max_context_window" in msg
        # 检查是否有 'context_length'
        or "context_length" in msg
        # 检查是否有 'maximum context'
        or "maximum context" in msg
    )


# 定义一个RecoveryState类,用于表示恢复状态
class RecoveryState:
    # 初始化方法
    def __init__(self):
        # 标记是否已升级处理
        self.has_escalated = False
        # 恢复尝试的次数计数
        self.recovery_count = 0
        # 连续发生529错误的次数
        self.consecutive_529 = 0
        # 是否已尝试被动压缩
        self.has_attempted_reactive_compact = False
        # 当前使用的模型
        self.current_model = MODEL_ID


# 判断是否是速率限制错误
def _is_rate_limit_error(e: Exception) -> bool:
    # 如果异常是 RateLimitError 类型
    if isinstance(e, RateLimitError):
        # 返回 True
        return True
    # 将异常信息转为小写字符串
    msg = str(e).lower()
    # 获取异常类型的名字并转为小写
    name = type(e).__name__.lower()
    # 检查异常类型名字中是否包含 'ratelimit' 或异常信息中是否包含 '429'
    return "ratelimit" in name or "429" in msg


# 计算重试的延时
def retry_delay(attempt: int, retry_after=None) -> float:
    # 如果有 retry_after 参数
    if retry_after:
        try:
            # 尝试将 retry_after 转为 float 并返回
            return float(retry_after)
        # 如果转换失败则忽略错误
        except (TypeError, ValueError):
            pass
    # 计算基础延时,指数退避,最大不超过 32000 毫秒,并转换为秒
    base = min(BASE_DELAY_MS * 2**attempt, 32000) / 1000
    # 返回基础延时加上 0 到 base*0.25 之间的随机浮点数
    return base + random.uniform(0, base * 0.25)


# 判断是否是过载错误
def _is_overloaded_error(e: Exception) -> bool:
    # 如果异常是 APIStatusError 并且状态码为 529
    if isinstance(e, APIStatusError) and e.status_code == 529:
        # 返回 True
        return True
    # 将异常信息转为小写字符串
    msg = str(e).lower()
    # 获取异常类型的名字并转为小写
    name = type(e).__name__.lower()
    # 检查异常类型名字中是否包含 'overloaded' 或异常信息中是否包含 '529'
    return "overloaded" in name or "529" in msg


# 为一个函数增加重试机制
def with_retry(fn, state: RecoveryState):
    # 尝试 MAX_RETRIES 次
    for attempt in range(MAX_RETRIES):
        try:
            # 执行传入的函数
            result = fn()
            # 529 计数清零
            state.consecutive_529 = 0
            # 返回结果
            return result
        # 捕获所有异常
        except Exception as e:
            # 如果是速率限制错误
            if _is_rate_limit_error(e):
                # 计算重试等待时间
                delay = retry_delay(attempt)
                # 打印重试信息(黄色)
                print(
                    f"  \x1b[33m[429 速率限制] 重试 {attempt + 1}/{MAX_RETRIES},等待 {delay:.1f}s\x1b[0m"
                )
                # 等待 delay 秒
                time.sleep(delay)
                # 继续下一次重试
                continue
            # 如果是过载错误
            if _is_overloaded_error(e):
                # 529 计数加一
                state.consecutive_529 += 1
                # 如果连续 529 次数超过最大允许次数
                if state.consecutive_529 >= MAX_CONSECUTIVE_529:
                    # 如果配置了备用模型
                    if FALLBACK_MODEL:
                        # 切换到备用模型
                        state.current_model = FALLBACK_MODEL
                        # 529 计数清零
                        state.consecutive_529 = 0
                        # 打印切换模型信息(红色)
                        print(
                            f"  \x1b[31m[529 x{MAX_CONSECUTIVE_529}] 切换到 {FALLBACK_MODEL}\x1b[0m"
                        )
                    # 如果没有配置备用模型
                    else:
                        # 529 计数清零
                        state.consecutive_529 = 0
                        # 打印未配置备用模型信息(红色)
                        print(
                            f"  \x1b[31m[529 x{MAX_CONSECUTIVE_529}] 未配置 FALLBACK_MODEL,继续重试\x1b[0m"
                        )
                # 计算重试等待时间
                delay = retry_delay(attempt)
                # 打印过载重试信息(黄色)
                print(
                    f"  \x1b[33m[529 过载] 重试 {attempt + 1}/{MAX_RETRIES},等待 {delay:.1f}s\x1b[0m"
                )
                # 等待 delay 秒
                time.sleep(delay)
                # 继续下一次重试
                continue
            # 如果不是以上错误则抛出异常
            raise
    # 如果超过最大重试次数,抛出运行时错误
    raise RuntimeError(f"超过最大重试次数({MAX_RETRIES})")

19.4. prompt.py #

prompt.py

from config import WORKDIR

# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR, MEMORY_INDEX, TEXT_ENCODING

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    "identity": (
        f"你是一个编程 Agent。直接行动,不要解释。"
        f"你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
        f"所有破坏性操作需要用户批准。"
        f"开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。"
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
        f"bash 支持 run_in_background 参数以在后台运行耗时命令。"
        f"定时任务可使用 schedule_cron / list_crons / cancel_cron。"
        f"遇到复杂子问题时,可使用 spawn_teammate 委派队友。"
        f"teammate团队协作可使用 spawn_teammate / send_message / check_inbox。"
        f"request_plan 要求队友 submit_plan 后,用 review_plan(request_id, approve) 批准或拒绝;"
        f"任务结束或需回收资源时用 request_shutdown 请求队友优雅退出。"
        f"团队协作:spawn_teammate 启动自主队友(idle 时轮询看板并自动认领任务);"
        f"create_task 创建任务后队友可在 idle 阶段自动认领;"
        f"send_message 向队友发消息;check_inbox 查看队友回信(含协议响应状态)。"
        f"并行改码目录隔离:create_worktree(name, task_id?) 创建独立目录与分支;"
        f"完成后 remove_worktree 或 keep_worktree 保留供审查。"
+       f"外部工具:connect_mcp(name) 连接 docs/deploy 等 MCP 服务器;"
+       f"连接后可以调用 mcp__前缀工具。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    "workspace": f"工作目录:{WORKDIR}",
    # 'skill' 键,指明需要完整技术文档时的指引
    "skill": "需要完整技术说明时,使用 load_skill 加载相关文档。",
    # 'memory' 键,指明记忆的使用方式
    "memory": "下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。",
}


# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str, memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS["identity"], PROMPT_SECTIONS["workspace"]]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f"可用技能:\n{skills}")
        sections.append(PROMPT_SECTIONS["skill"])
        # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f"可用记忆:\n{memories}")
        sections.append(PROMPT_SECTIONS["memory"])
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return "\n\n".join(sections)


# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ""
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return "\n".join(
        f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()
    )


# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ""
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors="replace").strip()


# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None


# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt


# 定义子任务的系统提示语
SUB_SYSTEM = (
    f"你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。"
    "你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
    "完成分配给你的任务,然后返回简洁摘要。不要继续委派。"
)

19.5. executor.py #

tools/executor.py

import json

# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict, extract_text

# 从config模块导入client实例和主模型PRIMARY_MODEL
from config import client, MODEL_ID

# 从tools.schema模块导入基础工具列表BASE_TOOLS
from tools.schema import BASE_TOOLS

# 从prompt模块导入子系统提示SUB_SYSTEM
from prompt import SUB_SYSTEM

# 从hooks模块导入钩子触发函数
from hooks import trigger_hooks

# 导入inspect模块,用于获取函数签名信息
import inspect

# 从tools.handlers模块导入TOOL_HANDLERS字典
from tools.handlers import TOOL_HANDLERS


# 定义execute_tool函数,接收工具名称和参数字典,返回字符串
+def execute_tool(name: str, args: dict, handlers: dict | None = None) -> str:
    # 根据工具名称从 handlers(或全局 TOOL_HANDLERS)中获取对应的处理函数
+   pool = handlers if handlers is not None else TOOL_HANDLERS
    # 根据工具名称从TOOL_HANDLERS字典中获取对应的处理函数
+   handler = pool.get(name)
    # 如果没有找到处理函数,则返回未知工具提示
    if not handler:
        return f"未知工具:{name}"
    # 获取处理函数的参数签名
    sig = inspect.signature(handler)
    # 含 **kwargs 时透传全部参数(MCP 工具等动态 schema),判断是否存在 **kwargs(可变关键字参数)
+   has_var_kw = any(
+       p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
+   )
    # 如果处理器定义了 **kwargs,说明它设计为接受任意动态参数(如 MCP 工具),直接透传全部 args。
+   if has_var_kw:
+       return str(handler(**args))
    # 从输入参数中筛选出处理函数所需的有效参数
    valid = {k: v for k, v in args.items() if k in sig.parameters}
    # 调用处理函数并返回结果
+   return str(handler(**valid))


# 定义运行子 Agent 的函数,参数为描述字符串,返回字符串类型
def run_spawn_subagent(description: str) -> str:
    # 打印子 Agent 已启动的信息
    print(f"\n\x1b[35m[子 Agent 已启动]\x1b[0m")
    # 初始化消息列表,用户以描述作为第一条消息
    messages = [{"role": "user", "content": description}]
    # 最多进行30轮消息交互
    for _ in range(30):
        # 调用OpenAI接口创建一次聊天补全
        response = client.chat.completions.create(
            # 指定主模型
            model=MODEL_ID,
            # 系统消息和当前消息历史作为上下文传递
            messages=[{"role": "system", "content": SUB_SYSTEM}, *messages],
            # 指定可用工具
            tools=BASE_TOOLS,
            # 设置最大token数
            max_tokens=8000,
        )
        # 取出assistant回复内容
        assistant = response.choices[0].message
        # 将assistant回复格式化为dict并加入消息历史
        messages.append(assistant_message_dict(assistant))
        # 如果assistant没有工具调用,跳出循环
        if not assistant.tool_calls:
            break
        # 遍历assistant需要调用的所有工具
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 获取调用的参数,JSON格式
            args = json.loads(tool_call.function.arguments or "{}")
            # 调用PreToolUse钩子判断是否被阻止
            blocked = trigger_hooks("PreToolUse", name, args)
            # 如果被阻止,加入一条tool回复,内容为阻止理由
            if blocked:
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": str(blocked),
                    }
                )
                continue
            # 执行工具,如未注册则提示“未知工具”
            output = (
                execute_tool(name, args)
                if name in TOOL_HANDLERS
                else f"未知工具:{name}"
            )
            # 调用PostToolUse钩子
            trigger_hooks("PostToolUse", name, args, output)
            # 打印子agent的工具调用及输出内容简略
            print(f"  \x1b[90m[sub] {name}: {str(output)[:100]}\x1b[0m")
            # 将工具返回的内容添加到消息历史
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )
    # 从所有消息的最后一条内容中提取文本为最终结果
    result = extract_text(messages[-1].get("content"))
    # 如果没有提取到,反向查找assistant角色消息提取结果
    if not result:
        for msg in reversed(messages):
            # 只检查assistant回复
            if msg.get("role") == "assistant":
                # 提取其内容
                result = extract_text(msg.get("content"))
                if result:
                    break
        # 如果还是没有结果,则说明未给出最终答案
        if not result:
            result = "子 Agent 在 30 轮内未给出最终答案。"
    # 打印子 Agent 完成的信息
    print(f"\x1b[35m[子 Agent 完成]\x1b[0m")
    # 返回最终结果
    return result


TOOL_HANDLERS["spawn_subagent"] = run_spawn_subagent

19.6. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os
# 导入操作系统相关模块
import glob as g
# 导入subprocess模块,用于执行子进程
import subprocess

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output, safe_path

# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING, WORKDIR

# 从config模块导入文本编码配置
from config import TEXT_ENCODING, WORKDIR

# 从skills模块导入load_skill函数
from skills import load_skill

# 从tasks模块导入create_task函数
from tasks import create_task, list_tasks, get_task, claim_task, complete_task
+from mcp import run_connect_mcp
# 从cron模块导入schedule_job, cancel_job, scheduled_jobs, cron_lock函数
from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock
from pathlib import Path
# 导入操作系统相关模块
import glob as g

from worktrees import (
    run_create_worktree,
    run_remove_worktree,
    run_keep_worktree,
)
# 从 teams 模块导入 spawn_teammate_thread,BUS, LEAD_NAME, format_inbox_messages
from teams import (spawn_teammate_thread,current_agent,BUS,LEAD_NAME,is_teammate_running,format_inbox_messages,consume_inbox,
run_request_shutdown,
run_request_plan,#要求队友提交计划供审核。
run_submit_plan,#向 Lead 提交计划待审批。
run_review_plan,#按 request_id 批准或拒绝已提交的计划。
)

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str, run_in_background: bool = False, cwd: Path | None = None) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == "nt" and command.strip().lower() == "date":
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = "date /t & time /t"
    # 定义危险命令的列表
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return "错误:危险命令已被拦截"
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,  # 要执行的命令
            shell=True,  # 在shell中执行
            cwd=str(cwd) if cwd else os.getcwd(),  # 当前工作目录设置为当前路径
            capture_output=True,  # 捕获标准输出和标准错误
            timeout=120,  # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b"") + (r.stderr or b"")).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else "(无输出)"
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return "错误:超时(120 秒)"
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f"错误:{e}"


# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path, cwd).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f"...(还有 {len(lines) - limit} 行)"]
        # 将行列表拼接为字符串并返回
        return "\n".join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path, cwd)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f"已写入 {len(content)} 字节到 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path, cwd)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f"错误:在 {path} 中未找到指定文本"
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(
            text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING
        )
        # 返回编辑成功的提示
        return f"已编辑 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return "\n".join(results) if results else "(无匹配)"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
CURRENT_TODOS: list[dict] = []


# 定义run_todo_write函数,参数为todos列表,返回字符串
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历todos列表,获取每个任务及其索引
    for i, t in enumerate(todos):
        # 如果任务中缺少content或status字段
        if "content" not in t or "status" not in t:
            # 返回错误提示,指出缺少字段的位置
            return f"错误:todos[{i}] 缺少 content 或 status"
        # 如果任务的status不是允许的三种状态
        if t["status"] not in ("pending", "in_progress", "completed"):
            # 返回错误提示,指出状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 校验全部通过后,更新全局任务列表
    CURRENT_TODOS = todos
    # 初始化显示用的lines列表,第一行为标题,并加黄颜色
    lines = ["\n\x1b[33m## 当前任务\x1b[0m"]
    # 遍历所有当前任务
    for t in CURRENT_TODOS:
        # 根据任务状态,选择不同的彩色标签
        icon = {
            "pending": "\x1b[33m等待中\x1b[0m",
            "in_progress": "\x1b[36m处理中\x1b[0m",
            "completed": "\x1b[32m已完成\x1b[0m",
        }[t["status"]]
        # 将格式化后的任务内容和标签加入lines
        lines.append(f"  [{icon}] {t['content']}")
    # 将所有内容组合成字符串打印到标准输出
    print("\n".join(lines))
    # 返回已更新任务数的字符串提示
    return f"已更新 {len(CURRENT_TODOS)} 个任务"


# 定义run_create_task函数,用于创建新任务
def run_create_task(
    # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
    subject: str,
    description: str = "",
    blockedBy: list[str] | None = None,
    # 函数返回类型为字符串
) -> str:
    # 调用create_task函数创建任务对象
    task = create_task(subject, description, blockedBy)
    # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
    # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
    # 返回已创建任务的ID、主题及依赖信息提示
    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
def run_list_tasks() -> str:
    # 调用list_tasks获取所有任务列表
    tasks = list_tasks()
    # 如果任务列表为空
    if not tasks:
        # 返回暂无任务的提示信息
        return "暂无任务。使用 create_task 添加。"
    # 初始化用于存储显示行的空列表
    lines = []
    # 遍历所有任务
    for t in tasks:
        # 根据任务状态获取对应的中文状态标签
        icon = {
            # pending状态对应“等待中”
            "pending": "等待中",
            # in_progress状态对应“处理中”
            "in_progress": "处理中",
            # completed状态对应“已完成”
            "completed": "已完成",
            # 按任务状态取值,未知状态则返回问号
        }.get(t.status, "?")
        # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
        # 若任务有负责人则格式化负责人信息,否则为空字符串
        owner = f" [{t.owner}]" if t.owner else ""
        # 如果有绑定 worktree,追加显示
        wt = f' (wt:{t.worktree})' if t.worktree else ''
        # 拼接每条任务信息并加入结果列表
        lines.append(f'  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}{wt}')
    # 将所有行用换行符拼接成字符串后返回
    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
def run_get_task(task_id: str) -> str:
    # 尝试获取指定ID的任务
    try:
        # 调用get_task返回任务详情
        return get_task(task_id)
    # 捕获任务文件不存在的异常
    except FileNotFoundError:
        # 返回未找到任务的错误提示
        return f"错误:未找到任务 {task_id}"


# 定义run_claim_task函数,认领指定任务,返回字符串
def run_claim_task(task_id: str, owner: str = "lead") -> str:
    # 以agent为负责人认领该任务并返回结果
    return claim_task(task_id, owner=owner or LEAD_NAME)


# 定义run_complete_task函数,完成指定任务,返回字符串
def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
    return complete_task(task_id)


# 定义调度定时(cron)任务的函数
def run_schedule_cron(
    cron: str,  # cron表达式
    prompt: str,  # 提示词
    recurring: bool = True,  # 是否循环
    durable: bool = True,  # 是否持久化
) -> str:  # 返回结果
    # 调用 schedule_job 安排定时任务,返回结果
    result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
    if isinstance(result, str):
        # 返回错误提示
        return f"错误:{result}"
    # 返回调度成功信息,包括 id、表达式和 prompt
    return f"已调度 {result.id}: '{cron}' → {prompt}"


# 定义列出所有 cron 定时任务的函数
def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
    with cron_lock:
        jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
    if not jobs:
        return "暂无 cron 任务。使用 schedule_cron 添加。"
    # 初始化结果字符串列表
    lines = []
    # 遍历所有定时任务
    for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
        tag = "循环" if j.recurring else "单次"
        # 根据 durable 标记区分“持久化”或“会话”
        dur = "持久化" if j.durable else "会话"
        # 拼接任务的信息字符串并加入列表
        lines.append(f"  {j.id}: '{j.cron}' → {j.prompt[:40]} [{tag}, {dur}]")
    # 返回所有任务拼接后的字符串
    return "\n".join(lines)


# 定义取消定时任务的函数
def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
    return cancel_job(job_id)

# 定义函数,启动一个队友 agent 线程
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
    # 调用 spawn_teammate_thread 启动队友 agent,传递名字、角色和 prompt
    return spawn_teammate_thread(name, role, prompt)

# 定义函数,通过消息总线发送消息给指定对象
def run_send_message(to: str, content: str) -> str:
    # 发送方固定为当前会话身份,不可伪造
    from_agent = current_agent.get()
    # 使用 BUS 发送消息
    BUS.send(from_agent, to, content)
    if to != LEAD_NAME and not is_teammate_running(to):
        # 返回已写入收件箱但队友未运行的提示
        return (
            f"已从 {from_agent} 写入 {to} 的收件箱,但该队友未在运行。"
            f"请 spawn_teammate 重启后才会被读取。"
        )
    # 返回发送结果的字符串说明
    return f"已从 {from_agent} 发送给 {to}"


# 定义函数,仅允许读取当前 Agent 自己的收件箱(Lead 只能读 lead)
def run_check_inbox() -> str:
    # 当前会话身份
    name = current_agent.get()
    # Lead 与队友都只能消费自己的收件箱,避免抢走对方消息
    msgs = consume_inbox(name)
    # 如果收件箱消息为空,返回提示信息
    if not msgs:
        return f"({name} 的收件箱为空)"
    # 如果收件箱有消息,格式化这些消息并返回
    return format_inbox_messages(msgs)
# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
    "todo_write": run_todo_write,
    "load_skill": load_skill,  # 按名称加载技能的完整内容
    "create_task": run_create_task,  # 创建新任务
    "list_tasks": run_list_tasks,  # 列出所有任务
    "get_task": run_get_task,  # 按 ID 获取任务完整详情
    "claim_task": run_claim_task,  # 认领 pending 任务,设置 owner 并改为 in_progress
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "schedule_cron": run_schedule_cron,  # 调度定时任务
    "list_crons": run_list_crons,  # 列出所有定时任务
    "cancel_cron": run_cancel_cron,  # 取消定时任务
    "spawn_teammate": run_spawn_teammate,  # 在后台线程启动队友 Agent。
    "send_message": run_send_message,  # 通过 MessageBus 向队友发送消息。
    "check_inbox": run_check_inbox,  # 仅检查当前 Agent 自己的收件箱。
    'request_shutdown': run_request_shutdown,#请求队友优雅关闭。
    'request_plan': run_request_plan,#要求队友提交计划供审核。
    'submit_plan': run_submit_plan,#向 Lead 提交计划待审批。
    'review_plan': run_review_plan,#按 request_id 批准或拒绝已提交的计划。
    'create_worktree': run_create_worktree,#创建隔离 git worktree
    'remove_worktree': run_remove_worktree,#删除 worktree
    'keep_worktree': run_keep_worktree,#保留 worktree 供审查
+   'connect_mcp': run_connect_mcp,#连接 MCP 服务器并发现工具
}

19.7. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(
    name: str, description: str, properties: dict, required: list[str]
) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        "type": "function",
        # 定义函数的具体内容
        "function": {
            # 函数名称
            "name": name,
            # 函数描述
            "description": description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    _fn_tool(
        "bash",
        "执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。",
        {
            "command": {"type": "string"},
            "run_in_background": {"type": "boolean", "default": False},
        },
        ["command"],
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool(
        "read_file",
        "读取文件内容。",
        {"path": {"type": "string"}, "limit": {"type": "integer"}},
        ["path"],
    ),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool(
        "write_file",
        "将内容写入文件。",
        {"path": {"type": "string"}, "content": {"type": "string"}},
        ["path", "content"],
    ),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool(
        "edit_file",
        "在文件中精确替换一段文本(仅替换一次)。",
        {
            "path": {"type": "string"},
            "old_text": {"type": "string"},
            "new_text": {"type": "string"},
        },
        ["path", "old_text", "new_text"],
    ),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool(
        "glob", "按 glob 模式查找文件。", {"pattern": {"type": "string"}}, ["pattern"]
    ),  # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool(
        "send_message",
        "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
        {
            "to": {"type": "string"},
            "content": {"type": "string"},
        },
        ["to", "content"],
    ),
    _fn_tool(
        "check_inbox",
        "检查自己的收件箱(队友回信)。",
        {},
        [],
    ),
    # 定义 submit_plan 工具:向 Lead 提交计划待审批。
    _fn_tool(
        'submit_plan',
        '向 Lead 提交计划待审批。',
        {
            'from_name': {'type': 'string'},
            'plan': {'type': 'string'}
        },
        ['from_name', 'plan']
    )
]
TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "spawn_subagent",
        "启动子 Agent 处理复杂子任务。仅返回最终结论。",
        {"description": {"type": "string"}},
        ["description"],
    ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    ),
    _fn_tool(
        "compact", "摘要较早对话以释放上下文空间。", {"focus": {"type": "string"}}, []
    ),
    _fn_tool(
        "create_task",
        "创建新任务,可选 blockedBy 依赖。",
        {
            "subject": {"type": "string"},
            "description": {"type": "string"},
            "blockedBy": {"type": "array", "items": {"type": "string"}},
        },
        ["subject"],
    ),
    _fn_tool("list_tasks", "列出所有任务的状态、负责人与依赖。", {}, []),
    _fn_tool(
        "get_task",
        "按 ID 获取任务完整详情。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "claim_task",
        "认领 pending 任务,设置 owner 并改为 in_progress。owner 为认领者 Agent 名称,默认 lead。",
        {"task_id": {"type": "string"},'owner': {'type': 'string', 'description': '认领者 Agent 名称,默认 lead'},},
        ["task_id"],
    ),
    _fn_tool(
        "complete_task",
        "完成 in_progress 任务,并报告下游解阻任务。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "schedule_cron",
        "调度 cron 任务。cron 为 5 段:分 时 日 月 周。",
        {
            "cron": {"type": "string", "description": "5 段 cron 表达式"},
            "prompt": {"type": "string", "description": "触发时注入的消息"},
            "recurring": {"type": "boolean", "description": "true=循环,false=单次"},
            "durable": {"type": "boolean", "description": "true=持久化到磁盘"},
        },
        ["cron", "prompt"],
    ),
    _fn_tool("list_crons", "列出所有已注册的 cron 任务。", {}, []),
    _fn_tool(
        "cancel_cron",
        "按 ID 取消 cron 任务。",
        {"job_id": {"type": "string"}},
        ["job_id"],
    ),
    _fn_tool(
        "spawn_teammate",
        "启动自主队友 Agent(idle 轮询看板、自动认领任务)。",
        {
            "name": {"type": "string"},
            "role": {"type": "string"},
            "prompt": {"type": "string"},
        },
        ["name", "role", "prompt"],
    ),
     # 定义 request_shutdown 工具:请求队友优雅关闭
    _fn_tool(
        'request_shutdown',
        '请求队友优雅关闭。',
        {'teammate': {'type': 'string'}},
        ['teammate'],
    ),
    # 定义 request_plan 工具:要求队友提交计划供审核
    _fn_tool(
        'request_plan',
        '要求队友提交计划供审核。',
        {'teammate': {'type': 'string'}, 'task': {'type': 'string'}},
        ['teammate', 'task'],
    ),
    # 定义 review_plan 工具:按 request_id 批准或拒绝已提交的计划
    _fn_tool(
        'review_plan',
        '按 request_id 批准或拒绝已提交的计划。',
        {
            'request_id': {'type': 'string'},
            'approve': {'type': 'boolean'},
            'feedback': {'type': 'string'},
        },
        ['request_id', 'approve'],
    ),
    _fn_tool(
        'create_worktree',
        '创建隔离的 git worktree 及独立分支 wt/{name}。可选 task_id 绑定任务(不改任务状态)。',
        {
            'name': {'type': 'string', 'description': 'worktree 名称,仅 [A-Za-z0-9._-]{1,64}'},
            'task_id': {'type': 'string', 'description': '可选,绑定到该任务'},
        },
        ['name'],
    ),
    _fn_tool(
        'remove_worktree',
        '删除 worktree。有未提交变更时拒绝,除非 discard_changes=true。',
        {
            'name': {'type': 'string'},
            'discard_changes': {'type': 'boolean', 'description': '强制丢弃未提交改动'},
        },
        ['name'],
    ),
    _fn_tool(
        'keep_worktree',
        '保留 worktree 供人工审查(不删除目录与分支)。',
        {'name': {'type': 'string'}},
        ['name'],
    ),
+   _fn_tool(
+       'connect_mcp',
+       '连接 MCP 服务器并发现外部工具。可用: docs, deploy。连接后工具名前缀为mcp__。',
+       {
+           'name': {
+               'type': 'string',
+               'description': 'MCP 服务器名称,如 docs 或 deploy',
+           },
+       },
+       ['name'],
+   ),
]
TEAMMATE_TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    )
]

20. Teammate Barrier — 派出去,也要收回来 #

"派出去,也要收回来" — 等的是 result,不是线程还活着;Stop 前拦一道,避免 Lead 早退。

本节对应教程 s20,在 s15–s17 异步队友与 s19 MCP 之上,补上 Lead 收尾屏障(Teammate Barrier)。s15 的口号是「派出去,别等着」——spawn_teammate 立即返回,队友在 daemon 线程里干活,结果写入 .mailboxes/lead.jsonl。问题是:Lead 若在队友写信之前就因「无 tool_calls」退出 agent_loop,本轮就看不到回信,容易对着错误文件路径「幻觉式汇报」。

本节要解决什么

场景 s15–s19(无屏障) s20(有屏障)
Lead 早停 agent_loop 直接 return,mailbox 里的信要等下一轮用户输入 Stop 前若仍有 pending result → 阻塞等待 → 注入 → 再给 Lead 一轮
等待对象 无(或模型自觉 check_inbox,不可靠) 等 type=result,不是傻等 thread.is_alive()
idle 长驻队友 若按「线程活着就等」会永久挡住 Stop 收到 result 即解除;idle 轮询本身不挡收尾
主动等待 仅有非阻塞 check_inbox 新增 await_teammates(可指定 names / timeout)
超时 无 TEAMMATE_WAIT_TIMEOUT;TEAMMATE_BARRIER_ROUNDS 防止无限拦截

没有收尾屏障,异步协作的「正确性」完全赌模型会不会再调一次 check_inbox;有了屏障,Harness 在生命周期上兜底——模型仍可主动 await_teammates,但忘了也不会把本轮结果丢进「下一次用户说话」。

为什么等 result,而不是等线程?

s17 自治队友在完成首轮工作后会进入 idle_poll(默认最多 60s),线程仍 is_alive(),却可能早已通过 send_message 回报过进度。若 Stop 条件写成「有活跃线程就不退出」,Lead 会被 idle 队友永久挂住。

正确语义:

spawn_teammate(name)
  → pending_teammate_results.add(name)

队友线程结束前:
  BUS.send(name, 'lead', summary, type='result')

consume_lead_inbox / check_inbox / await_teammates 读到 result:
  → pending_teammate_results.discard(name)

Stop 时:
  pending 非空 → wait → 注入 → continue(有限轮)
  pending 为空 → 允许真正退出(仍会再吸一次迟到邮件)

核心概念

概念 作用
pending_teammate_results 已 spawn、尚未消费到 type=result 的队友名集合
wait_for_teammates 轮询消费 lead 收件箱,直到 pending 清空或超时
apply_teammate_stop_barrier 无 tool_calls 时调用;有 pending 则 wait+注入并返回强制续跑的 user 提示
await_teammates Lead 专用工具,主动阻塞等待(派工后、汇报前推荐调用)
TEAMMATE_WAIT_TIMEOUT 单次等待上限(默认 120s)
TEAMMATE_BARRIER_ROUNDS 每个 agent_loop 回合最多自动屏障次数(默认 1),避免死循环

挂进主循环的位置

必须插在 Stop hook 之前:否则会先打印「本次会话共使用 N 次工具」再继续跑,误报会话已结束。

assistant 无 tool_calls:
  if barrier_rounds < TEAMMATE_BARRIER_ROUNDS:
      msg = apply_teammate_stop_barrier(messages)
      if msg:
          barrier_rounds += 1
          messages.append(user=msg)
          continue          # 再给 Lead 一轮汇总
  extract_memories / consolidate
  Stop hook ...
  return

相对 s19 的变化

文件 变化
agent.py 无 tool_calls 时先跑收尾屏障,再 Stop
config.py 新增 TEAMMATE_WAIT_TIMEOUT、TEAMMATE_BARRIER_ROUNDS
main.py 空输入 continue,避免误触发一轮空对话
prompt.py identity:派工后应 await_teammates,未见 result 勿声称完成
teams.py pending_teammate_results;spawn 时登记;消费 inbox 时按 result 销账;wait_for_teammates / apply_teammate_stop_barrier
tools/handlers.py / schema.py 注册 await_teammates

s19 的 MCP、s18 worktree、s17 idle 全部保留。屏障只约束「待回收的 result」,不改变队友内部 idle 协议。

试试这些 prompt:

  1. 生成一名名为 alice 的后端开发者。让她创建一个名为 schema.sql 的文件,并包含一个 users 表。
  2. 生成 alice 写 schema.sql,同时生成 bob 写一段简短的 README 说明该表;都完成后再汇总。

观察重点:控制台是否出现 [屏障] 等待队友 result / Stop 已拦截?Lead 最终汇报的文件路径是否与 teammate 实际写入一致?主动调用 await_teammates 时 tool_result 是否带上收件箱内容?

时序图

Lead 早停被屏障拦住,等到 result 后再汇总:

sequenceDiagram participant User as 用户 participant Lead as agent_loop participant Alice as teammate alice participant Bus as MessageBus participant Barrier as apply_teammate_stop_barrier User->>Lead: 让 alice 创建 schema.sql Lead->>Alice: spawn_teammate(pending += alice) Note over Lead: spawn 立即返回,继续推理 Lead->>Lead: 无 tool_calls,准备结束 Lead->>Barrier: Stop 前检查 pending Barrier-->>Lead: alice 仍 pending,开始 wait Alice->>Bus: send_message / 写文件 Alice->>Bus: type=result 摘要 Bus-->>Barrier: consume_lead_inbox 销账 alice Barrier-->>Lead: 注入 [队友屏障]+收件箱,continue Lead->>Lead: 根据真实 result 汇总回复 Lead-->>User: 正确汇报文件位置与表结构

说明:消息落在磁盘 mailbox,本来就不会「物理丢失」;屏障解决的是本轮同步感知。超时后仍缺 result 时提示可再 await_teammates 或 request_shutdown;TEAMMATE_BARRIER_ROUNDS=1 保证不会无限拦截 Stop。主动路径与自动路径共用 wait_for_teammates:模型可先 await_teammates,忘了则由 Stop 前屏障兜底。

20.1. agent.py #

agent.py

# 导入json库,用于处理JSON数据
import json

# 从config模块导入默认最大token数和主模型
from config import (
    DEFAULT_MAX_TOKENS,
    MODEL_ID,
    CONTEXT_LIMIT,
    ESCALATED_MAX_TOKENS,
    MAX_RECOVERY_RETRIES,
    CONTINUATION_PROMPT,
    TODO_REMINDER_ROUNDS,
+   TEAMMATE_BARRIER_ROUNDS,
)

# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict

# 从llm模块导入call_llm函数
from llm import call_llm, is_prompt_too_long_error, RecoveryState, with_retry

# 从prompt模块导入get_system_prompt函数
from prompt import get_system_prompt

# 从tools.executor模块导入execute_tool函数
from tools.executor import execute_tool
# 从tools.handlers导入todo更新提醒
from tools.handlers import todo_update_reminder
# 从teams模块导入inject_lead_inbox与收尾屏障
+from teams import inject_lead_inbox, apply_teammate_stop_barrier
# 从history模块导入tool_result_budget,snip_compact,micro_compact函数
from history import (
    tool_result_budget,
    snip_compact,
    micro_compact,
    estimate_size,
    compact_history,
    repair_message_chain,
    reactive_compact,
)
# 从mcp模块导入assemble_tool_pool和connected_mcp_summary函数
from mcp import assemble_tool_pool, connected_mcp_summary
# 从hooks模块导入trigger_hooks函数
from hooks import trigger_hooks

# 从utils模块导入assistant_message_dict函数
from utils import assistant_message_dict, message_text

# 从memory模块导入load_memories函数
from memory import load_memories, extract_memories, consolidate_memories
# 从background模块导入should_run_background,start_background_task,collect_background_results函数
from background import should_run_background,start_background_task,collect_background_results
# 从cron模块导入consume_cron_queue函数
from cron import consume_cron_queue
# 定义变量rounds_since_todo,用于记录自上次todo_write调用以来的轮数
rounds_since_todo = 0


# 定义agent_loop函数,参数是消息的列表
def agent_loop(messages: list):
    # 声明全局变量rounds_since_todo
    global rounds_since_todo
    state = RecoveryState()
    # 将最大token数设置为默认值
    max_tokens = DEFAULT_MAX_TOKENS
    # 本回合已触发的队友收尾屏障次数(防止无限拦截 Stop)
+   barrier_rounds = 0
    # 开始循环,直到遇到return退出
    while True:
         # 每轮重建工具池(connect_mcp 后 MCP 工具才能进入下一轮 LLM)
        tools, handlers = assemble_tool_pool()
        # 调用consume_cron_queue函数,获取需要执行的定时任务,并赋值给fired
        fired = consume_cron_queue()
        # 遍历所有被触发的定时任务job
        for job in fired:
            # 将定时任务的信息以用户消息的形式追加到messages列表
           messages.append({'role': 'user', 'content': f'[定时任务] {job.prompt}'})
            # 打印注入的cron任务,内容为job的prompt前50个字符,使用紫色高亮输出
           print(f'  \x1b[35m[注入 cron] {job.prompt[:50]}\x1b[0m')
        # 注入lead inbox
        inject_lead_inbox(messages)   
        # 从后台收集通知消息(如果有的话)
        bg_notifications = collect_background_results()
        # 如果收集到了后台通知
        if bg_notifications:
            # 将收集到的后台通知以用户消息格式追加到messages列表
           messages.append({'role': 'user', 'content': '\n\n'.join(bg_notifications)})
            # 打印注入后台通知的数量并以绿色高亮显示
           print(f'  \x1b[32m[注入] {len(bg_notifications)} 条后台通知\x1b[0m')
        # 获取系统提示词
        system = get_system_prompt()
        # 追加当前已连接 MCP 工具列表(避免 prompt 缓存拿不到新工具说明)
        mcp_summary = connected_mcp_summary()
        if mcp_summary:
            system += '\n\n' + mcp_summary
        # 加载有关历史消息的记忆内容
        memories_content = load_memories(messages)
        # 如果记忆内容存在
        if memories_content:
            # 将记忆内容追加到系统提示词后,前面加两个换行符
            system += "\n\n" + memories_content
        # 有活跃 todo 且 N 轮未更新:仅拼进当轮 system,不写入 messages
        todo_reminder = todo_update_reminder(rounds_since_todo, TODO_REMINDER_ROUNDS)
        if todo_reminder:
            system += "\n\n" + todo_reminder
            print(f"\x1b[33m> [todo提醒] 连续 {rounds_since_todo} 轮未更新\x1b[0m")
        # 创建一个用于存储消息压缩前内容的列表
        pre_compress = [
            # 对于messages中的每一个元素m,如果m是字典,则
            {"role": m.get("role", ""), "content": message_text(m)}
            # 遍历messages列表,只处理那些是字典类型的元素
            for m in messages
            if isinstance(m, dict)
        ]
        # L3: tool_result_budget — 超大 tool 结果落盘 .task_outputs/tool-results/
        messages[:] = tool_result_budget(messages)
        # L1: snip_compact — 消息 >50 条时保留头 3 + 尾 47,中间裁掉
        messages[:] = snip_compact(messages)
        # L2: micro_compact —  仅保留最近 3 条 tool 完整内容,旧的换占位符
        messages[:] = micro_compact(messages)
        # L4: compact_history — 超出上下文限制时写 transcript → LLM 摘要 → 替换为一条 [已压缩]
        if estimate_size(messages) > CONTEXT_LIMIT:
            print("[自动压缩]")
            messages[:] = compact_history(messages)
        # 修复消息链:补全缺失的 tool 响应,移除孤立的 tool 消息
        messages[:] = repair_message_chain(messages)
        # 尝试执行以下代码块
        try:
            response = with_retry(
                lambda max_tokens=max_tokens, model=state.current_model, tools=tools: call_llm(
                    system, messages, max_tokens, model, tools=tools
                ),
                state,
            )
        # 捕获所有异常并命名为e
        except Exception as e:
            # 如果捕获到的异常是提示词过长导致的错误
            if is_prompt_too_long_error(e):
                # 如果还没有尝试过reactive_compact方法进行压缩
               if not state.has_attempted_reactive_compact:
                    # 使用reactive_compact进行消息压缩
                   messages[:] = reactive_compact(messages)
                    # 标记已经尝试过reactive_compact
                   state.has_attempted_reactive_compact = True
                    # 继续while循环,重新尝试
                   continue
                # 如果压缩后仍然过长,则打印错误提示(红色字体)
               print('  \x1b[31m[不可恢复] compact 后仍然过长\x1b[0m')
                # 在消息列表中加入assistant角色的错误消息,提示上下文过大
               messages.append({'role': 'assistant', 'content': '[错误] 上下文过大,无法继续。'})
                # 终止函数执行
               return
            # 获取异常的类型名称
            name = type(e).__name__
            # 打印不可恢复的错误信息,取错误内容的前100个字符(红色字体)
            print(f'  \x1b[31m[不可恢复] {name}: {str(e)[:100]}\x1b[0m')
            # 在消息列表中添加assistant角色的错误信息,包含异常类型和前200字符内容
            messages.append({'role': 'assistant', 'content': f'[错误] {name}: {str(e)[:200]}'})
            # 终止函数执行
            return
        # 取出回复中的第一个选项
        choice = response.choices[0]
        # 判断回复是否因达到最大长度被截断
        if choice.finish_reason == 'length':
            # 如果还未升级max_tokens
           if not state.has_escalated:
                # 升级max_tokens至更大值
               max_tokens = ESCALATED_MAX_TOKENS
                # 标记已升级
               state.has_escalated = True
                # 打印升级提示
               print(f'  \x1b[33m[max_tokens] 升级 {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\x1b[0m')
                # 重新进入循环再次请求
               continue
            # 将助手的消息以dict形式加入消息列表
           messages.append(assistant_message_dict(choice.message))
            # 如果助手回复里包含工具调用
           if choice.message.tool_calls:
                # 遍历所有工具调用
               for tool_call in choice.message.tool_calls:
                    # 添加一条 tool 消息,提示输出被截断未执行
                   messages.append({
                       'role': 'tool',
                       'tool_call_id': tool_call.id,
                       'content': '[输出被截断,未能执行工具]',
                   })
                # 跳出本次循环,重新开始
               continue
            # 如果还在允许的最大恢复次数范围内
           if state.recovery_count < MAX_RECOVERY_RETRIES:
                # 添加一条用户消息,提示助手续写回复
               messages.append({'role': 'user', 'content': CONTINUATION_PROMPT})
                # 恢复计数加一
               state.recovery_count += 1
                # 打印续写提示
               print(f'  \x1b[33m[max_tokens] 续写 {state.recovery_count}/{MAX_RECOVERY_RETRIES}\x1b[0m')
                # 进入下一个循环尝试续写
               continue
            # 已达最大恢复重试次数,打印告警
           print('  \x1b[31m[max_tokens] 已达恢复上限\x1b[0m')
            # 终止函数执行
           return
        # 获取助手回复内容
        assistant = choice.message
        # 将助手的回复以dict形式加入消息列表
        messages.append(assistant_message_dict(assistant))
        # 如果助手没有工具调用,则终止循环
        if not assistant.tool_calls:
            # 队友收尾屏障:仍有未收到的 result 时先等待再给 Lead 一轮汇总
            # (须在 Stop hook 之前,避免误报会话已结束)
+           if barrier_rounds < TEAMMATE_BARRIER_ROUNDS:
+               barrier_msg = apply_teammate_stop_barrier(messages)
+               if barrier_msg:
+                   barrier_rounds += 1
+                   messages.append({"role": "user", "content": barrier_msg})
+                   continue
            # 提取记忆
            extract_memories(pre_compress)
            # 合并记忆
            consolidate_memories()
            # 调用trigger_hooks函数,触发名为'Stop'的hook,并传入当前消息列表作为参数,获取返回值force
            force = trigger_hooks("Stop", messages)
            # 判断force是否有值(即hook是否返回了信息需要处理)
            if force:
                # 如果有值,则将其作为用户角色的消息添加到消息列表
                messages.append({"role": "user", "content": force})
                # 继续while循环,重新进入agent_loop流程
                continue
            return
        # 轮数计数器 rounds_since_todo 加 1
        rounds_since_todo += 1
        # 遍历所有工具调用
        for tool_call in assistant.tool_calls:
            # 获取工具名称
            name = tool_call.function.name
            # 解析工具参数(若为空则用空字典)
            args = json.loads(tool_call.function.arguments or "{}")
            # 打印工具名称(蓝色高亮)
            print(f"\x1b[36m> {name} {json.dumps(args, ensure_ascii=False)}\x1b[0m")
            # 如果工具名称是'compact'
            if name == "compact":
                # 调用compact_history函数,对messages列表进行消息压缩处理
                messages[:] = compact_history(messages)
                # 跳出当前for tool_call循环
                break
            # 触发'PreToolUse'钩子,判断是否允许工具执行
            blocked = trigger_hooks("PreToolUse", name, args)
            # 如果被阻止(blocked有返回值),则进入下面的分支
            if blocked:
                # 将阻塞信息以'tool'角色形式加入消息列表
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": str(blocked),
                    }
                )
                # 跳过本次循环,继续处理下一个工具调用
                continue
            # 判断是否应该以后台任务方式运行工具
            if should_run_background(name, args):
                # 启动后台任务,并获取后台任务ID
               bg_id = start_background_task(tool_call.id, name, args)
                # 组织后台任务已启动的输出消息,包括任务ID、命令、通知方式
               output = (
                   f'[后台任务 {bg_id} 已启动] '
                   f'命令: {args.get("command", "")}。'
                   f'完成后将通过 task_notification 通知。'
               )
              # 如果不是后台运行,则直接同步执行工具
            else:
               try:
                    # 执行工具函数,并获取输出
                   output = execute_tool(name, args, handlers=handlers)
               except Exception as e:
                    # 如果执行过程中发生异常,将异常信息作为输出内容
                   output = f'错误:{type(e).__name__}: {e}'
             # 触发'PostToolUse'钩子,进行后置处理
            trigger_hooks("PostToolUse", name, args, output)
            # connect_mcp 后立即重建工具池,供本轮后续 / 下一轮使用
            if name == 'connect_mcp':
                tools, handlers = assemble_tool_pool()
            # 如果工具名称是 todo_write,则重置轮数计数器
            if name == "todo_write":
                # 重置轮数计数器为 0
                rounds_since_todo = 0
            # 把工具执行结果以特定格式加入消息列表
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": output}
            )

20.2. config.py #

config.py

# 导入操作系统相关的模块
import os

# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv

# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ["MODEL_ID"]
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
    base_url=os.getenv("OPENAI_BASE_URL"),
)
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# Change Code Page 设置命令行编码为UTF-8,UTF-8对应的代码页编号是65001,GBK 对应的代码页编号是 936
os.system("chcp 65001")
# 设置文本编码为UTF-8
TEXT_ENCODING = "utf-8"

# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / "skills"

# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results"
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
# 设置记忆目录为工作目录下的 .memory 目录
MEMORY_DIR = WORKDIR / ".memory"
# 创建记忆目录,如果目录不存在
MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
# 设置记忆合并阈值为10
CONSOLIDATE_THRESHOLD = 10
# 设置最大重试次数为10
MAX_RETRIES = 10
# 设置基础延迟时间为500毫秒
BASE_DELAY_MS = 500
# 定义连续发生529错误的最大次数
MAX_CONSECUTIVE_529 = 3
# 从环境变量中获取备用模型的名称
FALLBACK_MODEL = os.getenv("FALLBACK_MODEL")
# 定义升级后的最大token数
ESCALATED_MAX_TOKENS = 64000
# 定义最大恢复重试次数为3
MAX_RECOVERY_RETRIES = 3
# 定义续写提示
CONTINUATION_PROMPT = (
    "输出 token 上限已达到。直接继续 — 不要道歉或复述,从思路中断处接上。"
)
# 有未完成 todo 且连续 N 轮未调用 todo_write 时,向当轮 system 附加提醒
TODO_REMINDER_ROUNDS = 3
# 设置任务目录为工作目录下的 .tasks 目录
TASKS_DIR = WORKDIR / ".tasks"
# 创建任务目录,如果目录不存在
TASKS_DIR.mkdir(exist_ok=True)
# 定时任务持久化文件
DURABLE_PATH = WORKDIR / '.scheduled_tasks.json'
# 队友消息邮箱目录
MAILBOX_DIR = WORKDIR / '.mailboxes'
# 创建队友消息邮箱目录,如果目录不存在
MAILBOX_DIR.mkdir(exist_ok=True)
# git worktree 隔离目录
WORKTREES_DIR = WORKDIR / '.worktrees'
# 创建 worktree 目录,如果目录不存在
WORKTREES_DIR.mkdir(exist_ok=True)
# Lead 收尾屏障:等待队友 result 的最长秒数
+TEAMMATE_WAIT_TIMEOUT = 120
# Stop 时自动屏障最多触发轮数(每轮先 wait 再给 Lead 一次汇总机会)
+TEAMMATE_BARRIER_ROUNDS = 1

20.3. main.py #

main.py

# 导入线程模块
import threading

# 导入 agent_loop 函数从 agent 模块
from agent import agent_loop

# 导入trigger_user_prompt_hooks函数
from hooks import trigger_user_prompt_hooks

# 从cron模块导入start_cron_scheduler和start_queue_processor函数
from cron import start_cron_scheduler, start_queue_processor

# 定义会话历史记录列表
session_history: list = []
# 定义互斥锁用于线程同步
agent_lock = threading.Lock()


# 定义一个函数,带锁执行agent回合,可选参数为用户输入
def run_agent_turn_locked(user_query: str | None = None):
    # 如果用户有输入,将其加入会话历史中
    if user_query is not None:
        session_history.append({"role": "user", "content": user_query})
    # 启动agent主循环,处理会话
    agent_loop(session_history)
    # 获取最新一条历史记录,如果历史为空则为None
    final = session_history[-1] if session_history else None
    # 如果最新一条是助手且消息内容不为空,则打印出来
    if final and final.get("role") == "assistant" and final.get("content"):
        print(final["content"])


# 定义主函数
def main():
     # “定时投递/安排任务”(产生任务),作用是启动定时任务调度器。调度器负责定时、周期性地向任务队列投递任务,它为整个系统源源不断地按计划产生“待处理事件”。
    start_cron_scheduler()
    # “消费/处理任务队列中的实际任务”,作用是不断轮询检查任务队列,一旦发现有待处理的任务,就会调用 agent 处理方法完成任务。因此,这个线程是专门用来“实际执行任务”的。
    start_queue_processor(run_agent_turn_locked, agent_lock)
    # 打印提示信息,告诉用户如何退出
    print("输入问题,回车发送。输入 q 退出。\n")
    # 初始化历史消息列表
    history = []
    # 进入无限循环,不断接收用户输入
    while True:
        try:
            # 获取用户输入,带有提示符
            query = input("\x1b[36m>> \x1b[0m")
        # 捕获 EOFError 或 KeyboardInterrupt 异常(例如 Ctrl+D 或 Ctrl+C)
        except (EOFError, KeyboardInterrupt):
            # 异常时退出循环
            break
+       if not query.strip():
+           continue
        # 如果输入为空,或者用户输入了 'q' 或 'exit',则退出循环
        if query.strip().lower() in ("q", "exit", ""):
            break
        # 触发'UserPromptSubmit'钩子,进行前置处理,返回处理后的用户输入
        query = trigger_user_prompt_hooks(query)
        # 上锁,执行agent回合处理
        with agent_lock:
           run_agent_turn_locked(query)
        # 打印换行
        print()


# 如果当前脚本作为主程序运行,则调用 main 函数
if __name__ == "__main__":
    main()

20.4. prompt.py #

prompt.py

from config import WORKDIR

# 从 skills 模块导入技能注册表 SKILL_REGISTRY
from skills import SKILL_REGISTRY

# 从 config 模块导入工作目录常量 WORKDIR
from config import WORKDIR, MEMORY_INDEX, TEXT_ENCODING

# 定义一个包含提示语片段的字典,键为'identity'
PROMPT_SECTIONS = {
    # 'identity'键对应一个多行字符串,作为智能体的系统身份提示
    "identity": (
        f"你是一个编程 Agent。直接行动,不要解释。"
        f"你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
        f"所有破坏性操作需要用户批准。"
        f"开始多步骤任务前,先用 todo_write 规划步骤;执行过程中及时更新状态。"
        f"遇到复杂子问题时,使用 spawn_subagent 工具派生子Agent。"
        f"上下文过长时可使用 compact 工具。"
        f"bash 支持 run_in_background 参数以在后台运行耗时命令。"
        f"定时任务可使用 schedule_cron / list_crons / cancel_cron。"
        f"遇到复杂子问题时,可使用 spawn_teammate 委派队友。"
+       f"teammate团队协作可使用 spawn_teammate / send_message / check_inbox / await_teammates。"
+       f"spawn_teammate 后应用 await_teammates 等待 result,再向用户汇报;未收到 result 前不要声称完成。"
        f"request_plan 要求队友 submit_plan 后,用 review_plan(request_id, approve) 批准或拒绝;"
        f"任务结束或需回收资源时用 request_shutdown 请求队友优雅退出。"
        f"团队协作:spawn_teammate 启动自主队友(idle 时轮询看板并自动认领任务);"
        f"create_task 创建任务后队友可在 idle 阶段自动认领;"
        f"send_message 向队友发消息;check_inbox 查看队友回信(含协议响应状态)。"
        f"并行改码目录隔离:create_worktree(name, task_id?) 创建独立目录与分支;"
        f"完成后 remove_worktree 或 keep_worktree 保留供审查。"
        f"外部工具:connect_mcp(name) 连接 docs/deploy 等 MCP 服务器;"
        f"连接后可以调用 mcp__前缀工具。"
    ),
    # 'workspace' 键,对应当前的工作目录描述
    "workspace": f"工作目录:{WORKDIR}",
    # 'skill' 键,指明需要完整技术文档时的指引
    "skill": "需要完整技术说明时,使用 load_skill 加载相关文档。",
    # 'memory' 键,指明记忆的使用方式
    "memory": "下方会注入相关记忆正文,请遵守记忆中的用户偏好。用户说「记住」或表达明确偏好时,应提取为记忆。",
}


# 定义函数,将各段拼接成完整的系统提示,skills 为技能描述字符串
def _assemble_system_prompt(skills: str, memories: str) -> str:
    # 初始化包含基本身份与工作目录的列表 sections
    sections = [PROMPT_SECTIONS["identity"], PROMPT_SECTIONS["workspace"]]
    # 若传入的技能描述非空,则将其与技能说明段落加入 sections
    if skills:
        sections.append(f"可用技能:\n{skills}")
        sections.append(PROMPT_SECTIONS["skill"])
        # 若传入的记忆描述非空,则将其与记忆说明段落加入 sections
    if memories:
        sections.append(f"可用记忆:\n{memories}")
        sections.append(PROMPT_SECTIONS["memory"])
    # 用两个换行符拼接所有片段并返回完整的系统提示
    return "\n\n".join(sections)


# 定义一个私有函数,生成所有注册技能的简介文本
def _skills_text() -> str:
    # 若技能注册表为空则返回空字符串
    if not SKILL_REGISTRY:
        return ""
    # 遍历技能注册表,为每项技能生成 markdown 列表条目并拼接返回
    return "\n".join(
        f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()
    )


# 定义一个私有函数,返回记忆索引的文本内容
def _memory_index_text() -> str:
    # 如果 MEMORY_INDEX 文件不存在,则返回空字符串
    if not MEMORY_INDEX.exists():
        return ""
    # 读取 MEMORY_INDEX 文件的全部内容,以指定编码读取,无法解码的部分用 'replace' 替换,去除首尾空白后返回
    return MEMORY_INDEX.read_text(encoding=TEXT_ENCODING, errors="replace").strip()


# 最近一次生成的系统提示内容,初始为 None
_last_prompt: str | None = None
# 记录记忆索引文件最近一次的修改时间,初始为 None
_last_memory_mtime: float | None = None


# 定义公共函数,返回系统提示字符串
def get_system_prompt() -> str:
    # 声明要修改的全局变量 _last_prompt 和 _last_memory_mtime
    global _last_prompt, _last_memory_mtime
    # 如果 MEMORY_INDEX 文件存在,则获取其修改时间;否则赋值为 0.0
    mtime = MEMORY_INDEX.stat().st_mtime if MEMORY_INDEX.exists() else 0.0
    # 如果 _last_prompt 不为 None 且记忆文件修改时间未发生变化
    if _last_prompt is not None and mtime == _last_memory_mtime:
        # 返回缓存的系统提示
        return _last_prompt
    # 更新 _last_memory_mtime 为当前文件修改时间
    _last_memory_mtime = mtime
    # 生成新的系统提示并更新缓存
    _last_prompt = _assemble_system_prompt(_skills_text(), _memory_index_text())
    # 返回新的系统提示
    return _last_prompt


# 定义子任务的系统提示语
SUB_SYSTEM = (
    f"你是一个位于 {WORKDIR} 的编程 Agent,直接行动,不要解释。"
    "你将在 Windows cmd 环境下执行任务。使用 cmd 命令完成任务。"
    "完成分配给你的任务,然后返回简洁摘要。不要继续委派。"
)

20.5. teams.py #

teams.py


# 导入json模块,用于处理JSON数据
import json
# 导入threading模块,用于多线程
import threading
# 导入time模块,用于时间处理
import time
from pathlib import Path
# 当前 Agent 身份(Lead 主线程默认 lead;队友线程启动时设为队友名)
from contextvars import ContextVar
# 导入dataclass模块,用于定义数据类
from dataclasses import dataclass, field
# 从config模块导入常量和对象
from config import (
    WORKDIR,  # 工作目录
    client,  # 大语言模型客户端
    MODEL_ID, # 主模型名称
    DEFAULT_MAX_TOKENS,# 默认最大token数
    MAILBOX_DIR,   # 邮箱目录
    TEXT_ENCODING, # 文本编码方式
+   WORKTREES_DIR, # 工作树目录
+   TEAMMATE_WAIT_TIMEOUT,  # Lead 等待队友 result 超时
)
# 从tools.schema模块导入队友工具列表
from tools.schema import TEAMMATE_TOOLS
# 从history模块导入repair_message_chain函数
from history import repair_message_chain
# 从utils模块导入assistant_message_dict方法
from utils import assistant_message_dict
# 从tasks模块导入scan_unclaimed_tasks, claim_task   
from tasks import scan_unclaimed_tasks, claim_task, load_task,complete_task  
# 定义主管(lead)的名字
LEAD_NAME = "lead"
# 导入random模块,用于生成 request_id
import random
# 队友 LLM 调用最大轮次(防止无限循环)
TEAMMATE_MAX_ROUNDS = 50
# 空闲超时时间(单位:秒)
IDLE_TIMEOUT = 60
# 空闲轮询时间间隔(单位:秒)
IDLE_POLL_INTERVAL = 5
# 当前调用工具的 Agent 名称
current_agent: ContextVar[str] = ContextVar("current_agent", default="lead")
# active_teammates: 队友名 → 线程对象
active_teammates: dict[str, threading.Thread] = {}
# 已 spawn、尚未收到 type=result 的队友(Lead 收尾屏障用)
+pending_teammate_results: set[str] = set()
# agent 最大工作回合数
WORK_MAX_ROUNDS = 10
# MessageBus 文件读写锁
_bus_lock = threading.Lock()
# wait / 屏障轮询间隔
+_WAIT_POLL_INTERVAL = 0.5
# 使用dataclass装饰器定义一个协议状态的数据结构
@dataclass
class ProtocolState:
    # 请求的唯一标识符
    request_id: str
    # 协议类型,可能为 shutdown 或 plan_approval
    type: str       # shutdown | plan_approval
    # 请求发送者
    sender: str
    # 请求目标对象
    target: str
    # 状态,可能为 pending、approved 或 rejected
    status: str     # pending | approved | rejected
    # 附加数据/信息
    payload: str
    # 创建时间,默认为当前时间
    created_at: float = field(default_factory=time.time)

# 用于存储所有挂起的协议请求,键为请求ID,值为协议状态对象
pending_requests: dict[str, ProtocolState] = {}

# 生成新的唯一请求ID
def new_request_id() -> str:
    # 随机生成6位数字,格式化为 req_xxxxxx 的字符串
    return f'req_{random.randint(0, 999999):06d}'
# 消息总线类,用于管理不同agent间消息传递
class MessageBus:
    """基于文件的消息总线。每个 Agent 一个 .jsonl 收件箱,读取即消费。"""
    # 发送消息的方法
    def send(
        self,
        from_agent: str,# 发送者
        to_agent: str,# 接收者
        content: str,# 消息内容
        msg_type: str = "message",# 消息类型
        metadata: dict | None = None, # 附加元数据,默认为 None
    ):
        # 构造消息内容的字典
        msg = {
            "from": from_agent,  # 发送者
            "to": to_agent,  # 接收者
            "content": content,  # 消息内容
            "type": msg_type,  # 消息类型
            "ts": time.time(),  # 时间戳
            'metadata': metadata or {},            # 元数据,默认为空字典
        }
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
        with _bus_lock:
            # 以追加模式写入收件箱
            with open(inbox, "a", encoding=TEXT_ENCODING) as f:
                # 将消息写为json字符串,每条一行
                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
        # 控制台打印消息发送信息
        print(
            f"  \x1b[33m[总线] {from_agent} → {to_agent}[{msg_type}]: {content[:50]}\x1b[0m"
        )

    # 读取某agent收件箱的方法(与 send 共用锁,避免读写竞态丢信)
    def read_inbox(self, agent: str) -> list[dict]:
        # 构造收件箱路径
        inbox = MAILBOX_DIR / f"{agent}.jsonl"
        with _bus_lock:
            # 如果收件箱文件不存在,则返回空列表
            if not inbox.exists():
                return []
            # 读取所有消息,每行解析为json字典
            msgs = [
                json.loads(line)
                for line in inbox.read_text(encoding=TEXT_ENCODING).splitlines()
                if line.strip()
            ]
            # 读取后删除收件箱文件
            inbox.unlink()
        # 返回消息列表
        return msgs


# 实例化消息总线对象
BUS = MessageBus()

# 获取队友 LLM 上下文,只取最新 tail 条消息,并修复 tool 链
def _teammate_llm_context(messages: list, tail: int = 20) -> list:
    # 如果消息数量大于 tail,则取最后 tail 条,否则全部取
    window = messages[-tail:] if len(messages) > tail else list(messages)
    # 修复 tool 链,防止 API 错误,返回修复后的窗口消息
    return repair_message_chain(window)



# 处理队友的收件箱消息,将协议消息(如关机批复、计划审批等)和普通消息区分开
def _process_teammate_inbox(
    teammate_name: str,      # 队友名称
    inbox: list[dict],       # 收件箱消息列表
    messages: list           # 对话消息列表
) -> tuple[bool, list[dict]]:
    # 标记是否需要终止(收到关机请求)
    should_stop = False
    # 用于保存非协议消息
    non_protocol = []
    # 遍历收件箱中的每一条消息
    for msg in inbox:
        # 获取消息类型,默认为 'message'
        msg_type = msg.get('type', 'message')
        # 获取元数据字典,默认为空字典
        meta = msg.get('metadata', {})
        # 获取请求 ID,默认为空字符串
        req_id = meta.get('request_id', '')
        # 如果收到关机请求类型的协议消息
        if msg_type == 'shutdown_request':
            # 回复 Lead,说明已同意关闭
            BUS.send(
                teammate_name,            # 当前队友名称
                LEAD_NAME,                # Lead 名称
                '正在优雅关闭。',           # 消息内容
                'shutdown_response',      # 消息类型
                {'request_id': req_id, 'approve': True},  # 元数据,附带请求 ID 和批准信号
            )
            # 打印紫色的协议日志,说明已同意关闭
            print(f'  \x1b[35m[协议] {teammate_name} 已同意关闭({req_id})\x1b[0m')
            # 标记 should_stop 为 True
            should_stop = True
            # 跳出 for 循环,后续消息不再处理
            break
        # 如果收到计划审批响应
        if msg_type == 'plan_approval_response':
            # 获取是否批准
            approve = meta.get('approve', False)
            # 如果批准
            if approve:
                # 向对话消息列表添加“计划已批准”的提示消息
                messages.append({
                    'role': 'user',
                    'content': '[计划已批准] 请继续执行任务。',
                })
            else:
                # 否则添加“计划被拒绝”与反馈内容
                messages.append({
                    'role': 'user',
                    'content': f"[计划被拒绝] 反馈: {msg['content']}",
                })
            # 忽略后续代码,继续处理下条收件箱消息
            continue
        # 普通消息添加到 non_protocol 列表
        non_protocol.append(msg)
    # 返回是否需要停止循环和所有未被协议处理的普通消息
    return should_stop, non_protocol

# 空闲轮询函数,用于处理队友的空闲状态
def idle_poll(agent_name: str, messages: list, wt_ctx: dict | None = None) -> str:
    # 轮询 IDLE_TIMEOUT 秒,分为若干小轮,每一轮暂停 IDLE_POLL_INTERVAL 秒
    for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
        # 暂停 IDLE_POLL_INTERVAL 秒
        time.sleep(IDLE_POLL_INTERVAL)

        # 读取 agent_name 的 inbox 消息
        inbox = BUS.read_inbox(agent_name)
        # 如果 inbox 非空,说明有新消息
        if inbox:
            # 遍历收件箱中的每一条消息
            for msg in inbox:
                # 判断消息类型是否为关机请求
                if msg.get('type') == 'shutdown_request':
                    # 获取该消息的 request_id,如果没有则为空字符串
                    req_id = msg.get('metadata', {}).get('request_id', '')
                    # 回复 Lead,表示已同意关闭
                    BUS.send(
                        agent_name,
                        LEAD_NAME,
                        '正在优雅关闭。',
                        'shutdown_response',
                        {'request_id': req_id, 'approve': True},
                    )
                    # 打印紫色的协议日志,表示在 idle 时同意关闭
                    print(
                        f'  \x1b[35m[协议] {agent_name} 在 idle 时同意关闭({req_id})\x1b[0m'
                    )
                    # 返回 'shutdown',表示关闭
                    return 'shutdown'

            # 将收到的 inbox 消息以 json 格式写入 messages
            messages.append({
                'role': 'user',
                'content': '<inbox>' + json.dumps(inbox, ensure_ascii=False) + '</inbox>',
            })
            # 打印收到 inbox 消息的提示
            print(f'  \x1b[36m[idle] {agent_name} 收到 inbox 消息\x1b[0m')
            # 返回 'work',表示进入工作状态
            return 'work'
         # 检查是否有未被认领的任务
        unclaimed = scan_unclaimed_tasks()
        # 如果存在未认领的任务
        if unclaimed:
            # 取第一个未认领任务
            task = unclaimed[0]
            # 尝试用 agent_name 认领该任务
            result = claim_task(task.id, agent_name)
            # 如果认领成功(返回值以"已认领"开头)
            if result.startswith('已认领'):
                # 初始化工作目录信息为空字符串
                wt_info = ''
                # 如果任务有 worktree 字段
                if task.worktree:
                    # 构造工作目录路径
                    wt_path = WORKTREES_DIR / task.worktree
                    # 准备带工作目录信息的字符串
                    wt_info = f'\n工作目录: {wt_path}'
                    # 如果传入了 wt_ctx 则保存工作目录路径
                    if wt_ctx is not None:
                        wt_ctx['path'] = str(wt_path)
                # 如果没有 worktree 但 wt_ctx 存在则设置为 None
                elif wt_ctx is not None:
                    wt_ctx['path'] = None
                # 将自动认领的信息记入 messages
                messages.append({
                    'role': 'user',
                    'content': (
                        f'<auto-claimed>任务 {task.id}: '
                        f'{task.subject}</auto-claimed>'
                    ),
                })
                # 打印自动认领任务的绿色提示
                print(f'  \x1b[32m[idle] {agent_name} 自动认领: {task.subject}\x1b[0m')
                # 返回 'work',表示进入工作状态
                return 'work'
            # 如果认领失败,打印黄色失败提示
            print(f'  \x1b[33m[idle] {agent_name} 认领失败: {result}\x1b[0m')
    # 如果超时轮询结束仍未有新消息或任务,打印红色超时提示
    print(f'  \x1b[31m[idle] {agent_name} 超时({IDLE_TIMEOUT}s)\x1b[0m')
    # 返回 'timeout',表示空闲超时
    return 'timeout'

# 启动一个队友线程函数
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
    # 如果请求启动的名字与 Lead 名字重复,则返回错误
    if name == LEAD_NAME:
        return f"错误:不能使用保留名 '{LEAD_NAME}'"
    # 查找当前名字的队友线程是否存在
    existing = active_teammates.get(name)
    # 如果该线程已经存在并且存活,则提示已存在
    if existing and existing.is_alive():
        return f"队友 '{name}' 已存在且仍在运行"
    # 如果线程对象存在但未存活,将其从 active_teammates 移除
    if existing:
        active_teammates.pop(name, None)
        # 打印黄色日志说明旧线程被移除,可以重新启动
        print(f"  \x1b[33m[队友] {name} 旧线程已退出,允许重新启动\x1b[0m")
    # 构建 system prompt,指示 AI 队友身份及工作指令
    system = (
        f"你是 '{name}',角色为 {role}。"
        f"工作目录: {WORKDIR}。使用 Windows cmd 命令。"
        f"检查收件箱中的协议消息(shutdown_request、plan_approval_response等)。"
        f"需要 Lead 审批时,使用 submit_plan 提交计划。"
        f"使用工具完成任务。你可以从看板列出并认领任务。"
        f"若任务绑定了 worktree,bash/read/write/edit/glob 会在该目录下执行。"
    )
    # 队友线程主执行函数
    def run():
        # 延迟导入,避免与 handlers 循环依赖
        from tools.executor import execute_tool
        from tools.handlers import  run_bash, run_read, run_write, run_edit, run_glob
        from tools.schema import TOOLS
         # worktree 工作目录上下文(认领绑定任务后自动切换)
        wt_ctx: dict[str, str | None] = {'path': None}
        # 定义一个函数用于获取当前 worktree 工作目录的 Path 对象,如果没有设置则返回 None
        def _wt_cwd() -> Path | None:
            # 从 wt_ctx 字典中获取 'path' 的值
            p = wt_ctx['path']
            # 如果 p 存在则返回 Path(p),否则返回 None
            return Path(p) if p else None
         # 定义一个函数,用于处理队友线程的具体工具调用逻辑
        def teammate_execute_tool(tname: str, args: dict) -> str:
            # 获取当前工作目录
            cwd = _wt_cwd()
            # 判断工具类型是否为 'bash' 命令行
            if tname == 'bash':
                # 执行 bash 命令行工具
                return run_bash(
                    # 从参数获取命令字符串
                    args.get('command', ''),
                    # 是否在后台运行
                    run_in_background=bool(args.get('run_in_background', False)),
                    # 指定工作目录
                    cwd=cwd,
                )
            # 判断工具类型为 'read_file',读取文件
            if tname == 'read_file':
                # 调用文件读取工具,带参数 limit 和工作目录
                return run_read(args.get('path', ''), limit=args.get('limit'), cwd=cwd)
            # 判断工具类型为 'write_file',写入文件
            if tname == 'write_file':
                # 调用文件写入工具,传入路径和内容
                return run_write(args.get('path', ''), args.get('content', ''), cwd=cwd)
            # 判断工具类型为 'edit_file',编辑文件内容
            if tname == 'edit_file':
                # 调用编辑文件工具,替换旧内容为新内容
                return run_edit(
                    args.get('path', ''),
                    args.get('old_text', ''),
                    args.get('new_text', ''),
                    cwd=cwd,
                )
            # 判断工具类型为 'glob',查找匹配文件
            if tname == 'glob':
                # 通过模式匹配查找文件路径
                return run_glob(args.get('pattern', ''), cwd=cwd)
            # 判断工具类型为 'claim_task',认领任务
            if tname == 'claim_task':
                # 调用认领任务方法
                result = claim_task(args.get('task_id', ''), owner=name)
                # 如果认领成功,则尝试切换 worktree 工作目录
                if result.startswith('已认领'):
                    # 载入任务信息
                    task = load_task(args['task_id'])
                    # 如果任务绑定专用 worktree,更新 worktree 目录路径
                    wt_ctx['path'] = (
                        str(WORKTREES_DIR / task.worktree) if task.worktree else None
                    )
                # 返回认领结果
                return result
            # 判断工具类型为 'complete_task',完成任务
            if tname == 'complete_task':
                # 调用完成任务方法
                result = complete_task(args.get('task_id', ''))
                # 完成后清空当前 worktree 路径
                wt_ctx['path'] = None
                # 返回任务完成结果
                return result
            # 如果是其它类型工具,通用工具执行接口
            return execute_tool(tname, args)
        # 绑定当前线程的 Agent 身份,防止伪造  from_agent
        identity_token = current_agent.set(name)
        # 初始化消息,prompt作为第一条user消息
        messages = [{"role": "user", "content": prompt}]
        # 用于记录退出原因
        exit_reason = ""
         # try-finally 保证安全清理退出
        try:
            # 无限循环,直到线程被停止
            while True:
                # 若消息数量不超过 3 条,插入身份声明消息
                if len(messages) <= 3:
                    messages.insert(0, {
                        'role': 'user',
                        'content': (
                            f"<identity>你是 '{name}',角色: {role}。"
                            f"请继续你的工作。</identity>"
                        ),
                    })   
                # 初始化是否退出循环标志
                should_shutdown = False  
                # 进入最大循环轮数限制
                for _ in range(WORK_MAX_ROUNDS):
                    # 读取当前队友的收件箱消息
                    inbox = BUS.read_inbox(name)    
                    # 如果收件箱有消息,则进行处理
                    if inbox:
                        # 处理协议消息和非协议消息
                        should_stop, non_protocol = _process_teammate_inbox(
                            name, inbox, messages,
                        )
                        # 若收到关闭信号,则设置标志并跳出循环
                        if should_stop:
                            should_shutdown = True
                            break
                        # 如果有非协议消息,将其添加到对话消息列表
                        if non_protocol:
                            messages.append({
                                'role': 'user',
                                'content': (
                                    f'<inbox>{json.dumps(non_protocol, ensure_ascii=False)}</inbox>'
                                ),
                            })  
                    # 通过 OpenAI 客户端请求 LLM 产生回复
                    try:
                        response = client.chat.completions.create(
                            model=MODEL_ID,
                            messages=[
                                {'role': 'system', 'content': system},
                                *_teammate_llm_context(messages),
                            ],
                            tools=TOOLS,
                            max_tokens=DEFAULT_MAX_TOKENS,
                        )
                    # 捕捉 API 调用异常,记录错误与退出原因
                    except Exception as e:
                        exit_reason = f'LLM 错误: {type(e).__name__}: {e}'
                        print(f'  \x1b[31m[队友] {name} {exit_reason}\x1b[0m')
                        should_shutdown = True
                        break   
                     # 获取 assistant 角色的回复消息
                    assistant = response.choices[0].message
                    # 将 assistant 消息加入消息列表
                    messages.append(assistant_message_dict(assistant))

                    # 如果 assistant 没有调用任何工具,跳出当前大循环
                    if not assistant.tool_calls:
                        break

                    # 遍历所有工具调用,执行每一个工具
                    for tool_call in assistant.tool_calls:
                        # 获取工具名称
                        tname = tool_call.function.name
                        # 解析工具参数
                        args = json.loads(tool_call.function.arguments or '{}')
                        # 执行工具(含 worktree cwd 切换)
                        output = teammate_execute_tool(tname, args)
                        # 回复工具调用的结果消息
                        messages.append({
                            'role': 'tool',
                            'tool_call_id': tool_call.id,
                            'content': output,
                        })
                # 如果应当退出主循环,则跳出外层 while
                if should_shutdown:
                    break     
                # 进入空闲轮询(自动认领时可设置 wt_ctx)
                idle_result = idle_poll(name, messages, wt_ctx)
                # 如果收到关闭信号,则跳出循环
                if idle_result == 'shutdown':
                    break
                # 如果长时间未响应,设置超时退出原因
                if idle_result == 'timeout':
                    exit_reason = f'idle 超时({IDLE_TIMEOUT}s)'
                    break
            # 组织总结性回复,默认优先用 exit_reason
            summary = exit_reason or '完成。'
            # 从最后的 assistant 消息中找一条有内容的作为总结
            for msg in reversed(messages):
                if msg.get('role') == 'assistant' and msg.get('content'):
                    content = msg['content']
                    if isinstance(content, str) and content.strip():
                        summary = content
                        break

            # 向 Lead 汇报最终结果消息
            BUS.send(name, LEAD_NAME, summary, 'result')
            # 控制台输出队友结束日志
            print(f'  \x1b[32m[队友] {name} 已结束\x1b[0m')    
        finally:
            current_agent.reset(identity_token)
            active_teammates.pop(name, None)   
    # 创建线程对象,目标为 run 函数,设置为守护线程
    thread = threading.Thread(target=run, daemon=True)
    # 将该线程注册到 active_teammates 字典
    active_teammates[name] = thread
    # 标记待回收 result,供 Lead 收尾屏障等待
+   pending_teammate_results.add(name)
    # 启动线程
    thread.start()
    # 启动后打印青色控制台日志
    print(f"  \x1b[36m[队友] 已启动 {name},角色 {role}\x1b[0m")
+   return f"队友 '{name}' 已启动,角色 {role}。完成后将向 lead 发送 result;可用 await_teammates 等待。"

# 将收件箱消息格式化为文本字符串(含 type / request_id,便于 review_plan)
def format_inbox_messages(msgs: list[dict]) -> str:
    lines = []
    for m in msgs:
         # 从消息字典中获取 'metadata' 字段,没有则默认为空字典
        meta = m.get('metadata', {})
        # 从 metadata 字典中获取 'request_id' 字段,没有则默认为空字符串
        req_id = meta.get('request_id', '')
        # 如果request_id存在,则格式为“[类型 req:request_id]”,否则为“[类型]”
        tag = f" [{m.get('type', 'message')} req:{req_id}]" if req_id else f" [{m.get('type', 'message')}]"
        # 生成包含来源、标签和内容(截断到前200个字符)的字符串并加入lines列表
        lines.append(f"来自 {m['from']}{tag}: {m['content'][:200]}")
    # 将所有格式化好的消息行用换行拼接,最前面加上“[收件箱]”标题,作为最终返回的字符串
    return "[收件箱]\n" + "\n".join(lines)


# 匹配响应,根据 request_id 关联并校验响应类型
def match_response(response_type: str, request_id: str, approve: bool) -> None:
    # 通过 request_id 获取协议状态
    state = pending_requests.get(request_id)
    # 未找到对应协议请求
    if not state:
        print(f'  \x1b[31m[协议] 未知 request_id: {request_id}\x1b[0m')
        return
    # 校验 shutdown 类型的请求响应类型是否正确
    if state.type == 'shutdown' and response_type != 'shutdown_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 shutdown_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 校验 plan_approval 类型的请求响应类型是否正确
    if state.type == 'plan_approval' and response_type != 'plan_approval_response':
        print(
            f'  \x1b[31m[协议] 类型不匹配: 期望 plan_approval_response,'
            f'实际 {response_type}\x1b[0m'
        )
        return
    # 判断该请求状态是否已处理过,避免重复处理
    if state.status != 'pending':
        print(f'  \x1b[33m[协议] {request_id} 已是 {state.status},忽略重复响应\x1b[0m')
        return
    # 根据approve参数设置状态为通过或拒绝
    state.status = 'approved' if approve else 'rejected'
    # 选择显示的icon(勾或叉)
    icon = '✓' if approve else '✗'
    # 通过或拒绝对应不同颜色
    color = '32' if approve else '31'
    # 打印协议处理结果信息
    print(
        f'  \x1b[{color}m[协议] {state.type} {icon} '
        f'({request_id}: {state.status})\x1b[0m'
    )

+def _mark_results_received(msgs: list[dict]) -> None:
+   """根据收件箱中的 result 消息,清除 pending_teammate_results。"""
+   for msg in msgs:
+       if msg.get('type') == 'result':
+           sender = msg.get('from', '')
+           if sender in pending_teammate_results:
+               pending_teammate_results.discard(sender)
+               print(f'  \x1b[32m[屏障] 已收到 {sender} 的 result\x1b[0m')


+def list_pending_teammates() -> list[str]:
+   """返回仍在等待 result 的队友名。"""
+   return sorted(pending_teammate_results)


# 定义函数,读取 Lead 收件箱。参数 route_protocol 表示是否需要路由协议响应。
def consume_lead_inbox(route_protocol: bool = True) -> list[dict]:
    # 读取 LEAD_NAME 的收件箱消息列表
    msgs = BUS.read_inbox(LEAD_NAME)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # result 到达即解除屏障等待
+   _mark_results_received(msgs)
    # 如果需要路由协议响应
    if route_protocol:
        # 遍历所有消息
        for msg in msgs:
            # 从消息中获取 metadata,默认为空字典
            meta = msg.get('metadata', {})
            # 从 metadata 中获取 request_id,默认为空字符串
            req_id = meta.get('request_id', '')
            # 获取消息类型
            msg_type = msg.get('type', '')
            # 如果有 request_id 且消息类型以 "_response" 结尾
            if req_id and msg_type.endswith('_response'):
                # 从 metadata 中获取 approve 字段,默认为 False
                approve = meta.get('approve', False)
                # 调用 match_response 方法,路由协议响应
                match_response(msg_type, req_id, approve)
    # 返回读取到的所有消息
    return msgs


# 注入lead的收件箱消息到对话消息列表
def inject_lead_inbox(messages: list) -> int:
    # 调用consume_lead_inbox读取lead收件箱消息,开启路由协议
    inbox = consume_lead_inbox(route_protocol=True)
    # 如果没有消息,返回0
    if not inbox:
+       return 0
    # 把收件箱内容格式化为一条user消息,添加到对话消息列表
    messages.append({'role': 'user', 'content': format_inbox_messages(inbox)})
    # 控制台打印注入了多少条消息
    print(f'  \x1b[33m[收件箱] 已注入 {len(inbox)} 条消息\x1b[0m')
+   return len(inbox)


# 判断指定名字的队友线程是否在运行
def is_teammate_running(name: str) -> bool:
    # 从 active_teammates 字典中获取指定名字的线程对象
    thread = active_teammates.get(name)
    # 判断线程对象是否存在且线程是否存活
    return thread is not None and thread.is_alive()


+def wait_for_teammates(
+   names: list[str] | None = None,
+   timeout: float | None = None,
+) -> str:
+   """
+   阻塞等待指定(或全部 pending)队友的 result。
+   等待期间轮询消费 lead 收件箱中的消息,返回汇总文本供 tool_result 使用。
+   """
+   timeout = TEAMMATE_WAIT_TIMEOUT if timeout is None else timeout
+   targets = set(names) if names else set(pending_teammate_results)
+   if not targets:
+       return '没有待等待的队友 result。'
    # 只等待仍在 pending 中的名字
+   targets &= pending_teammate_results
+   if not targets:
+       return '指定队友的 result 均已收到。'

+   print(
+       f'  \x1b[35m[屏障] 等待队友 result: {", ".join(sorted(targets))}'
+       f'(超时 {timeout:.0f}s)\x1b[0m'
+   )
+   deadline = time.time() + timeout
+   collected: list[dict] = []
+   while time.time() < deadline:
+       inbox = consume_lead_inbox(route_protocol=True)
+       if inbox:
+           collected.extend(inbox)
+       remaining = targets & pending_teammate_results
+       if not remaining:
+           break
        # 线程已死但尚未读到 result:再读一轮后由调用方 prune
+       if all(not is_teammate_running(n) for n in remaining):
+           inbox = consume_lead_inbox(route_protocol=True)
+           if inbox:
+               collected.extend(inbox)
            # 仍无 result 则视为异常结束,解除挂起以免死等
+           for n in list(remaining):
+               if n in pending_teammate_results and not is_teammate_running(n):
+                   pending_teammate_results.discard(n)
+                   print(f'  \x1b[33m[屏障] {n} 已结束但无 result,解除等待\x1b[0m')
+           break
+       time.sleep(_WAIT_POLL_INTERVAL)

+   remaining = sorted(targets & pending_teammate_results)
+   parts = []
+   if collected:
+       parts.append(format_inbox_messages(collected))
+   if remaining:
+       parts.append(
+           f'等待超时或未完成:仍缺少 result → {", ".join(remaining)}。'
+           f'可再次 await_teammates,或 request_shutdown。'
+       )
+   else:
+       parts.append(f'已收到全部 result:{", ".join(sorted(targets))}。')
+   return '\n'.join(parts)


+def apply_teammate_stop_barrier(messages: list) -> str | None:
+   """
+   Lead 无 tool_calls 准备 Stop 时调用。
+   若仍有 pending result:阻塞等待 → 注入收件箱 → 返回应追加的 user 提示;
+   无 pending 则返回 None(允许真正退出)。
+   """
+   pending = list_pending_teammates()
+   if not pending:
        # 退出前再吸一次迟到的信(不强制 continue,除非确实有新信)
+       n = inject_lead_inbox(messages)
+       if n:
+           return (
+               '[队友屏障] 退出前从收件箱注入了迟到消息,请据此更新回复后再结束。'
+           )
+       return None

+   status = wait_for_teammates(names=pending, timeout=TEAMMATE_WAIT_TIMEOUT)
+   inject_lead_inbox(messages)
+   still = list_pending_teammates()
+   hint = (
+       f'[队友屏障] {status}\n'
+       '请根据收件箱中的队友结果汇总回复用户;'
+       '不要在未看到 result 时声称任务已完成。'
+   )
+   if still:
+       hint += f'\n仍在等待: {", ".join(still)}'
+   print(f'  \x1b[35m[屏障] Stop 已拦截,注入汇总后继续一轮\x1b[0m')
+   return hint


# 定义函数,读取某 Agent 收件箱。
def consume_inbox(agent_name: str) -> list[dict]:
    # 读取指定 agent 的收件箱消息列表
    msgs = BUS.read_inbox(agent_name)
    # 如果消息列表为空,则直接返回空列表
    if not msgs:
        return []
    # Lead 侧消费时同样解除 pending
+   if agent_name == LEAD_NAME:
+       _mark_results_received(msgs)
    # 返回读取到的所有消息
    return msgs

# 请求优雅关闭某队友,向其发起关机协议消息
def run_request_shutdown(teammate: str) -> str:
    # 生成新的唯一请求 ID
    req_id = new_request_id()
    # 在 pending_requests 字典中记录关机请求的 protocol 状态
    pending_requests[req_id] = ProtocolState(
        request_id=req_id,   # 请求编号
        type='shutdown',     # 协议类型
        sender=LEAD_NAME,    # 发起者为 Lead
        target=teammate,     # 目标为指定队友名
        status='pending',    # 当前状态为等待处理
        payload='',          # 没有关联负载
    )
    # 向队友发送关机请求消息,包括元数据中的请求 ID
    BUS.send(LEAD_NAME, teammate, '请优雅关闭。', 'shutdown_request', {'request_id': req_id})
    # 打印带颜色的控制台日志,显示已发送关机请求
    print(f'  \x1b[35m[协议] shutdown_request → {teammate}({req_id})\x1b[0m')
    # 正常情况下返回已发送请求的说明
    return f'已向 {teammate} 发送关闭请求(req: {req_id})'

# 要求队友提交计划,即发消息让队友编写任务计划
def run_request_plan(teammate: str, task: str) -> str:
    # 向队友收件箱发送请求,要求其提交计划,消息类型为普通 message
    BUS.send(LEAD_NAME, teammate, f'请提交计划: {task}', 'message')
    # 否则返回已成功请求队友提交计划
    return f'已要求 {teammate} 提交计划'


# 队友向 Lead 提交计划以待审批
def run_submit_plan(from_name: str, plan: str) -> str:
    # 函数说明文档
    """队友向 Lead 提交计划待审批。"""
    # 生成计划审批请求的新 request_id
    req_id = new_request_id()
    # 在 pending_requests 保存本次请求的状态对象
    pending_requests[req_id] = ProtocolState(
        request_id=req_id,      # 当前 request_id
        type='plan_approval',   # 协议类型为“计划审批”
        sender=from_name,       # 谁发的
        target=LEAD_NAME,       # 发给 Lead
        status='pending',       # 当前状态为等待审批
        payload=plan,           # 计划内容
    )
    # 通过 BUS 发送计划审批请求协议消息,content 是计划内容
    BUS.send(from_name, LEAD_NAME, plan, 'plan_approval_request', {'request_id': req_id})
    # 返回提示文本,包含 request_id
    return f'计划已提交({req_id})。等待审批...'


# Lead 对队友提交的计划进行审批(批准或拒绝),并进行响应
def run_review_plan(request_id: str, approve: bool, feedback: str = '') -> str:
    # 从 pending_requests 字典中查找请求状态对象
    state = pending_requests.get(request_id)
    # 如果找不到该请求,返回提示
    if not state:
        return f'未找到请求 {request_id}'
    # 如果该请求已经不在待处理状态,说明已操作过,返回对应状态
    if state.status != 'pending':
        return f'请求 {request_id} 已是 {state.status}'
    # 根据 approve 设定当前请求的最终状态
    state.status = 'approved' if approve else 'rejected'
    # 向队友发回计划审批协议响应,带上审批反馈和结果
    BUS.send(
        LEAD_NAME,#发送者
        state.sender,#接收者
        feedback or ('已批准' if approve else '已拒绝'),#消息内容
        'plan_approval_response',#消息类型
        {'request_id': request_id, 'approve': approve},#元数据
    )
    # 设定审批通过或者拒绝的标志字符
    icon = '✓' if approve else '✗'
    # 控制台输出审批过程日志,带颜色
    print(f'  \x1b[32m[协议] 计划 {icon}({request_id})\x1b[0m')
    # 返回描述审批结果的字符串
    return f"计划{'已批准' if approve else '已拒绝'}({request_id})"

20.6. handlers.py #

tools/handlers.py

# 导入os模块,用于与操作系统交互
import os

# 导入subprocess模块,用于执行子进程
import subprocess

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output

# 从utils模块导入decode_subprocess_output函数,用于解码子进程输出
from utils import decode_subprocess_output, safe_path

# 从config模块导入TEXT_ENCODING和WORKDIR,用于指定文本编码和工作目录
from config import TEXT_ENCODING, WORKDIR

# 从config模块导入文本编码配置
from config import TEXT_ENCODING, WORKDIR

# 从skills模块导入load_skill函数
from skills import load_skill

# 从tasks模块导入create_task函数
from tasks import create_task, list_tasks, get_task, claim_task, complete_task
from mcp import run_connect_mcp
# 从cron模块导入schedule_job, cancel_job, scheduled_jobs, cron_lock函数
from cron import schedule_job, cancel_job, scheduled_jobs, cron_lock
from pathlib import Path
# 导入操作系统相关模块
import glob as g

from worktrees import (
    run_create_worktree,
    run_remove_worktree,
    run_keep_worktree,
)
# 从 teams 模块导入 spawn_teammate_thread,BUS, LEAD_NAME, format_inbox_messages
from teams import (spawn_teammate_thread,current_agent,BUS,LEAD_NAME,is_teammate_running,format_inbox_messages,consume_inbox,
run_request_shutdown,
run_request_plan,#要求队友提交计划供审核。
run_submit_plan,#向 Lead 提交计划待审批。
run_review_plan,#按 request_id 批准或拒绝已提交的计划。
+wait_for_teammates,
)

# 定义run_bash函数,接受一个字符串类型参数command,并返回字符串
def run_bash(command: str, run_in_background: bool = False, cwd: Path | None = None) -> str:
    # 如果当前操作系统是Windows且命令是'date'(忽略前后空白并转为小写)
    if os.name == "nt" and command.strip().lower() == "date":
        # 将命令更改为Windows下同时输出日期和时间的命令
        command = "date /t & time /t"
    # 定义危险命令的列表
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    # 如果命令中包含任何一个危险命令
    if any(d in command for d in dangerous):
        # 返回错误提示,拦截执行危险命令
        return "错误:危险命令已被拦截"
    # 尝试执行命令,捕获异常
    try:
        # 使用subprocess.run运行命令
        r = subprocess.run(
            command,  # 要执行的命令
            shell=True,  # 在shell中执行
            cwd=str(cwd) if cwd else os.getcwd(),  # 当前工作目录设置为当前路径
            capture_output=True,  # 捕获标准输出和标准错误
            timeout=120,  # 超时时间为120秒
        )
        # 解码输出内容,合并stdout和stderr,并去除首尾空白
        out = decode_subprocess_output((r.stdout or b"") + (r.stderr or b"")).strip()
        # 返回输出内容的前50000个字符,如果无输出则返回'(无输出)'
        return out[:50000] if out else "(无输出)"
    # 捕获超时异常,返回超时错误信息
    except subprocess.TimeoutExpired:
        return "错误:超时(120 秒)"
    # 捕获文件未找到或OS错误,返回详细错误信息
    except (FileNotFoundError, OSError) as e:
        return f"错误:{e}"


# 定义读取文件的处理函数,参数为文件路径和可选的行数限制
def run_read(path: str, limit: int | None = None, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取文件路径,按指定编码读取内容并按行分割
        lines = safe_path(path, cwd).read_text(encoding=TEXT_ENCODING).splitlines()
        # 如果有行数限制且文件总行数超过限制
        if limit and limit < len(lines):
            # 截取前limit行,并在最后添加提示剩余行的说明
            lines = lines[:limit] + [f"...(还有 {len(lines) - limit} 行)"]
        # 将行列表拼接为字符串并返回
        return "\n".join(lines)
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义写文件函数,参数为路径和内容
def run_write(path: str, content: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path校验并获取目标文件路径
        file_path = safe_path(path, cwd)
        # 确保文件父目录存在,若不存在则创建
        file_path.parent.mkdir(parents=True, exist_ok=True)
        # 按指定编码写入内容到文件
        file_path.write_text(content, encoding=TEXT_ENCODING)
        # 返回写入成功的提示语句,包括字节数
        return f"已写入 {len(content)} 字节到 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义编辑文件函数,参数为路径、待替换旧文本、和新文本
def run_edit(path: str, old_text: str, new_text: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 使用safe_path获取文件路径
        file_path = safe_path(path, cwd)
        # 读取文件的全部内容(默认编码)
        text = file_path.read_text()
        # 如果旧文本不在内容中
        if old_text not in text:
            # 返回错误提示,未找到指定文本
            return f"错误:在 {path} 中未找到指定文本"
        # 替换第一次出现的旧文本为新文本,并写回文件
        file_path.write_text(
            text.replace(old_text, new_text, 1), encoding=TEXT_ENCODING
        )
        # 返回编辑成功的提示
        return f"已编辑 {path}"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义glob通配符路径匹配函数,参数为模式
def run_glob(pattern: str, cwd: Path | None = None) -> str:
    # 尝试执行以下代码
    try:
        # 初始化结果列表
        results = []
        # 遍历所有匹配到的路径,根目录为WORKDIR
        for match in g.glob(pattern, root_dir=WORKDIR):
            # 检查匹配到的路径是否相对WORKDIR安全
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                # 将安全的匹配结果加入结果列表
                results.append(match)
        # 如果结果非空,拼接为字符串返回,否则返回无匹配的提示
        return "\n".join(results) if results else "(无匹配)"
    # 捕获所有异常并返回错误信息
    except Exception as e:
        return f"错误:{e}"


# 定义全局变量CURRENT_TODOS,用于存储当前的任务列表,类型为list[dict]
CURRENT_TODOS: list[dict] = []


def todo_update_reminder(rounds_since: int, threshold: int) -> str | None:
    """有活跃 todo 且超过 threshold 轮未更新时,返回当轮 system 附加段;否则 None。"""
    if rounds_since < threshold or not CURRENT_TODOS:
        return None
    active = [t for t in CURRENT_TODOS if t.get("status") in ("pending", "in_progress")]
    if not active:
        return None
    lines = [
        f"[todo提醒] 已有未完成任务,且连续 {rounds_since} 轮未调用 todo_write,请更新进度。",
        "当前任务:",
    ]
    for t in CURRENT_TODOS:
        lines.append(f"- [{t.get('status', '?')}] {t.get('content', '')}")
    return "\n".join(lines)


# 定义run_todo_write函数,参数为todos列表,返回字符串
def run_todo_write(todos: list) -> str:
    # 声明使用全局变量CURRENT_TODOS
    global CURRENT_TODOS
    # 遍历todos列表,获取每个任务及其索引
    for i, t in enumerate(todos):
        # 如果任务中缺少content或status字段
        if "content" not in t or "status" not in t:
            # 返回错误提示,指出缺少字段的位置
            return f"错误:todos[{i}] 缺少 content 或 status"
        # 如果任务的status不是允许的三种状态
        if t["status"] not in ("pending", "in_progress", "completed"):
            # 返回错误提示,指出状态无效
            return f"错误:todos[{i}] 的状态无效:{t['status']}"
    # 校验全部通过后,更新全局任务列表
    CURRENT_TODOS = todos
    # 初始化显示用的lines列表,第一行为标题,并加黄颜色
    lines = ["\n\x1b[33m## 当前任务\x1b[0m"]
    # 遍历所有当前任务
    for t in CURRENT_TODOS:
        # 根据任务状态,选择不同的彩色标签
        icon = {
            "pending": "\x1b[33m等待中\x1b[0m",
            "in_progress": "\x1b[36m处理中\x1b[0m",
            "completed": "\x1b[32m已完成\x1b[0m",
        }[t["status"]]
        # 将格式化后的任务内容和标签加入lines
        lines.append(f"  [{icon}] {t['content']}")
    # 将所有内容组合成字符串打印到标准输出
    print("\n".join(lines))
    # 返回已更新任务数的字符串提示
    return f"已更新 {len(CURRENT_TODOS)} 个任务"


# 定义run_create_task函数,用于创建新任务
def run_create_task(
    # 参数:任务主题、描述(默认空字符串)、阻塞依赖列表(默认None)
    subject: str,
    description: str = "",
    blockedBy: list[str] | None = None,
    # 函数返回类型为字符串
) -> str:
    # 调用create_task函数创建任务对象
    task = create_task(subject, description, blockedBy)
    # 若存在阻塞依赖则格式化为依赖描述字符串,否则为空字符串
    deps = f"(blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
    # 以蓝色ANSI颜色打印创建成功的任务主题及依赖信息
    print(f"  \x1b[34m[创建] {task.subject}{deps}\x1b[0m")
    # 返回已创建任务的ID、主题及依赖信息提示
    return f"已创建 {task.id}: {task.subject}{deps}"


# 定义run_list_tasks函数,用于列出所有任务,返回字符串
def run_list_tasks() -> str:
    # 调用list_tasks获取所有任务列表
    tasks = list_tasks()
    # 如果任务列表为空
    if not tasks:
        # 返回暂无任务的提示信息
        return "暂无任务。使用 create_task 添加。"
    # 初始化用于存储显示行的空列表
    lines = []
    # 遍历所有任务
    for t in tasks:
        # 根据任务状态获取对应的中文状态标签
        icon = {
            # pending状态对应“等待中”
            "pending": "等待中",
            # in_progress状态对应“处理中”
            "in_progress": "处理中",
            # completed状态对应“已完成”
            "completed": "已完成",
            # 按任务状态取值,未知状态则返回问号
        }.get(t.status, "?")
        # 若任务有阻塞依赖则格式化依赖信息,否则为空字符串
        deps = f"(blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
        # 若任务有负责人则格式化负责人信息,否则为空字符串
        owner = f" [{t.owner}]" if t.owner else ""
        # 如果有绑定 worktree,追加显示
        wt = f' (wt:{t.worktree})' if t.worktree else ''
        # 拼接每条任务信息并加入结果列表
        lines.append(f'  {icon} {t.id}: {t.subject} [{t.status}]{owner}{deps}{wt}')
    # 将所有行用换行符拼接成字符串后返回
    return "\n".join(lines)


# 定义run_get_task函数,按任务ID获取任务详情,返回字符串
def run_get_task(task_id: str) -> str:
    # 尝试获取指定ID的任务
    try:
        # 调用get_task返回任务详情
        return get_task(task_id)
    # 捕获任务文件不存在的异常
    except FileNotFoundError:
        # 返回未找到任务的错误提示
        return f"错误:未找到任务 {task_id}"


# 定义run_claim_task函数,认领指定任务,返回字符串
def run_claim_task(task_id: str, owner: str = "lead") -> str:
    # 以agent为负责人认领该任务并返回结果
    return claim_task(task_id, owner=owner or LEAD_NAME)


# 定义run_complete_task函数,完成指定任务,返回字符串
def run_complete_task(task_id: str) -> str:
    # 调用complete_task完成该任务并返回结果
    return complete_task(task_id)


# 定义调度定时(cron)任务的函数
def run_schedule_cron(
    cron: str,  # cron表达式
    prompt: str,  # 提示词
    recurring: bool = True,  # 是否循环
    durable: bool = True,  # 是否持久化
) -> str:  # 返回结果
    # 调用 schedule_job 安排定时任务,返回结果
    result = schedule_job(cron, prompt, recurring, durable)
    # 如果结果是字符串,表示出错
    if isinstance(result, str):
        # 返回错误提示
        return f"错误:{result}"
    # 返回调度成功信息,包括 id、表达式和 prompt
    return f"已调度 {result.id}: '{cron}' → {prompt}"


# 定义列出所有 cron 定时任务的函数
def run_list_crons() -> str:
    # 使用锁确保并发安全,读取所有 scheduled_jobs
    with cron_lock:
        jobs = list(scheduled_jobs.values())
    # 如果没有任何任务,返回空提示
    if not jobs:
        return "暂无 cron 任务。使用 schedule_cron 添加。"
    # 初始化结果字符串列表
    lines = []
    # 遍历所有定时任务
    for j in jobs:
        # 根据 recurring 标记区分“循环”或“单次”
        tag = "循环" if j.recurring else "单次"
        # 根据 durable 标记区分“持久化”或“会话”
        dur = "持久化" if j.durable else "会话"
        # 拼接任务的信息字符串并加入列表
        lines.append(f"  {j.id}: '{j.cron}' → {j.prompt[:40]} [{tag}, {dur}]")
    # 返回所有任务拼接后的字符串
    return "\n".join(lines)


# 定义取消定时任务的函数
def run_cancel_cron(job_id: str) -> str:
    # 调用 cancel_job 并返回结果
    return cancel_job(job_id)

# 定义函数,启动一个队友 agent 线程
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
    # 调用 spawn_teammate_thread 启动队友 agent,传递名字、角色和 prompt
    return spawn_teammate_thread(name, role, prompt)

# 定义函数,通过消息总线发送消息给指定对象
def run_send_message(to: str, content: str) -> str:
    # 发送方固定为当前会话身份,不可伪造
    from_agent = current_agent.get()
    # 使用 BUS 发送消息
    BUS.send(from_agent, to, content)
    if to != LEAD_NAME and not is_teammate_running(to):
        # 返回已写入收件箱但队友未运行的提示
        return (
            f"已从 {from_agent} 写入 {to} 的收件箱,但该队友未在运行。"
            f"请 spawn_teammate 重启后才会被读取。"
        )
    # 返回发送结果的字符串说明
    return f"已从 {from_agent} 发送给 {to}"


# 定义函数,仅允许读取当前 Agent 自己的收件箱(Lead 只能读 lead)
def run_check_inbox() -> str:
    # 当前会话身份
    name = current_agent.get()
    # Lead 与队友都只能消费自己的收件箱,避免抢走对方消息
    msgs = consume_inbox(name)
    # 如果收件箱消息为空,返回提示信息
    if not msgs:
        return f"({name} 的收件箱为空)"
    # 如果收件箱有消息,格式化这些消息并返回
    return format_inbox_messages(msgs)


+def run_await_teammates(names: list[str] | None = None, timeout: float | None = None) -> str:
+   """阻塞等待队友 result;仅 Lead 应调用。"""
+   if current_agent.get() != LEAD_NAME:
+       return '错误:仅 lead 可调用 await_teammates'
+   return wait_for_teammates(names=names, timeout=timeout)


# 定义TOOL_HANDLERS字典,将'bash'设置为run_bash函数
TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
    "todo_write": run_todo_write,
    "load_skill": load_skill,  # 按名称加载技能的完整内容
    "create_task": run_create_task,  # 创建新任务
    "list_tasks": run_list_tasks,  # 列出所有任务
    "get_task": run_get_task,  # 按 ID 获取任务完整详情
    "claim_task": run_claim_task,  # 认领 pending 任务,设置 owner 并改为 in_progress
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "complete_task": run_complete_task,  # 完成 in_progress 任务,并报告下游解阻任务
    "schedule_cron": run_schedule_cron,  # 调度定时任务
    "list_crons": run_list_crons,  # 列出所有定时任务
    "cancel_cron": run_cancel_cron,  # 取消定时任务
    "spawn_teammate": run_spawn_teammate,  # 在后台线程启动队友 Agent。
    "send_message": run_send_message,  # 通过 MessageBus 向队友发送消息。
    "check_inbox": run_check_inbox,  # 仅检查当前 Agent 自己的收件箱。
+   "await_teammates": run_await_teammates,  # 阻塞等待队友 result。
    'request_shutdown': run_request_shutdown,#请求队友优雅关闭。
    'request_plan': run_request_plan,#要求队友提交计划供审核。
    'submit_plan': run_submit_plan,#向 Lead 提交计划待审批。
    'review_plan': run_review_plan,#按 request_id 批准或拒绝已提交的计划。
    'create_worktree': run_create_worktree,#创建隔离 git worktree
    'remove_worktree': run_remove_worktree,#删除 worktree
    'keep_worktree': run_keep_worktree,#保留 worktree 供审查
    'connect_mcp': run_connect_mcp,#连接 MCP 服务器并发现工具
}

20.7. schema.py #

tools/schema.py

# 定义一个函数_fn_tool,接收名称、描述、属性和必需字段列表,返回一个字典
def _fn_tool(
    name: str, description: str, properties: dict, required: list[str]
) -> dict:
    # 返回一个包含类型和函数信息的字典
    return {
        # 设定类型为'function'
        "type": "function",
        # 定义函数的具体内容
        "function": {
            # 函数名称
            "name": name,
            # 函数描述
            "description": description,
            # 参数设置,定义为一个对象,包含属性和必需字段
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


# 定义一个工具列表,包含一个通过_fn_tool函数生成的工具:bash命令执行
BASE_TOOLS = [
    _fn_tool(
        "bash",
        "执行一条 shell 命令。耗时操作可设 run_in_background=true 在后台运行。",
        {
            "command": {"type": "string"},
            "run_in_background": {"type": "boolean", "default": False},
        },
        ["command"],
    ),
    # 定义读取文件内容的工具,参数为 path(字符串类型)和 limit(整数类型),其中 path 为必需
    _fn_tool(
        "read_file",
        "读取文件内容。",
        {"path": {"type": "string"}, "limit": {"type": "integer"}},
        ["path"],
    ),
    # 定义写入文件内容的工具,参数为 path 和 content(都为字符串类型),均为必需
    _fn_tool(
        "write_file",
        "将内容写入文件。",
        {"path": {"type": "string"}, "content": {"type": "string"}},
        ["path", "content"],
    ),
    # 定义编辑文件内容的工具,参数为 path、old_text、new_text(均为字符串类型),都为必需,进行精确替换一次
    _fn_tool(
        "edit_file",
        "在文件中精确替换一段文本(仅替换一次)。",
        {
            "path": {"type": "string"},
            "old_text": {"type": "string"},
            "new_text": {"type": "string"},
        },
        ["path", "old_text", "new_text"],
    ),
    # 定义使用 glob 模式查找文件的工具,参数为 pattern(字符串类型)
    _fn_tool(
        "glob", "按 glob 模式查找文件。", {"pattern": {"type": "string"}}, ["pattern"]
    ),  # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
    _fn_tool(
        "send_message",
        "通过 MessageBus 发送消息。发送方固定为当前 Agent 身份,不可伪造。",
        {
            "to": {"type": "string"},
            "content": {"type": "string"},
        },
        ["to", "content"],
    ),
    _fn_tool(
        "check_inbox",
        "检查自己的收件箱(队友回信)。",
        {},
        [],
    ),
    # 定义 submit_plan 工具:向 Lead 提交计划待审批。
    _fn_tool(
        'submit_plan',
        '向 Lead 提交计划待审批。',
        {
            'from_name': {'type': 'string'},
            'plan': {'type': 'string'}
        },
        ['from_name', 'plan']
    )
]
TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "spawn_subagent",
        "启动子 Agent 处理复杂子任务。仅返回最终结论。",
        {"description": {"type": "string"}},
        ["description"],
    ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    ),
    _fn_tool(
        "compact", "摘要较早对话以释放上下文空间。", {"focus": {"type": "string"}}, []
    ),
    _fn_tool(
        "create_task",
        "创建新任务,可选 blockedBy 依赖。",
        {
            "subject": {"type": "string"},
            "description": {"type": "string"},
            "blockedBy": {"type": "array", "items": {"type": "string"}},
        },
        ["subject"],
    ),
    _fn_tool("list_tasks", "列出所有任务的状态、负责人与依赖。", {}, []),
    _fn_tool(
        "get_task",
        "按 ID 获取任务完整详情。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "claim_task",
        "认领 pending 任务,设置 owner 并改为 in_progress。owner 为认领者 Agent 名称,默认 lead。",
        {"task_id": {"type": "string"},'owner': {'type': 'string', 'description': '认领者 Agent 名称,默认 lead'},},
        ["task_id"],
    ),
    _fn_tool(
        "complete_task",
        "完成 in_progress 任务,并报告下游解阻任务。",
        {"task_id": {"type": "string"}},
        ["task_id"],
    ),
    _fn_tool(
        "schedule_cron",
        "调度 cron 任务。cron 为 5 段:分 时 日 月 周。",
        {
            "cron": {"type": "string", "description": "5 段 cron 表达式"},
            "prompt": {"type": "string", "description": "触发时注入的消息"},
            "recurring": {"type": "boolean", "description": "true=循环,false=单次"},
            "durable": {"type": "boolean", "description": "true=持久化到磁盘"},
        },
        ["cron", "prompt"],
    ),
    _fn_tool("list_crons", "列出所有已注册的 cron 任务。", {}, []),
    _fn_tool(
        "cancel_cron",
        "按 ID 取消 cron 任务。",
        {"job_id": {"type": "string"}},
        ["job_id"],
    ),
    _fn_tool(
        "spawn_teammate",
        "启动自主队友 Agent(idle 轮询看板、自动认领任务)。",
        {
            "name": {"type": "string"},
            "role": {"type": "string"},
            "prompt": {"type": "string"},
        },
        ["name", "role", "prompt"],
+   ),
+   _fn_tool(
+       "await_teammates",
+       "阻塞等待队友完成并发送 result。可指定 names;默认等待全部待回收队友。派工后、向用户汇报前应调用。",
+       {
+           "names": {
+               "type": "array",
+               "items": {"type": "string"},
+               "description": "要等待的队友名列表;省略则等待全部 pending",
+           },
+           "timeout": {
+               "type": "number",
+               "description": "最长等待秒数,默认见配置 TEAMMATE_WAIT_TIMEOUT",
+           },
+       },
+       [],
    ),
     # 定义 request_shutdown 工具:请求队友优雅关闭
    _fn_tool(
        'request_shutdown',
        '请求队友优雅关闭。',
        {'teammate': {'type': 'string'}},
        ['teammate'],
    ),
    # 定义 request_plan 工具:要求队友提交计划供审核
    _fn_tool(
        'request_plan',
        '要求队友提交计划供审核。',
        {'teammate': {'type': 'string'}, 'task': {'type': 'string'}},
        ['teammate', 'task'],
    ),
    # 定义 review_plan 工具:按 request_id 批准或拒绝已提交的计划
    _fn_tool(
        'review_plan',
        '按 request_id 批准或拒绝已提交的计划。',
        {
            'request_id': {'type': 'string'},
            'approve': {'type': 'boolean'},
            'feedback': {'type': 'string'},
        },
        ['request_id', 'approve'],
    ),
    _fn_tool(
        'create_worktree',
        '创建隔离的 git worktree 及独立分支 wt/{name}。可选 task_id 绑定任务(不改任务状态)。',
        {
            'name': {'type': 'string', 'description': 'worktree 名称,仅 [A-Za-z0-9._-]{1,64}'},
            'task_id': {'type': 'string', 'description': '可选,绑定到该任务'},
        },
        ['name'],
    ),
    _fn_tool(
        'remove_worktree',
        '删除 worktree。有未提交变更时拒绝,除非 discard_changes=true。',
        {
            'name': {'type': 'string'},
            'discard_changes': {'type': 'boolean', 'description': '强制丢弃未提交改动'},
        },
        ['name'],
    ),
    _fn_tool(
        'keep_worktree',
        '保留 worktree 供人工审查(不删除目录与分支)。',
        {'name': {'type': 'string'}},
        ['name'],
    ),
    _fn_tool(
        'connect_mcp',
        '连接 MCP 服务器并发现外部工具。可用: docs, deploy。连接后工具名为 mcp__server__tool。',
        {
            'name': {
                'type': 'string',
                'description': 'MCP 服务器名称,如 docs 或 deploy',
            },
        },
        ['name'],
    ),
]
TEAMMATE_TOOLS = [
    *BASE_TOOLS,
    # 定义创建并管理当前编码会话的任务列表的工具,参数为 todos(数组类型,每个元素为对象,包含 content 和 status 字段)
#   _fn_tool(
#       "todo_write",
#       "创建并管理当前编码会话的任务列表。",
#       {
#           "todos": {
#               "type": "array",
#               "items": {
#                   "type": "object",
#                   "properties": {
#                       "content": {"type": "string"},
#                       "status": {
#                           "type": "string",
#                           "enum": ["pending", "in_progress", "completed"],
#                       },
#                   },
#                   "required": ["content", "status"],
#               },
#           }
#       },
#       ["todos"],
#   ),
    _fn_tool(
        "load_skill",
        "按名称加载技能的完整内容。",
        {"name": {"type": "string"}},
        ["name"],
    )
]

21. Session Resume — 关掉还能接着聊 #

"关掉还能接着聊" — session_history 落盘到 .sessions/latest.json,下次 --resume 装回内存;compact 的 transcript 是归档,session 才是可续聊的状态。

本节对应教程 s21,在 s20 队友屏障之上补上 会话持久化与 Resume。此前 session_history 只活在进程内存里:CLI 一退,上下文全丢。s08 的 write_transcript 会在压缩时把旧对话写到 .transcripts/,但那是「压缩前快照」,不会在启动时自动装回 agent_loop。本节把「当前可续聊的消息列表」写成工作区文件,并与已有的 agent_lock 共存——用户输入与 cron 回合共用同一落盘路径。

本节要解决什么

场景 s20 及以前 s21(Session Resume)
进程退出 session_history 清空 写入 .sessions/latest.json
再次启动 永远从空会话开始 uv run ../harness/main.py --resume 装回 messages
与 compact 的关系 transcript 仅归档,不能直接续聊 session 存的是当前 history(可能已是压缩后的摘要)
cron 触发的回合 改了 history 但不落盘 run_agent_turn_locked 末尾统一 save_session
并发 用户与 cron 抢写 history 落盘放在 agent_lock 内,与调度共用锁
写坏文件 — 先写 .tmp 再 replace,避免半截 JSON

没有 session,长任务只能「一口气做完」;有了 Resume,关掉终端、换窗口、甚至崩溃后仍能从上次上下文继续——这是 Claude Code 一类产品的核心体验之一。

文件格式

.sessions/latest.json
{
  "version": 1,
  "updated_at": <unix ts>,
  "message_count": N,
  "messages": [ {role, content, tool_calls?, ...}, ... ]
}

messages 与内存中的 session_history 同构(user / assistant / tool),用 default=str 序列化,避免个别字段无法 JSON 编码。

落盘时机与锁

run_agent_turn_locked (已持有 agent_lock):
  append user(若有)
  agent_loop(session_history)
  save_session(session_history)   ← 用户回合与 cron 回合都走这里

退出 (q / Ctrl+C):
  with agent_lock:
      save_session(...)           ← 再保险一次

把 save_session 放进 run_agent_turn_locked,而不是只放在 input 循环末尾,才能让 cron 队列改过的 history 也被持久化。

启动语义

命令 行为
uv run ../harness/main.py 空会话起步;跑完仍覆盖 latest.json(下次可 resume)
... --resume 若文件存在则 load_session → session_history;否则警告并空启动

--resume 必须在 start_cron_scheduler / 接受输入之前完成,避免队列线程与空 history 竞态。

核心概念

概念 作用
SESSION_DIR / SESSION_LATEST 工作区下 .sessions/ 与 latest.json
save_session 原子写:.tmp → replace
load_session 读回 messages;损坏则返回 []
--resume CLI 开关,启动时装载上次会话
agent_lock 用户 / cron / 退出落盘共用,保证读写一致

相对 s20 的变化

文件 变化
session.py 新增:保存 / 加载 / 摘要
config.py SESSION_DIR、SESSION_LATEST
main.py argparse --resume;回合末与退出时 save_session
.gitignore 忽略 .sessions/(含对话历史,勿提交)

s08 transcript、s09 memory、s20 barrier 全部保留。Session 管的是「这一轮 CLI 进程的可续聊 history」;Memory 管跨会话偏好;Transcript 管压缩前归档——三者不互相替代。

试试这些步骤:

  1. 正常启动,让 Agent 做一个小任务(如创建 hello.txt),然后 q 退出。
  2. 打开 .sessions/latest.json,确认 messages 里有 user / assistant / tool。
  3. uv run ../harness/main.py --resume,问「刚才创建的文件叫什么?」——应能答对。
  4. (可选)不带 --resume 再开一次:空会话;跑一轮后又会覆盖 latest.json。

观察重点:控制台 [session] 已保存 N 条?--resume 时有无 已恢复: N 条消息?cron 若触发过任务,退出后文件里是否包含那次注入的对话?

时序图

保存一回合,并在下次用 --resume 接着聊:

sequenceDiagram participant User as 用户 participant Main as main.py participant Lock as agent_lock participant Agent as agent_loop participant Sess as session.py participant Disk as .sessions/latest.json Note over User,Disk: 第一次运行(无 --resume) User->>Main: 输入任务 Main->>Lock: acquire Lock->>Agent: agent_loop(session_history) Agent-->>Lock: 回合结束(history 已更新) Lock->>Sess: save_session(messages) Sess->>Disk: 写 latest.json.tmp → replace Lock-->>Main: release User->>Main: q 退出 Main->>Sess: 退出前再 save(持锁) Note over User,Disk: 第二次运行(--resume) User->>Main: uv run main.py --resume Main->>Sess: load_session() Sess->>Disk: 读 latest.json Disk-->>Sess: messages Sess-->>Main: 填入 session_history Main->>User: 已恢复 N 条消息 User->>Main: 「刚才创建的文件叫什么?」 Main->>Agent: 带着完整 history 继续推理 Agent-->>User: 答出文件名

说明:不带 --resume 时从空列表开始,但不会删除已有 latest.json——直到新回合结束 save_session 才覆盖。原子写保证进程在写文件中途被杀时,旧文件仍可读。Session 与 transcript 的区别:resume 装的是「当前可续聊状态」,不是某次 compact 的历史快照目录。

21.1. session.py #

session.py

# 会话持久化:将 session_history 读写到 .sessions/latest.json,支持 --resume 接着聊
import json
import time
from pathlib import Path

from config import SESSION_DIR, SESSION_LATEST, TEXT_ENCODING

# 会话文件格式版本
SESSION_VERSION = 1


def _ensure_session_dir() -> None:
    SESSION_DIR.mkdir(parents=True, exist_ok=True)


def save_session(messages: list, path: Path | None = None) -> Path:
    """将当前消息列表原子写入会话文件,返回路径。"""
    _ensure_session_dir()
    target = path or SESSION_LATEST
    payload = {
        "version": SESSION_VERSION,
        "updated_at": time.time(),
        "message_count": len(messages),
        "messages": messages,
    }
    # 先写临时文件再替换,避免进程中断留下半截 JSON
    tmp = target.with_suffix(target.suffix + ".tmp")
    tmp.write_text(
        json.dumps(payload, ensure_ascii=False, default=str, indent=2),
        encoding=TEXT_ENCODING,
    )
    tmp.replace(target)
    return target


def load_session(path: Path | None = None) -> list:
    """
    从会话文件加载 messages。
    文件不存在或损坏时返回空列表(由调用方决定是否提示)。
    """
    target = path or SESSION_LATEST
    if not target.exists():
        return []
    try:
        data = json.loads(target.read_text(encoding=TEXT_ENCODING))
    except (OSError, json.JSONDecodeError) as e:
        print(f"  \x1b[33m[session] 读取失败,将从空会话开始: {e}\x1b[0m")
        return []
    msgs = data.get("messages")
    if not isinstance(msgs, list):
        print("  \x1b[33m[session] 文件格式无效(缺少 messages 列表)\x1b[0m")
        return []
    return msgs


def session_exists(path: Path | None = None) -> bool:
    """会话文件是否存在。"""
    return (path or SESSION_LATEST).exists()


def format_session_summary(messages: list) -> str:
    """用于启动时打印的简短摘要。"""
    if not messages:
        return "(空会话)"
    roles = {}
    for m in messages:
        if isinstance(m, dict):
            r = m.get("role", "?")
            roles[r] = roles.get(r, 0) + 1
    parts = [f"{k}={v}" for k, v in sorted(roles.items())]
    return f"{len(messages)} 条消息({', '.join(parts)})"

21.2. .gitignore #

.gitignore

# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# 本地会话快照(含对话历史)
+.sessions/

21.3. config.py #

config.py

# 导入操作系统相关的模块
import os

# 导入Path对象用于处理文件路径
from pathlib import Path

# 导入dotenv模块来加载环境变量
from dotenv import load_dotenv

# 导入OpenAI官方python库
from openai import OpenAI

# 加载.env文件中的环境变量,override=True表示覆盖已有环境变量
load_dotenv(override=True)
# 定义默认的最大token数
DEFAULT_MAX_TOKENS = 8000
# 从环境变量中获取主要模型的名称
MODEL_ID = os.environ["MODEL_ID"]
# 创建OpenAI客户端对象,使用环境变量中的API密钥和Base URL
client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],
    base_url=os.getenv("OPENAI_BASE_URL"),
)
# 设置工作目录为当前目录
WORKDIR = Path.cwd()
# Change Code Page 设置命令行编码为UTF-8,UTF-8对应的代码页编号是65001,GBK 对应的代码页编号是 936
os.system("chcp 65001")
# 设置文本编码为UTF-8
TEXT_ENCODING = "utf-8"

# 设置技能目录为工作目录下的 skills 目录
SKILLS_DIR = WORKDIR / "skills"

# 设置持久化阈值为30000
PERSIST_THRESHOLD = 1000
# 设置最大字节数为300000
MAX_BYTES = 10000
# 设置工具结果目录为工作目录下的 .task_outputs / tool-results 目录
TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results"
# 设置保留最近3条tool消息
KEEP_RECENT = 3
# 设置上下文限制为100000
CONTEXT_LIMIT = 100000
# 设置转录目录为工作目录下的 .transcripts 目录
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
# 设置记忆目录为工作目录下的 .memory 目录
MEMORY_DIR = WORKDIR / ".memory"
# 创建记忆目录,如果目录不存在
MEMORY_DIR.mkdir(exist_ok=True)
# 设置记忆索引文件为工作目录下的 .memories 目录下的 MEMORY.md 文件
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
# 设置记忆合并阈值为10
CONSOLIDATE_THRESHOLD = 10
# 设置最大重试次数为10
MAX_RETRIES = 10
# 设置基础延迟时间为500毫秒
BASE_DELAY_MS = 500
# 定义连续发生529错误的最大次数
MAX_CONSECUTIVE_529 = 3
# 从环境变量中获取备用模型的名称
FALLBACK_MODEL = os.getenv("FALLBACK_MODEL")
# 定义升级后的最大token数
ESCALATED_MAX_TOKENS = 64000
# 定义最大恢复重试次数为3
MAX_RECOVERY_RETRIES = 3
# 定义续写提示
CONTINUATION_PROMPT = (
    "输出 token 上限已达到。直接继续 — 不要道歉或复述,从思路中断处接上。"
)
# 有未完成 todo 且连续 N 轮未调用 todo_write 时,向当轮 system 附加提醒
TODO_REMINDER_ROUNDS = 3
# 设置任务目录为工作目录下的 .tasks 目录
TASKS_DIR = WORKDIR / ".tasks"
# 创建任务目录,如果目录不存在
TASKS_DIR.mkdir(exist_ok=True)
# 定时任务持久化文件
DURABLE_PATH = WORKDIR / '.scheduled_tasks.json'
# 队友消息邮箱目录
MAILBOX_DIR = WORKDIR / '.mailboxes'
# 创建队友消息邮箱目录,如果目录不存在
MAILBOX_DIR.mkdir(exist_ok=True)
# git worktree 隔离目录
WORKTREES_DIR = WORKDIR / '.worktrees'
# 创建 worktree 目录,如果目录不存在
WORKTREES_DIR.mkdir(exist_ok=True)
# Lead 收尾屏障:等待队友 result 的最长秒数
TEAMMATE_WAIT_TIMEOUT = 120
# Stop 时自动屏障最多触发轮数(每轮先 wait 再给 Lead 一次汇总机会)
TEAMMATE_BARRIER_ROUNDS = 1
# 会话持久化目录(session_history → latest.json,支持 --resume)
+SESSION_DIR = WORKDIR / ".sessions"
+SESSION_DIR.mkdir(exist_ok=True)
# 当前会话快照路径
+SESSION_LATEST = SESSION_DIR / "latest.json"

21.4. main.py #

main.py

# 导入线程模块
import threading
# 导入命令行参数解析
+import argparse

# 导入 agent_loop 函数从 agent 模块
from agent import agent_loop

# 导入trigger_user_prompt_hooks函数
from hooks import trigger_user_prompt_hooks

# 从cron模块导入start_cron_scheduler和start_queue_processor函数
from cron import start_cron_scheduler, start_queue_processor

# 从 session 模块导入保存/加载会话
+from session import (
+   save_session,
+   load_session,
+   session_exists,
+   format_session_summary,
+)
+from config import SESSION_LATEST

# 定义会话历史记录列表
session_history: list = []
# 定义互斥锁用于线程同步(用户输入与 cron 队列共用,落盘也在锁内)
agent_lock = threading.Lock()


# 定义一个函数,带锁执行agent回合,可选参数为用户输入
def run_agent_turn_locked(user_query: str | None = None):
    # 如果用户有输入,将其加入会话历史中
    if user_query is not None:
        session_history.append({"role": "user", "content": user_query})
    # 启动agent主循环,处理会话
    agent_loop(session_history)
    # 回合结束后落盘(与 cron 调度共用本函数,故写在此处)
+   path = save_session(session_history)
+   print(f"  \x1b[90m[session] 已保存 {len(session_history)} 条 → {path.name}\x1b[0m")
    # 获取最新一条历史记录,如果历史为空则为None
    final = session_history[-1] if session_history else None
    # 如果最新一条是助手且消息内容不为空,则打印出来
    if final and final.get("role") == "assistant" and final.get("content"):
        print(final["content"])


+def _parse_args():
+   parser = argparse.ArgumentParser(description="Agent Harness CLI")
+   parser.add_argument(
+       "--resume",
+       action="store_true",
+       help=f"从 {SESSION_LATEST} 恢复上次会话并继续聊天",
+   )
+   return parser.parse_args()


# 定义主函数
def main():
+   args = _parse_args()
    # 恢复上次会话(须在启动 cron / 接受输入之前完成)
+   if args.resume:
+       if session_exists():
+           restored = load_session()
+           session_history.clear()
+           session_history.extend(restored)
+           print(
+               f"  \x1b[36m[session] 已恢复: {format_session_summary(session_history)}"
+               f" ← {SESSION_LATEST}\x1b[0m"
+           )
+       else:
+           print(
+               f"  \x1b[33m[session] 未找到 {SESSION_LATEST},从空会话开始\x1b[0m"
+           )
    # “定时投递/安排任务”(产生任务),作用是启动定时任务调度器。调度器负责定时、周期性地向任务队列投递任务,它为整个系统源源不断地按计划产生“待处理事件”。
    start_cron_scheduler()
    # “消费/处理任务队列中的实际任务”,作用是不断轮询检查任务队列,一旦发现有待处理的任务,就会调用 agent 处理方法完成任务。因此,这个线程是专门用来“实际执行任务”的。
    start_queue_processor(run_agent_turn_locked, agent_lock)
    # 打印提示信息,告诉用户如何退出
+   if args.resume and session_history:
+       print("已恢复上次会话。输入问题继续,回车发送。输入 q 退出。\n")
+   else:
+       print("输入问题,回车发送。输入 q 退出。\n")
+       print(f"(下次可用 --resume 从 {SESSION_LATEST.name} 接着聊)\n")
    # 进入无限循环,不断接收用户输入
    while True:
        try:
            # 获取用户输入,带有提示符
            query = input("\x1b[36m>> \x1b[0m")
        # 捕获 EOFError 或 KeyboardInterrupt 异常(例如 Ctrl+D 或 Ctrl+C)
        except (EOFError, KeyboardInterrupt):
            # 退出前再落盘一次(若回合中断时 history 已有内容)
+           with agent_lock:
+               if session_history:
+                   save_session(session_history)
+                   print(
+                       f"\n  \x1b[90m[session] 退出前已保存 "
+                       f"{len(session_history)} 条\x1b[0m"
+                   )
            # 异常时退出循环
            break
        if not query.strip():
            continue
        # 如果输入为空,或者用户输入了 'q' 或 'exit',则退出循环
+       if query.strip().lower() in ("q", "exit"):
+           with agent_lock:
+               if session_history:
+                   save_session(session_history)
            break
        # 触发'UserPromptSubmit'钩子,进行前置处理,返回处理后的用户输入
        query = trigger_user_prompt_hooks(query)
        # 上锁,执行agent回合处理(落盘在 run_agent_turn_locked 内,与 cron 共用)
        with agent_lock:
           run_agent_turn_locked(query)
        # 打印换行
        print()


# 如果当前脚本作为主程序运行,则调用 main 函数
if __name__ == "__main__":
    main()