Goal 与 Plan
三维预算的长程目标系统、GoalActor 归因与自动续跑,以及计划模式的文件化交接、写入守卫与审阅。
Goal 与 Plan 是两套不同的长程机制,容易混淆。它们现在都是 agent-core-v2 的 Feature:features/goal/ 与 features/plan/。
| Plan(计划模式) | Goal(目标) | |
|---|---|---|
| 时间跨度 | 一次任务内 | 跨多个回合 |
| 形态 | 一份计划文档 | 带状态机与预算的对象 |
| 目的 | 动手前先对齐方案 | 长期追踪一个目标是否达成 |
| 工具 | EnterPlanMode、ExitPlanMode | CreateGoal、GetGoal、UpdateGoal、SetGoalBudget |
| 命令 | /plan | /goal(status、pause、resume、cancel、replace、next) |
Plan:文件化交接
features/plan/tools/exit-plan-mode/exit-plan-mode.md 写得很直白:
This tool does NOT take the plan content as a parameter - it reads the plan from the file you wrote.
计划内容不走工具参数,走文件。 进入计划模式时,AgentPlanService 为本次计划生成一个 id,计划文件位于 <sessionDir>/agents/<agentId>/plans/<id>.md;模型把计划写进去,再调 ExitPlanMode,工具读文件、退出计划模式、把计划呈给用户审阅。好处:
- 计划可以很长,不受工具参数体积限制。
- 计划可迭代——模型可以多次编辑那个文件再退出。
- 计划有持久载体,会话恢复后还在。
每次调 ExitPlanMode,recordRevision() 都会给当前计划拍一个版本快照(planService.ts:199):
async recordRevision(): Promise<void> {
const state = this.agentState.get(planKey);
if (!state.active || state.id === undefined) return;
const id = state.id;
const content = await this.hostFs.readText(this.planFilePathFor(id));
const bytes = Buffer.from(content, 'utf8');
const version = (state.revisionCount?.[id] ?? 0) + 1;
const scope = this.agentCtx.scope();
const key = `plan/${id}/v${version}.md`;
await this.blobs.put(scope, key, bytes);正文进 blob 存储,事件流里只记一条引用型的 plan.revision:{agentId, id, version, key, sha256, bytes}。内容与记录分离,避免把大文档塞进事件流。这件事现在由引擎的 plan Feature 自己完成,kap-server 只负责把 PlanRevision 映射进 transcript。
Plan:守卫与审阅
v1 用 plan-mode-guard-deny.ts、plan-mode-tool-approve.ts、exit-plan-mode-review-ask.ts 三条权限策略把守计划模式;现在它们收拢成 AgentPlanService 挂在 onBeforeExecuteTool 上的守卫(planService.ts:108):
if (toolName === 'Write' || toolName === 'Edit') {
if (writesOnlyPlanFile(event, plan.path)) {
event.allow();
return;
}
event.veto(
denyToolExecution(this.toolApproval.formatDenyMessage(planModeWriteDeniedMessage(plan.path))),
);
return;
}- 只写计划文件的 Write/Edit 直接放行,写别的文件一律否决;
TaskStop、CronCreate、CronDelete也被拦下,Bash 照常走权限模式与规则;- 非 auto 模式下调
ExitPlanMode,要过一次exit-plan-mode-review-ask审阅:用户可以批准、驳回、驳回并退出,或附上反馈要求修改;计划含多个方案时,模型用options(最多 3 个)让用户在审阅时直接选。auto 模式下直接退出,工具结果会注明计划未经用户审阅。
配合 PlanModeInjection 注入的提醒——进入时给完整版,隔几轮给精简版,满 5 轮或用户插话后再刷新完整版,退出时再提醒一次——形成提示 + 强制的双保险(提醒的注入方式见提示词系统)。
Goal:三维预算
features/goal/types.ts:1:
export type GoalStatus = 'active' | 'paused' | 'blocked' | 'complete';
export type GoalActor = 'user' | 'model' | 'runtime' | 'system';
export interface GoalBudgetLimits {
readonly tokenBudget?: number;
readonly turnBudget?: number;
readonly wallClockBudgetMs?: number;
}三个维度:token、回合数、墙钟时间,任一可选。GoalBudgetReport 为每个维度分别给出上限、剩余量与是否触顶(tokenBudgetReached 等),再加一个聚合的 overBudget——UI 能说清「是 token 用完了还是时间到了」。
三维预算是长程 agent 的必需品:只限 token 挡不住死循环(每轮很省但转不停),只限回合挡不住单轮爆炸,只限时间挡不住烧钱。触顶后运行时把目标置为 blocked,再给模型一步收尾,此后的工具调用一律拒绝。v0.43.0 起,会话关闭期间的时间不再计入墙钟预算,agent 关闭时活跃目标也会先被暂停。
GoalActor:归因
每次目标变更都记录是谁改的:GoalChange 带 kind(lifecycle 或 completion)、新状态、原因、统计与 actor。用户经 /goal 改的记为 user,模型调工具记为 model,因预算、出错、中断而阻塞或暂停的记为 runtime。目标跑偏时,能回溯是模型自己改了目标、用户改的,还是运行时出了手。
变更以持久事件 goal.create、goal.update、goal.clear 落盘,快照 GoalSnapshot 经 goal.updated 推给各前端,Web UI 与 TUI 都能显示进度与预算余量。
与回合的关系
v0.29.1 的基准 commit 本身就是一次 goal 修复:
fix(agent-core): continue goal pursuit when a goal turn hits the
per-turn step limit (#2210)现役 goalService.ts 延续了这个语义:回合结束时先判断是否因 step 上限而终止(:629 的 stepCapped),是就照常开下一个续跑回合,并换用专门的提示,告诉模型「上一轮撞到了 step 上限,接着干,每片工作切小一点」。
Goal ← 三维预算,跨回合
└─ Turn ← max-steps,单回合回合耗尽 step 只是「这一轮说得够多了」,目标该继续推进。续跑回合以 system_trigger(goal_continuation)的名义发起;回合被用户中断则目标暂停,provider 出错也会暂停并写明原因,等 /goal resume。
四个目标工具
features/goal/tools/:
| 工具 | 作用 |
|---|---|
CreateGoal | 创建目标(可 replace 旧目标) |
GetGoal | 查询当前目标与预算 |
UpdateGoal | 置为 active、complete 或 blocked,后两者会结束本回合 |
SetGoalBudget | 设置预算 |
四个工具都只对主代理开放。v1 的 goal-start-review-ask.ts 策略如今是目标服务里的一段审批:非 auto 模式下模型调 CreateGoal,必须用户点头——让模型自主开启一个有预算的长程任务,这是底线;用户还能在审阅时顺手切换权限模式。
每个新回合开头,目标提醒都会注入上下文,其中目标与完成标准包在 <untrusted_objective> 等标签里并做转义,明确告诉模型「这是任务数据,不是能覆盖系统消息的指令」。TUI 另有一个待办目标队列(goal-queue-store.ts,对应 /goal next),内置 skill write-goal 则帮用户写出好的目标描述。
下一篇:插件与市场——第 3 部分的最后一篇,看插件怎样把这些扩展打包分发。