- 1. Agent Loop — 最小可运行的 Agent 内核
- 2. Tool Use — 加一个工具,只加一行
- 3. Permission — 执行前做权限判断
- 4. Hooks — 挂在循环上,不写进循环里
- 5. TodoWrite — 没有计划的 Agent,做着做着就偏了
- 6. Subagent — 大任务拆小,每个拿到的都是干净上下文
- 7. Skill Loading — 用到的时候才加载
- 8. Context Compact — 上下文总会满,要有办法腾地方
- 9. Memory — 压缩会丢细节,要有一层不丢的
- 10. System Prompt — 运行时组装,不硬编码
- 11. Error Recovery — 错误不是结束,是重试的开始
- 12. Task System — 跨会话的持久化任务图
- 13. Background Tasks — 慢命令不阻塞主循环
- 14. Cron Scheduler — 时间驱动的自动唤醒
- 15. Agent Teams — 多 Agent 异步协作
- 16. Team Protocols — 队友之间要有约定
- 17. Autonomous Agents — 自己看板,自己认领
- 18. Worktree Isolation — 各干各的,互不干扰
- 19. MCP Tools — 外接工具,标准协议
- 20. Teammate Barrier — 派出去,也要收回来
- 21. Session Resume — 关掉还能接着聊
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:
创建一个名为 hello.py 的文件,内容为打印 "Hello, World!"列出当前目录下所有的 Python 文件当前的 git 分支是什么?
观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?
时序图
一次完整的用户问答(模型需要执行 1 次 bash 命令)如下:
说明:每轮循环中大模型(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.py1.1. .env #
.env
# 设置OpenAI的基础URL
OPENAI_BASE_URL=https://api.deepseek.com
# 设置OpenAI的API密钥
OPENAI_API_KEY=sk-a4ff302bcdb44c73a3a689e340102efa
# 设置主要使用的模型
MODEL_ID=deepseek-v4-pro1.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:
读取 README.md 文件,并告诉我这个项目是做什么的创建一个名为 test.py 的文件,内容为打印 "hello",然后再读取该文件查找当前目录下所有的 Python 文件同时读取 README.md 和 pyproject.toml,然后生成一个总结文件
观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?\
时序图
以「找到所有 Python 文件并读取 main.py 前 20 行」为例,模型可能连续调用 glob 和 read_file:
说明:每轮
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 path3. 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:
在当前目录创建一个名为 test.txt 的文件(应该直接通过)删除 tmp 目录下所有临时文件(bash + rm 会触发闸门 2)当前目录下有哪些文件?(只读,全部通过)使用write_file工具向c:/目录写入一个名为hello.txt的空文本文件(写工作区外,触发闸门 2)
观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?
时序图
以模型尝试执行 bash: del temp.log 为例,展示权限闸门与执行分支:
说明:拒绝路径与放行路径都会生成
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 currentStop Hook 若返回字符串,会作为新的 user 消息注入并 continue 循环——这是「强制续跑」的扩展点(教学版 summary_hook 只打印统计,返回 None)。
试试这些 prompt:
读取文件 README.md(应直接通过,注意观察 hook 日志)创建一个名为 test.txt 的文件(通过后观察是否触发了 PostToolUse)删除tmp目录下的所有文件(bash + rm 操作会触发权限 hook)
观察重点:每次工具执行前,是否出现了 [HOOK] 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?
时序图
一次完整问答中,四个 Hook 事件在循环中的挂载位置:
说明:循环体内不再出现
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:`
重构example/hello.py:添加类型注解、文档字符串和 main 保护(先列出 3 个步骤再执行)在example/demo_pkg 下创建一个 Python 包,包含 __init__.py、utils.py 和 tests/test_utils.py检查example 下的所有 Python 文件并修复代码风格问题
观察重点:第一次工具调用是不是 todo_write?TODO 列了几步?执行过程中状态有没有从 pending 变成 in_progress / completed?
时序图
以「重构 3 个文件并跑测试」为例,展示规划、执行、提醒、更新的完整流程:
说明:
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:
使用子任务查找本项目安装了哪些第三方模块(子 Agent 负责读取文件,主 Agent 只接收结论)用 spawn_subagent 工具读取 agents/ 目录下所有 .py 文件,并总结每个文件的功能用 spawn_subagent 工具创建 example/string_tools.py,内含 slugify(text: str) 函数,然后让主 Agent 验证该文件
观察重点:是否出现 [Subagent spawned] / [Subagent done]?子 Agent 的工具调用是否以 [sub] ... 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?
时序图
以主 Agent 委派「追踪 utils.py 的调用链」为例:
说明:子 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: …
---加载流程
- 启动:
skills.py遍历SKILLS_DIR,填充SKILL_REGISTRY(name / description / content) - 每轮 LLM 前:
get_system_prompt()→_skills_text()生成「可用技能」列表注入 system - 按需:模型调用
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
有哪些可用的技能?加载 code-review 技能并按照其说明操作
观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 [HOOK] load_skill?加载后回答是否使用了对应 skill 的说明?
时序图
以用户请求「按 code-review 技能审查 agent.py」为例:
说明:目录层让模型「知道有哪些技能可选」;全文层只在任务需要时才占上下文。新增技能只需在
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:
- 读取当前目录下面的红楼梦.txt并总结故事梗概
读取 README.md 文件,然后读取 code.py,再读取 example/README.md(连续读取多个文件,观察 L2 压缩旧结果的情况)读取 example/ 目录下的所有文件(一次性读取大量内容,观察 L3 将内容落盘)
观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要?
时序图
长会话中一轮循环的压缩与 LLM 调用流程:
说明: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 里。上下文满了要压缩,压缩就有损。需要一层:
- 存在文件系统,不被
snip_compact/compact_history裁掉 - 跨会话可读,新开会话仍能注入相关偏好
- 按需加载,不把全部记忆塞进每轮 prompt
两层加载(与 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 条:
- 把
list_memory_files()的 name + description 编成编号目录 - 调用 LLM,返回 JSON 索引数组,如
[0, 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(分多轮输入,观察记忆的累积和加载):
我更喜欢用 Tab 键缩进,而不是空格。请记住这一点。创建一个名为 test.py 的 Python 文件(观察 Agent 是否使用了 Tab)我之前告诉过你我的偏好吗?(观察 Agent 是否还记得)我也更喜欢字符串用单引号而不是双引号。
观察重点:每轮结束后是否出现 [Memory: extracted N new memories]?.memory/ 目录下是否生成了 .md 文件?MEMORY.md 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆?
时序图
一轮完整循环中的记忆读、写与压缩关系:
说明:记忆索引常驻 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 = 109.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 = "..." 字符串,会出现:
- 换项目不知改哪段 — identity、工具说明、记忆指引全搅在一起
- 改一处牵动全局 — 加一句 memory 说明可能影响 identity 语气
- 空段落也占 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())MEMORY.md未变 → 多轮循环复用同一段前缀,终端打印[缓存命中]extract_memories/consolidate_memories重建索引 →mtime变化 → 重新组装
技能目录在进程启动时扫描,会话内不变,随前缀一并缓存。
相对 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:
读取文件 README.md(观察始终加载的三个 section)
时序图
每轮 LLM 调用前 system prompt 的组装流程:
说明:组装逻辑集中在
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 后仍过长 → [不可恢复],returnwith_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:
- 写一篇长篇科幻小说 (让 Agent 生成一段很长的代码,观察截断后是否自动续写(看
[max_tokens] escalating日志)) - 读到当前目录下面所有的文件内容 (连续读取大量文件撑大上下文,观察 reactive compact)
- 如果遇到 429/529,观察指数退避的日志输出
时序图
一次 LLM 调用可能经历的恢复分支:
说明:恢复动作大多是
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:
创建任务:setup database schema,create API endpoints(依赖 schema),write tests(依赖 endpoints),write docs(依赖 schema)列出所有任务及其状态认领第一个未被阻塞的任务并完成再次列出任务 —— 哪些任务已被解锁?
时序图
带依赖的任务从创建到解阻的完整流程:
说明:
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 msg12.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:
在后台运行 pip list 并打印运行结果运行 pip list 并打印运行结果 (使用run_in_background)
时序图
慢命令从派发到通知注入的完整流程:
说明:后台线程是
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_llmcron 匹配要点
validate_cron注册前校验 5 段表达式cron_matches支持*、*/n、,、-;日/周双约束时取 OR 语义_last_fired按分钟去重,同一任务同一分钟只触发一次
三个新工具
| 工具 | 作用 |
|---|---|
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:
安排一个每 2 分钟打印当前日期时间的任务列出所有 cron 任务取消循环任务并用 list_crons 验证
时序图
从注册到自动执行的完整流程:
说明:
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:
生成一名名为 alice 的后端开发者。让她创建一个名为 schema.sql 的文件,并包含一个 users 表。生成一名名为 bob 的测试人员。让他检查 schema.sql 是否存在,并列出其内容。
观察重点:Lead 如何启动队友?.mailboxes/ 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history?
时序图
从委派队友到 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:
生成一名名为 alice 的后端开发者,再让她创建 config.py,然后再请求她优雅关闭。派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 请求关机到协议状态落定的完整流程:
说明:
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:
创建两个任务:① 写 schema.sql(users 表)② 写 seed.sql(依赖第一个)。再 spawn 一名 alice,不要给她具体指令,只说「等任务」。spawn bob 和 carol 两个队友,再 create_task 三条互不依赖的小任务,观察谁自动认领了哪条。
观察重点:控制台是否出现 [idle] alice 自动认领: ...?.tasks/*.json 的 owner / status 是否变为队友名与 in_progress?Lead 不发 send_message 时队友是否仍开始干活?
时序图
从 Lead 建任务、派队友,到 idle 自动认领再回到 WORK:
说明: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,到队友认领切换目录、写文件、收尾:
说明:绑定不推进任务状态,认领才切
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:
连接到 docs MCP 服务器,搜索 authentication,再查 API 版本。连接 deploy,查看 api-gateway 状态,然后触发部署。同时连接 docs 和 deploy,列出当前所有可用工具(含 mcp__ 前缀)。
观察重点:控制台 [mcp] 已连接: ...?下一轮 LLM 的 tool_call 是否出现 mcp__docs__search?两个 server 能否并存?execute_tool 是否把参数透传到 mock handler?
时序图
从连接 MCP 到模型调用带前缀的外部工具:
说明:教学版用 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:
生成一名名为 alice 的后端开发者。让她创建一个名为 schema.sql 的文件,并包含一个 users 表。生成 alice 写 schema.sql,同时生成 bob 写一段简短的 README 说明该表;都完成后再汇总。
观察重点:控制台是否出现 [屏障] 等待队友 result / Stop 已拦截?Lead 最终汇报的文件路径是否与 teammate 实际写入一致?主动调用 await_teammates 时 tool_result 是否带上收件箱内容?
时序图
Lead 早停被屏障拦住,等到 result 后再汇总:
说明:消息落在磁盘 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 = 120.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 管压缩前归档——三者不互相替代。
试试这些步骤:
- 正常启动,让 Agent 做一个小任务(如创建
hello.txt),然后q退出。 - 打开
.sessions/latest.json,确认messages里有 user / assistant / tool。 uv run ../harness/main.py --resume,问「刚才创建的文件叫什么?」——应能答对。- (可选)不带
--resume再开一次:空会话;跑一轮后又会覆盖latest.json。
观察重点:控制台 [session] 已保存 N 条?--resume 时有无 已恢复: N 条消息?cron 若触发过任务,退出后文件里是否包含那次注入的对话?
时序图
保存一回合,并在下次用 --resume 接着聊:
说明:不带
--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()