目标
s01 到 s05 让智能体具备了工具调用、权限管控、Hook 扩展、TodoWrite 规划能力。但接到"重构 UserService 和 OrderService 两个独立文件"这类任务时,它仍然串行处理——先做完 A,再做 B。主智能体在等 A 完成期间什么也做不了。
Index 06 的目标是引入异步后台子智能体:
task工具派发任务——主智能体调用task工具,立即拿到task_id,后台 launch 一个独立子智能体执行query_task工具查询结果——主智能体继续推理,后续用query_task轮询任务状态/获取结果- 完全隔离的依赖图——子智能体持有独立的
messagesHistory、ToolRegistry、TodoStore、HookManager - 结构化并发——ReplLoop 持有
CoroutineScope(SupervisorJob + Dispatchers.Default),退出时cancel()取消所有子任务
完成后的效果——主智能体可以并行派发多个独立任务:
> 帮我重构 UserService.kt 和 OrderService.kt 两个文件
[主智能体调用 task 派发任务 1: 重构 UserService]
Task dispatched: task_a1b2c3...
Use query_task to check status.
[主智能体调用 task 派发任务 2: 重构 OrderService]
Task dispatched: task_d4e5f6...
Use query_task to check status.
[主智能体调用 query_task 查询任务 1]
Task task_a1b2c3 COMPLETED.
Result:
UserService.kt 已重构:抽取了 validateUserId...
[主智能体调用 query_task 查询任务 2]
Task task_d4e5f6 COMPLETED.
Result:
OrderService.kt 已重构:抽取了 validateOrderId...两个子智能体在后台并行跑,主智能体只负责派发和收集结果。
架构:异步后台模型
Index 06 在 s05 的基础上增加了一个"后台任务系统"。与 TodoWrite 不同的是,它引入了并发——主智能体不再阻塞在单个 AgentLoop.run() 里:
用户输入 "重构 UserService 和 OrderService"
│
▼
┌──────────────────┐
│ 主 AgentLoop │
│ messagesHistory │
└──┬─────────────┘
│ LLM 调用 task 工具
▼
┌──────────────────────────────────┐
│ TaskTool.execute (写工具) │
│ │
│ ① Permission (s03) ──── 放行 │
│ ② PreToolUse Hook (s04) ─ 放行 │
│ ③ SubagentStore.createRecord │
│ + markRunning (PENDING→RUNNING)│
│ ④ scope.launch { │
│ subagent.run() ← 后台协程 │
│ } │
│ ⑤ 返回 "Task dispatched: task_xxx"│
│ ⑥ PostToolUse Hook (s04) ─ 透传 │
└──────────────────────────────────┘
│
▼ tool_result 进入主 AgentLoop.messagesHistory
│
▼ 主智能体继续推理(可能再次 task 或 query_task)
│
▼ ...后续轮询 query_task 拿到 COMPLETED + finalText关键洞察:TaskTool 与其他工具一样是普通 Tool,穿过同一个 Permission 管线和 Pre/Post hooks。它不修改 AgentLoop 的核心循环逻辑,只在 execute 里 launch 一个后台协程。子智能体内部跑的是另一个独立的 AgentLoop 实例。
| 层 | Index 05 状态 | Index 06 变更 |
|---|---|---|
| 配置 | AgentConfig 4 字段 | +maxConcurrentSubagents(默认 5) |
| 工具 | ToolRegistry 是单例 object | object → class,构造注入 |
| 待办 | TodoStore 是单例 object | object → class,构造注入 |
| 智能体 | AgentLoop 构造 4 参数 | +toolRegistry 参数(构造注入) |
| 权限 | ToolCategoryRule 无状态 | +registry 构造参数 |
| 子智能体 | — | 新建 subagent/ 包,6 个主类 + 2 个工具 |
| REPL | 主循环 while(true) | +CoroutineScope + try/finally cancel() |
第一层:重构两个单例
为什么必须先重构?
子智能体要"完全隔离"——独立 messagesHistory、独立工具集、独立 todo 列表。如果 ToolRegistry 和 TodoStore 还是全局单例,主智能体和子智能体会共享同一份状态,隔离形同虚设。
ToolRegistry:object → class
// 改造前(s02-s05)
object ToolRegistry {
private val tools = mutableMapOf<String, Tool>()
fun register(tool: Tool) { tools[tool.name] = tool }
fun get(name: String): Tool = tools[name] ?: throw UnknownToolException(name)
// ...
}
// 改造后(s06)
class ToolRegistry {
private val tools = mutableMapOf<String, Tool>()
fun register(tool: Tool) { tools[tool.name] = tool }
fun get(name: String): Tool = tools[name] ?: throw UnknownToolException(name)
// ...签名完全不变
}签名零变化,只是从 object 改为 class。KDoc 早在 s02 就预埋了演进说明("Index 06 Subagent 需要多实例时再改为 class"),这次正是兑现承诺。
AgentLoop 构造函数注入
class AgentLoop(
private val llmProvider: LLMProvider,
private val systemPrompt: String,
private val config: AgentConfig = AgentConfig(),
private val hooks: AgentLoopHooks = AgentLoopHooks(),
private val toolRegistry: ToolRegistry = ToolRegistry() // 新增,默认值保向后兼容
)3 处单例引用点改为实例引用:
- L64:
ToolRegistry.getDefinitions()→toolRegistry.getDefinitions() - L138:
ToolRegistry.get(tc.name).isReadOnly→toolRegistry.get(tc.name).isReadOnly - L201:
ToolRegistry.get(toolCall.name)→toolRegistry.get(toolCall.name)
隐藏耦合点:ToolCategoryRule
重构时容易漏掉权限规则——ToolCategoryRule 也硬编码依赖 ToolRegistry 单例:
// 改造前
class ToolCategoryRule : PermissionRule {
override suspend fun evaluate(toolCall: ToolCall): PermissionResult {
val isReadOnly = try {
ToolRegistry.get(toolCall.name).isReadOnly // ← 隐藏耦合
} catch (_: UnknownToolException) {
return PermissionResult.ASK
}
return if (isReadOnly) PermissionResult.ALLOW else PermissionResult.ASK
}
}
// 改造后
class ToolCategoryRule(private val registry: ToolRegistry) : PermissionRule {
override suspend fun evaluate(toolCall: ToolCall): PermissionResult {
val isReadOnly = try {
registry.get(toolCall.name).isReadOnly // 注入实例
} catch (_: UnknownToolException) {
return PermissionResult.ASK
}
return if (isReadOnly) PermissionResult.ALLOW else PermissionResult.ASK
}
}TodoStore:同样的演进
TodoStore 从 object 改为 class,TodoWriteTool 增加构造参数 store: TodoStore。顺手消掉一个日志耦合——原实现用 TodoStore.completedCount() / size() 做日志统计,改造后直接用本地 parsed 列表统计:
// 改造前
TodoStore.replaceAll(parsed)
logger.info { "Todo list updated: ${TodoStore.completedCount()}/${TodoStore.size()} done" }
// 改造后
store.replaceAll(parsed)
logger.info {
"Todo list updated: ${parsed.count { it.status == TodoStatus.COMPLETED }}/${parsed.size} done"
}工具刚写入的数据,没必要再查一次 store 来做日志统计——本地 parsed 就是同一份真相。
测试改造模式
所有依赖单例的测试统一改造:删 beforeTest/afterTest { clear() },每用例 new 局部实例。
// 改造前
beforeTest { ToolRegistry.clear() }
"register adds tool" {
val registry = ToolRegistry // 误把 object 当类型
registry.register(FakeEchoTool())
}
// 改造后
"register adds tool" {
val registry = ToolRegistry() // 局部实例
registry.register(FakeEchoTool())
}AgentLoopTest 中 18+ 处 AgentLoop(...) 实例化都新增 toolRegistry = registry 参数。TodoWrite 相关 3 个用例额外注入 val todoStore = TodoStore(),断言改用 todoStore.size()。
第二层:子智能体数据模型
SubagentStatus——状态机
enum class SubagentStatus { PENDING, RUNNING, COMPLETED, FAILED }状态机:
PENDING → RUNNING → { COMPLETED | FAILED }PENDING:TaskTool 刚创建记录,尚未 launchRUNNING:协程已 launch,子智能体 AgentLoop 正在执行COMPLETED:成功结束,finalText已填充FAILED:抛出异常(非 CancellationException),error已填充
SubagentRecord——不可变快照
data class SubagentRecord(
val id: String, // "task_${UUID}"
val description: String, // 简短描述
val prompt: String, // 任务正文
val status: SubagentStatus,
val finalText: String? = null, // COMPLETED 时填充
val error: String? = null, // FAILED 时填充
val createdAt: Long,
val completedAt: Long? = null
)每次状态流转通过 copy 生成新实例替换旧值(见 SubagentStore),保证读取方拿到的一定是某一时刻的完整快照——典型的"不可变值对象 + 可变引用"模式。
第三层:Subagent 与 SubagentFactory
Subagent——持有独立 AgentLoop
class Subagent(
private val loop: AgentLoop,
val description: String,
private val prompt: String
) {
suspend fun run(): String = loop.run(prompt)
}极其简单的包装——子智能体就是"一个 AgentLoop + 任务 prompt"。但它持有的 AgentLoop 实例的所有依赖(messagesHistory、ToolRegistry、TodoStore、HookManager)都是 SubagentFactory.create 时新建的独立实例。
SubagentFactory——组装隔离的依赖图
class SubagentFactory(
private val llmProvider: LLMProvider,
private val config: AgentConfig
) {
fun create(description: String, prompt: String): Subagent {
val todoStore = TodoStore() // 独立 todo
val toolRegistry = ToolRegistry().apply {
register(ReadFileTool())
register(WriteFileTool())
register(BashTool())
register(TodoWriteTool(todoStore))
// 不注册 Task/QueryTask——禁止递归派发孙智能体
}
val hookManager = HookManager().apply {
LoggingHook().registerTo(this)
// 不注册 TimingHook/TodoDisplayHook——避免干扰主 REPL 输出
}
val loop = AgentLoop(
llmProvider = llmProvider,
systemPrompt = SubagentPrompts.DEFAULT,
config = config,
hooks = AgentLoopHooks(
onPreToolUse = { tc -> hookManager.firePreToolUse(tc) },
onPostToolUse = { tc, result -> hookManager.firePostToolUse(tc, result) }
),
toolRegistry = toolRegistry
)
return Subagent(loop, description, prompt)
}
}关键设计取舍:
- 不注入 PermissionPipeline——子智能体后台运行无交互界面,ReadLinePrompter 会死锁;主智能体在 TaskTool 调用阶段已对"派发子任务"这一动作审批,子智能体内部工具调用不再二次确认
- 不注册 task/query_task——天然禁止递归派发孙智能体,避免资源失控
- 只注册 LoggingHook——便于调试,不注册 TodoDisplayHook(避免 println 干扰主 REPL 输出)
这是有意为之的"降权模型"——子智能体能力是主智能体的子集,但没有"再派发"能力。
为什么不提取 AgentLoopCore 抽象?
考虑过提取一个 AgentLoopCore 只包含核心循环逻辑,让 AgentLoop 和 Subagent 分别继承。但评估后不提取:
- AgentLoop 已支持构造注入全部依赖(llmProvider/systemPrompt/config/hooks/toolRegistry)
- 子智能体与主智能体的区别仅在"参数不同"
- 直接
new AgentLoop(...)即可,符合"严格迭代,不做预留"原则 - 提取会增加无用抽象层,违反 YAGNI
第四层:SubagentStore
class SubagentStore {
private val records = ConcurrentHashMap<String, SubagentRecord>()
private val runningCount = AtomicInteger(0)
fun createRecord(description: String, prompt: String): String { /* 生成 task_id + PENDING */ }
fun markRunning(id: String) { /* PENDING→RUNNING,count++ */ }
fun markCompleted(id: String, finalText: String) { /* RUNNING→COMPLETED,count-- */ }
fun markFailed(id: String, error: String) { /* RUNNING→FAILED,count-- */ }
fun get(id: String): SubagentRecord? = records[id]
fun runningCount(): Int = runningCount.get()
fun clear() { records.clear(); runningCount.set(0) }
}线程安全设计
TaskTool 在主 AgentLoop 协程中调用 createRecord/markRunning,子智能体在后台 launch 的协程中调用 markCompleted/markFailed——两者并发访问,故所有写操作都基于 ConcurrentHashMap.computeIfPresent 做状态机校验:
fun markRunning(id: String) {
records.computeIfPresent(id) { _, record ->
if (record.status == SubagentStatus.PENDING) {
runningCount.incrementAndGet()
record.copy(status = SubagentStatus.RUNNING)
} else {
record // 状态不符,保持原样
}
}
}非法转换(如 COMPLETED → RUNNING、PENDING → COMPLETED)通过 computeIfPresent 内状态检查自然忽略——只允许从预期前置状态转换。
runningCount 语义
只统计 RUNNING 状态的记录数,用于 TaskTool 的并发上限检查。markRunning 时 +1,markCompleted/markFailed 时 -1。AtomicInteger 保证并发场景下的精确计数。
第五层:Task 与 QueryTask 工具
TaskTool——异步派发
class TaskTool(
private val factory: SubagentFactory,
private val store: SubagentStore,
private val scope: CoroutineScope,
private val maxConcurrent: Int
) : Tool {
override val name = "task"
override val isReadOnly = false // 副作用:创建协程;串行执行避免并发竞态
override suspend fun execute(input: JsonObject): ToolResult {
val description = input["description"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: description is required", isError = true)
val prompt = input["prompt"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: prompt is required", isError = true)
if (store.runningCount() >= maxConcurrent) {
return ToolResult("", "Error: max concurrent subagents ($maxConcurrent) reached", isError = true)
}
val taskId = store.createRecord(description, prompt)
store.markRunning(taskId)
scope.launch {
try {
val subagent = factory.create(description, prompt)
val finalText = subagent.run()
store.markCompleted(taskId, finalText)
} catch (e: CancellationException) {
throw e // 重新抛出,遵守结构化并发
} catch (e: Exception) {
store.markFailed(taskId, e.message ?: "Unknown error")
}
}
return ToolResult("", "Task dispatched: $taskId\nUse query_task to check status.")
}
}为什么 isReadOnly = false?
派发会创建协程、占用资源。AgentLoop 的并发模型是"读工具并行,写工具串行"——TaskTool 标为写工具,确保主智能体一次只派发一个任务,避免 runningCount >= maxConcurrent 检查出现 TOCTOU 竞态。
QueryTaskTool——同步查询
class QueryTaskTool(private val store: SubagentStore) : Tool {
override val name = "query_task"
override val isReadOnly = true // 读操作,可并发查询
override suspend fun execute(input: JsonObject): ToolResult {
val taskId = input["task_id"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: task_id is required", isError = true)
val record = store.get(taskId)
?: return ToolResult("", "Error: task not found: $taskId", isError = true)
val content = when (record.status) {
SubagentStatus.PENDING -> "Task $taskId is PENDING (not yet started)."
SubagentStatus.RUNNING -> "Task $taskId is RUNNING. Try again later."
SubagentStatus.COMPLETED -> "Task $taskId COMPLETED.\n\nResult:\n${record.finalText ?: ""}"
SubagentStatus.FAILED -> "Task $taskId FAILED.\n\nError:\n${record.error ?: "Unknown error"}"
}
return ToolResult("", content, isError = record.status == SubagentStatus.FAILED)
}
}FAILED 状态返回 isError = true——让 LLM 在 tool_result 中明确感知失败,而不是把错误信息当作"正常结果"误用。
包依赖方向:为什么 TaskTool/QueryTaskTool 在 subagent/ 包?
按 s02 的工具约定,所有 Tool 实现都放在 tool/tools/ 包。但 TaskTool/QueryTaskTool 依赖 SubagentFactory/SubagentStore,如果把它们放在 tool/tools/,会引入反向依赖:
tool.tools → subagent → agent, tool这破坏了 tool → (独立) 的单向依赖。放在 subagent/ 包内则保持合法:
subagent → agent, llm, tool ✓ 单向第六层:ReplLoop 集成
结构化并发
class ReplLoop(...) {
private lateinit var subagentScope: CoroutineScope
private lateinit var subagentStore: SubagentStore
// ...
fun start() {
subagentScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// ...组装工具、权限、hooks、AgentLoop...
printWelcome()
try {
while (true) {
// REPL 主循环
}
} finally {
subagentScope.cancel() // 结构化并发:退出时取消所有子智能体
}
printGoodbye()
}
}为什么用 SupervisorJob? 普通 Job 的子协程失败会传播给父协程和其他兄弟——一个子智能体抛异常会让整个 subagentScope 取消。SupervisorJob 隔离了失败——单个子任务 FAILED 不影响其他正在运行的子任务。
为什么用 Dispatchers.Default?
子智能体跑 LLM 调用和工具执行,是 CPU + IO 混合型工作。Default 调度器在共享线程池上运行,不阻塞主线程。主 REPL 的 runBlocking { agentLoop.run(input) } 在调用方线程上跑,与后台子任务互不阻塞。
/clear 命令扩展
CMD_CLEAR, CMD_C -> {
agentLoop.messagesHistory.clear()
approvalStore.clear()
todoStore.clear()
subagentStore.clear() // 新增:清空对话也清空任务列表
true
}清空对话时也清空 subagentStore——避免悬挂任务。正在运行的后台协程不会被中断(它们持有对 AgentLoop 的引用),但它们的 markCompleted/markFailed 会被 computeIfPresent 忽略(记录已被 clear 删除)。
关键设计取舍汇总
| 决策点 | 选择 | 理由 |
|---|---|---|
| AgentLoopCore 抽象 | 不提取 | AgentLoop 已支持构造注入;提取增加无用抽象层 |
| 子智能体 PermissionPipeline | 不注入 | 后台运行无交互界面,ReadLinePrompter 死锁;主智能体 Task 调用阶段已审批 |
| 子智能体 HookManager | 是,仅注册 LoggingHook | 便于调试,不注册 TodoDisplayHook 避免干扰主 REPL |
| 子智能体工具集 | 4 内置 + TodoWriteTool,不含 Task/QueryTask | 天然禁止递归派发孙智能体 |
| Task 工具 isReadOnly | false | 副作用:创建协程;串行执行避免并发竞态 |
| QueryTask 工具 isReadOnly | true | 读操作,可并发查询 |
| SubagentStore 类型 | class(ReplLoop 持有) | 与 ToolRegistry/TodoStore 一致;测试隔离靠局部实例 |
| Task/QueryTask 包路径 | subagent/ 包 | 避免 tool.tools → subagent 反向依赖 |
| 协程 scope | SupervisorJob + Dispatchers.Default | 单任务失败不传播;不阻塞主线程 |
| 异步结果获取 | Task + QueryTask 两工具 | 主智能体派发后继续推理,后续轮询查询 |
| 异常处理 | CancellationException 重新抛出,其他异常转 FAILED | 遵守结构化并发;失败信息通过 QueryTask 返回 LLM |
文件清单
src/main/kotlin/com/sepcai/code/
├── subagent/ # [新建] 子智能体领域包
│ ├── SubagentStatus.kt # 状态枚举:PENDING/RUNNING/COMPLETED/FAILED
│ ├── SubagentRecord.kt # 不可变任务记录 data class
│ ├── SubagentPrompts.kt # 子智能体默认 systemPrompt
│ ├── Subagent.kt # 包装独立 AgentLoop
│ ├── SubagentFactory.kt # 工厂:组装隔离依赖图
│ ├── SubagentStore.kt # ConcurrentHashMap + AtomicInteger 状态存储
│ ├── TaskTool.kt # 派发工具(isReadOnly=false)
│ └── QueryTaskTool.kt # 查询工具(isReadOnly=true)
├── agent/
│ ├── AgentConfig.kt # [修改] +maxConcurrentSubagents 字段
│ └── AgentLoop.kt # [修改] +toolRegistry 构造参数 + 3 处引用点
├── tool/
│ ├── ToolRegistry.kt # [修改] object → class
│ └── tools/
│ └── TodoWriteTool.kt # [修改] +store 构造参数 + 消掉日志耦合
├── todo/
│ └── TodoStore.kt # [修改] object → class
├── permission/rules/
│ └── ToolCategoryRule.kt # [修改] +registry 构造参数
└── repl/
└── ReplLoop.kt # [修改] +CoroutineScope + Task/QueryTask 注册 + try/finally
src/test/kotlin/com/sepcai/code/
├── subagent/ # [新建]
│ ├── SubagentFactoryTest.kt # 5 个用例(隔离性、工具集、run 返回值)
│ ├── SubagentStoreTest.kt # 9 个用例(状态机、并发安全)
│ ├── TaskToolTest.kt # 5 个用例(派发、并发上限、参数校验)
│ └── QueryTaskToolTest.kt # 6 个用例(四种状态、未知名、缺参数)
├── tool/
│ ├── ToolRegistryTest.kt # [修改] 删 beforeTest,每用例局部实例
│ └── TodoWriteToolTest.kt # [修改] 删 before/afterTest,每用例注入 store
├── todo/
│ └── TodoStoreTest.kt # [修改] 删 before/afterTest,每用例局部实例
├── permission/rules/
│ └── ToolCategoryRuleTest.kt # [修改] 删 clear,每用例注入 registry
└── agent/
└── AgentLoopTest.kt # [修改] 删 afterTest,每用例注入 registry + 2 个集成测试8 个新源文件 + 7 个修改源文件 · 4 个新测试文件 + 5 个测试修改 · 全部通过。
下一站
s06 完成了阶段二的第二个能力——异步并行。智能体现在能:
- 派发独立子任务到后台执行
- 主智能体继续推理,不被阻塞
- 后续轮询查询子任务状态和结果
但子智能体的结果是内存存储的——/clear 或 /exit 后所有 task 记录消失。当你想让智能体"昨天的重构任务进度继续"时,它仍然无能为力。s12 会引入持久化(design.md 决策 4),但在此之前,s07-s11 会继续完善其他能力。


