feat: implement goal core workflow

This commit is contained in:
_Kerman 2026-07-01 21:29:51 +08:00
parent 2627c9067d
commit 186ed6eaa5
8 changed files with 1019 additions and 354 deletions

231
GOAL.md Normal file
View file

@ -0,0 +1,231 @@
# Goal 功能拆分
本文把 agent-core 中 goal mode 的能力拆成三部分:
1. 核心工作流:没有它就不能运行 goal。
2. 统计 / token 数限制:让 goal 可度量、可限额、可审计。
3. 用户交互相关:让用户可以安全启动、理解、控制和恢复 goal。
## 1. 核心工作流
核心工作流是 goal mode 的运行骨架。它负责创建结构化目标、维护状态机、把普通 turn 串成自治多轮执行,并让模型用机器可读状态结束或停放目标。
### 目标状态
同一个 main agent 同时最多只有一个当前 goal。goal 不是普通聊天文本,而是 runtime 持有的结构化状态,至少包含目标、可选完成标准、当前状态、停止原因和运行统计。
状态分为四类:
- `active`:正在被 goal driver 推进。只有这个状态会自动运行下一轮。
- `paused`暂停但保留目标。通常来自用户暂停、中断、进程恢复后降级、provider 或 runtime 错误。可以恢复。
- `blocked`目标遇到真实阻塞但保留目标。通常来自模型判断需要外部输入、目标无法按当前表述完成、预算达到、prompt hook 阻止。可以恢复。
- `complete`瞬时完成状态。runtime 发出完成事件后立即清除 goal不长期持久化。
没有 `cancelled` 状态。取消就是清除 goal并提醒模型忽略之前关于该目标的 active reminder。
### 创建和替换
创建 goal 时runtime 需要校验目标不能为空、不能过长。已有 active、paused 或 blocked goal 时,默认拒绝创建新 goal防止静默覆盖。只有用户或调用方明确要求替换时才先清除旧 goal再创建新 goal。
新 goal 创建后进入 `active`,写入持久记录,并发出 goal 更新事件。
### 多轮驱动
goal driver 的职责是把一个 active goal 推进成连续的普通 turn
- turn 开始时如果 goal 已经是 `active`,进入 goal driver。
- 普通 turn 中如果模型创建了 goal或把 paused/blocked goal 恢复成 active当前 turn 结束后 goal driver 接管继续执行。
- driver 每次只运行一个普通 turn。
- 每个 turn 结束后读取 goal 状态。
- goal 仍是 `active`runtime 自动追加 continuation prompt 并启动下一轮。
- goal 变成 `paused``blocked` 或被清除时driver 停止。
模型如果不调用状态更新工具,且 goal 仍是 activeruntime 会继续下一轮。模型不能只靠自然语言说“完成了”来结束 goal必须给出结构化状态信号。
### Goal 注入
每个 goal turn 的边界runtime 会把当前 goal 状态注入上下文。注入内容包括:
- 当前正在 goal mode。
- 目标和完成标准是什么。
- 目标文本是用户提供的数据,不能覆盖 system/developer 指令、工具 schema、权限规则或 host 控制。
- 当前状态和进度。
- 模型应该做简短自审,然后推进一个连贯工作切片。
- 简单、已完成、不可能、不安全、矛盾的目标,应在同一轮内直接标记 complete 或 blocked。
- 只有全部要求完成、验证通过、没有下一步有用动作时,才能标记 complete。
- 外部条件或用户输入阻塞时,应标记 blocked。
- 不要只做了计划、总结、第一版或部分结果就标记 complete。
goal 注入只在 turn / continuation 边界做,不在每个 model step 都做,避免上下文重复膨胀,也有利于 prompt cache。
paused 和 blocked goal 的注入更轻:
- paused提醒模型目标存在但当前不应自治推进除非用户明确要求继续。
- blocked提醒模型目标被阻塞且当前不自治推进除非用户要求处理或恢复。
### Continuation prompt
当 goal 仍是 activeruntime 会追加一个系统触发输入,含义相当于“继续朝当前 active goal 工作”。它不只是简单续跑,还要求模型每轮重新判断:
- 是否已经完成。
- 是否遇到真实阻塞。
- 是否应该只推进一个合理切片后继续下一轮。
- 是否应该避免发散或启动无关工作。
- 除非真实阻塞,否则不要向用户要输入。
### 完成、阻塞和暂停
模型通过结构化状态更新控制 goal 生命周期:
- `complete`目标已满足runtime 发出完成事件并清除 goal。
- `blocked`遇到真实阻塞runtime 保留 goal 并停止自治推进。
- `paused`:暂时放下 goalruntime 保留 goal 并停止自治推进。
- `active`:恢复 paused 或 blocked goal。
状态更新工具的输入应保持窄,只表达机器状态。完成总结或阻塞原因由模型随后给用户说明。
当模型标记 complete 后runtime 应再给模型一次收尾机会,生成简短最终回复,说明 goal 已完成、主要做了什么、跑了什么验证。
当模型标记 blocked 后runtime 应再给模型一次收尾机会,说明具体阻塞、需要什么输入或变化才能继续。
如果当前 turn 已经没有 step 预算,不应为了收尾总结强行再跑一步,避免把“没法写总结”变成 turn 失败。
### 错误停车
goal mode 把技术运行失败视为可恢复停车:
- 用户中断当前 turngoal 变 paused。
- provider rate limitgoal 变 paused。
- provider 连接错误、认证错误、API 错误goal 变 paused。
- 模型配置错误goal 变 paused。
- runtime 异常goal 变 paused。
- provider safety filtergoal 变 paused。
业务、规则或外部条件阻塞则变 blocked
- prompt hook 阻止目标。
- 模型判断无法继续。
- 预算达到。
- 需要用户或外部系统提供新条件。
### 持久化和恢复
goal 的创建、更新、完成、阻塞、清除应写入可恢复记录。session 恢复时runtime 用记录重建 goal。
恢复时如果发现 goal 原来是 active不应自动继续跑而是降级为 paused。因为旧进程中的 active turn 不可能还活着,自动继续会造成重启后偷偷消耗资源。
paused 和 blocked 原样保留。complete 理论上不长期存在,因为完成后会清除。
fork session 时不继承源 session 的 goal并提醒模型不要继续源 session 的旧目标。
## 2. 统计 / token 数限制
这一部分让 goal 可度量、可限额、可审计。没有它goal 仍然可以运行,但不可控。
### 运行统计
goal 统计包括:
- continuation turn 数。
- token 数。
- active wall-clock 时间。
统计只在 goal 是 `active` 时增长。paused 和 blocked 期间不继续计数。
turn 统计在每个 goal turn 准备运行时增加,因此模型在某一轮里标记 complete 时,这一轮也计入最终统计。
token 统计在 model step 结束后累计。没有 active goal 时,不记入 goal。token 统计应以静默更新为主,不应每一步都刷 UI。
时间统计只计算 active pursuit 时间。进入 active 时开启计时区间,离开 active 时折算进累计时间pause/resume 会形成新的 active 区间。
### 预算
goal 预算包括:
- turn budget。
- token budget。
- wall-clock budget。
默认没有预算。只有用户明确给出硬限制时才设置,例如“最多 20 轮”“不超过 500k token”“30 分钟内”。模糊表达如“尽快”“别花太久”不能设置预算,模型也不能自行发明预算。
时间预算需要合理范围。过短或过长应拒绝。turn 和 token 预算应规范化为正整数。
### 预算硬停
预算检查应发生在 goal turn 开始前和结束后。token budget 还应在 model step 后触发停止,避免超额后继续下一步。
一旦达到预算runtime 应直接把 goal 标记为 blocked原因是配置预算已达到。这个 blocked 仍可恢复,但如果预算不变,恢复后可能立刻再次 blocked。
### 预算引导和最终统计
当预算未接近时,模型提示应鼓励稳定推进。当任一预算达到 75% 以上时,提示应转为收敛,避免启动新的可选工作。
complete 和 blocked 的最终回复提示应包含 worked turns、elapsed time、tokens used 等统计信息。UI 事件也应带当前 snapshot 和变化类型。
telemetry 可以记录 goal 创建、预算设置、continuation、状态变化、清除等事件但不应包含目标文本、停止原因等敏感内容。
## 3. 用户交互相关
这一部分让用户可以安全启动、理解、控制和恢复 goal。没有它runtime 仍可能运行,但交互体验和安全边界不足。
### 生命周期控制
用户可以直接控制 goal
- 创建。
- 查看。
- 暂停。
- 恢复。
- 取消。
这些操作可以不经过模型 turn。pause 把 active goal 变 pausedresume 把 paused 或 blocked goal 变 activecancel 直接清除当前 goal。
resume 会清除旧停止原因表示开始新的尝试。paused/blocked goal 不会因为用户发普通消息就自动继续。
### 模型发起 goal 的确认
模型可以代表用户创建 goal但只有在用户明确要求启动 goal、自治工作或宿主 goal-intake 提示要求时才应该这样做。普通请求不能被模型擅自升级成 goal。
模型发起 CreateGoal 时,非 auto 权限模式下应触发用户确认。确认菜单允许用户选择本次 goal 的运行权限模式。用户拒绝则 goal 不创建。
`GetGoal``SetGoalBudget``UpdateGoal` 只改 goal runtime 状态,默认可以更容易批准。真正写文件、跑 shell、访问敏感路径等仍走普通权限系统。
### 暂停、阻塞和取消后的提示
paused goal 的上下文提示应说明目标存在但当前不应继续做,除非用户明确要求继续。
blocked goal 的上下文提示应说明目标被阻塞且当前不自治推进,可以在用户要求时帮助解阻,否则正常处理当前请求。
cancel 后应追加提醒,让模型忽略旧 goal 的 active reminder避免旧上下文诱导模型继续已经取消的目标。
### 完成和阻塞的用户回复
complete 后goal 被清除,模型应给用户一条简短完成总结,说明完成了什么、做了什么验证。
blocked 后goal 保留,模型应给用户一条简短阻塞说明,说明具体阻塞和继续所需输入、权限、外部条件或变更。
### Tool 暴露和隔离
goal 工具只给 main agent。subagent 不应直接创建、恢复、结束主 goal。
没有 goal 时,模型不应看到 `UpdateGoal``SetGoalBudget`。有 goal 时才暴露这些控制工具。
goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有用户语义。
### 辅助写 goal
`write-goal` 类能力用于帮助用户把粗糙意图整理成适合 goal mode 的完成契约。好的 goal 应明确:
- end state什么条件必须变成真。
- proof用什么可观察证据证明完成。
- boundaries工作范围和禁止触碰的内容。
- loop如何迭代推进。
- stop rule什么情况下停止并报告而不是强行继续。
预算是 opt-in不应默认加入也不应把 turn cap 写进目标文本。
### UI 和会话语义
goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshotUI 可以继续展示可恢复 goal。
session 恢复时active goal 会变 paused避免重启后自动继续。fork session 时不继承 goal并提醒模型不要继续源 session 的目标。

View file

@ -243,6 +243,10 @@ goal --> systemReminder #34495E
goal --> replayBuilder #34495E
goal --> telemetry #34495E
goal --> contextInjector #34495E
goal --> contextMemory #34495E
goal --> turn #34495E
goal --> toolRegistry #34495E
goal --> permissionMode #34495E
skill --> prompt #34495E
skill --> eventSink #34495E
skill --> wireRecord #34495E
@ -349,6 +353,8 @@ fullCompaction ..> turn #16A085 : hooks.onContextOverflow
permissionRules ..> wireRecord #16A085 : permission.rules.add / record_approval_result
skill ..> wireRecord #16A085 : skill.activate
goal ..> wireRecord #16A085 : goal.create/update/clear
goal ..> turn #16A085 : turn lifecycle hooks
goal ..> eventSink #16A085 : hook.result / goal.updated
microCompaction ..> wireRecord #16A085 : micro_compaction.apply / full_compaction.complete
swarm ..> wireRecord #16A085 : swarm_mode.enter/exit
swarm ..> turn #16A085 : hooks.onEnded

View file

@ -17,7 +17,7 @@ export interface IAgentGoalService {
createGoal(input: CreateGoalInput, actor?: GoalActor): Promise<GoalSnapshot>;
pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
resumeGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
cancelGoal(actor?: GoalActor): Promise<GoalSnapshot>;
cancelGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
setBudgetLimits(
input: { readonly budgetLimits: GoalBudgetLimits },
actor?: GoalActor,

View file

@ -1,17 +1,42 @@
import {
randomUUID } from 'node:crypto';
/**
* `goal` domain (L4) - `IAgentGoalService` implementation.
*
* Owns the per-agent goal lifecycle; persists records through `wireRecord`,
* broadcasts through `eventSink`, injects reminders through `contextInjector`,
* drives continuation turns through `turn`, updates context through
* `contextMemory`, writes system reminders through `systemReminder`, registers
* model tools through `toolRegistry`, and reports telemetry through
* `telemetry`. Bound at Agent scope.
*/
import { randomUUID } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { Disposable } from "#/_base/di";
import {
Disposable,
} from "#/_base/di";
import { ErrorCodes, KimiError } from "#/errors";
ErrorCodes,
KimiError,
toKimiErrorPayload,
type KimiErrorPayload,
} from "#/errors";
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import {
ensureMessageId,
IAgentContextMemoryService,
type ContextMessage,
type PromptOrigin,
} from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentPermissionModeService } from '#/agent/permissionMode';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import {
IAgentTurnService,
type Turn,
type TurnEndedContext,
type TurnStepContext,
} from '#/agent/turn';
import type { TelemetryProperties } from '#/app/telemetry';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -38,6 +63,10 @@ import type {
} from './types';
import { CreateGoalTool } from '#/agent/goal/tools/create-goal';
import { GetGoalTool } from '#/agent/goal/tools/get-goal';
import {
buildGoalBlockedReasonPrompt,
buildGoalCompletionSummaryPrompt,
} from '#/agent/goal/tools/outcome-prompts';
import { SetGoalBudgetTool } from '#/agent/goal/tools/set-goal-budget';
import { UpdateGoalTool } from '#/agent/goal/tools/update-goal';
@ -76,6 +105,36 @@ const GOAL_FORK_CLEARED_REMINDER = [
'Handle requests normally unless the user starts a new goal.',
].join(' ');
const GOAL_CONTINUATION_ORIGIN: PromptOrigin = {
kind: 'system_trigger',
name: 'goal_continuation',
};
const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion_summary';
const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked_reason';
const GOAL_RATE_LIMIT_PAUSE_REASON = 'Paused after provider rate limit';
const GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX = 'Paused after provider connection error';
const GOAL_PROVIDER_AUTH_PAUSE_PREFIX = 'Paused after provider authentication error';
const GOAL_PROVIDER_API_PAUSE_PREFIX = 'Paused after provider API error';
const GOAL_MODEL_CONFIG_PAUSE_PREFIX = 'Paused after model configuration error';
const GOAL_RUNTIME_PAUSE_PREFIX = 'Paused after runtime error';
const GOAL_PROVIDER_FILTERED_PAUSE_REASON = 'Paused after provider safety policy block';
const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
const GOAL_CONTINUATION_PROMPT = [
'Continue working toward the active goal.',
'Keep the self-audit brief. Do not explore unrelated interpretations once the goal can be',
'decided. If the objective is simple, already answered, impossible, unsafe, or contradictory,',
'do not run another goal turn. Explain briefly if useful, then call UpdateGoal with `complete`',
'or `blocked` in the same turn. Otherwise, weigh the objective and any completion criteria',
'against the work done so far. Goal mode is iterative: do one coherent slice of work, then',
'reassess. Call UpdateGoal with `complete` only when all required work is done, any stated',
'validation has passed, and there is no useful next action. Do not mark complete after only',
'producing a plan, summary, first pass, or partial result. If an external condition or required',
'user input prevents progress, or the objective cannot be completed as stated, call UpdateGoal',
'with `blocked`. Otherwise keep going - use the existing conversation context and your tools,',
'and do not ask the user for input unless a real blocker prevents progress.',
].join(' ');
export interface GoalServiceOptions {
readonly enabled?: boolean | (() => boolean);
readonly injection?: GoalInjectionOptions;
@ -98,6 +157,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
declare readonly _serviceBrand: undefined;
private state: GoalState | undefined;
private readonly goalDrivenTurns = new Set<number>();
private readonly countedGoalTurns = new Set<number>();
private readonly goalOutcomeContinuationTurns = new Set<number>();
private readonly promptHookBlockedTurns = new Set<number>();
constructor(
private readonly options: GoalServiceOptions = {},
@ -106,7 +169,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IAgentReplayBuilderService private readonly replayBuilder: IAgentReplayBuilderService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentContextInjectorService private readonly dynamicInjector: IAgentContextInjectorService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService,
) {
@ -146,11 +211,42 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
this.normalizeAfterReplay();
}),
);
this._register(
turnService.hooks.onLaunched.register('goal-track-launched-turn', (ctx, next) => {
this.handleTurnLaunched(ctx.turn);
return next();
}),
);
this._register(
turnService.hooks.beforeStep.register('goal-count-turn', async (ctx, next) => {
await this.handleBeforeStep(ctx);
await next();
}),
);
this._register(
turnService.hooks.afterStep.register('goal-outcome-continuation', async (ctx, next) => {
await next();
this.handleAfterStep(ctx);
}),
);
this._register(
turnService.hooks.onEnded.register('goal-drive-continuation', async (ctx, next) => {
await next();
await this.handleTurnEnded(ctx);
}),
);
this._register(
events.on((event) => {
if (event.type === 'hook.result' && event.blocked === true) {
this.promptHookBlockedTurns.add(event.turnId);
}
}),
);
this._register(toolRegistry.register(new CreateGoalTool(this, this.permissionMode)));
this._register(toolRegistry.register(new GetGoalTool(this)));
this._register(toolRegistry.register(new SetGoalBudgetTool(this)));
this._register(toolRegistry.register(new UpdateGoalTool(this, this.reminders)));
this._register(toolRegistry.register(new UpdateGoalTool(this)));
}
get enabled(): boolean {
@ -289,7 +385,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
return this.toSnapshot(state);
}
async cancelGoal(actor: GoalActor = 'user'): Promise<GoalSnapshot> {
async cancelGoal(
_input: GoalReasonInput = {},
actor: GoalActor = 'user',
): Promise<GoalSnapshot> {
const state = this.requireState();
const snapshot = this.toSnapshot(state);
this.clearInternal(actor);
@ -314,7 +413,14 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
change: { kind: 'lifecycle', status: 'blocked', reason: input.reason, actor },
});
this.appendStatusUpdate(state, actor, input.reason);
return this.toSnapshot(state);
const snapshot = this.toSnapshot(state);
if (actor === 'model') {
this.reminders.appendSystemReminder(buildGoalBlockedReasonPrompt(snapshot), {
kind: 'system_trigger',
name: GOAL_BLOCKED_REMINDER_NAME,
});
}
return snapshot;
}
async markComplete(
@ -334,6 +440,12 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
stats: this.statsOf(state),
actor,
});
if (actor === 'model') {
this.reminders.appendSystemReminder(buildGoalCompletionSummaryPrompt(snapshot), {
kind: 'system_trigger',
name: GOAL_COMPLETION_REMINDER_NAME,
});
}
this.clearInternal(actor);
return snapshot;
}
@ -361,6 +473,66 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
return this.toSnapshot(state);
}
private handleTurnLaunched(turn: Turn): void {
if (this.state?.status === 'active') this.goalDrivenTurns.add(turn.id);
this.goalOutcomeContinuationTurns.delete(turn.id);
this.promptHookBlockedTurns.delete(turn.id);
}
private async handleBeforeStep(ctx: TurnStepContext): Promise<void> {
if (!this.goalDrivenTurns.has(ctx.turn.id)) return;
if (this.countedGoalTurns.has(ctx.turn.id)) return;
this.countedGoalTurns.add(ctx.turn.id);
await this.incrementTurn();
}
private handleAfterStep(ctx: TurnStepContext): void {
if (this.goalOutcomeContinuationTurns.has(ctx.turn.id)) return;
if (!isGoalOutcomeReminder(this.context.get().at(-1))) return;
this.goalOutcomeContinuationTurns.add(ctx.turn.id);
ctx.continueTurn = true;
}
private async handleTurnEnded(ctx: TurnEndedContext): Promise<void> {
this.goalDrivenTurns.delete(ctx.turn.id);
this.countedGoalTurns.delete(ctx.turn.id);
this.goalOutcomeContinuationTurns.delete(ctx.turn.id);
const blockedByPromptHook = this.promptHookBlockedTurns.delete(ctx.turn.id);
if (blockedByPromptHook) {
await this.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' });
return;
}
if (ctx.result.reason === 'cancelled') {
await this.pauseOnInterrupt({ reason: 'Paused after interruption' });
return;
}
if (ctx.result.reason === 'failed') {
await this.pauseActiveGoal({ reason: goalFailurePauseReason(ctx.result.error) });
return;
}
if (ctx.result.reason === 'filtered') {
await this.pauseActiveGoal({ reason: GOAL_PROVIDER_FILTERED_PAUSE_REASON });
return;
}
if (this.state?.status !== 'active') return;
if (this.turnService.getActiveTurn() !== undefined) return;
this.launchContinuationTurn();
}
private launchContinuationTurn(): void {
const message = ensureMessageId({
role: 'user',
content: [{ type: 'text', text: GOAL_CONTINUATION_PROMPT }],
toolCalls: [],
origin: GOAL_CONTINUATION_ORIGIN,
});
this.context.splice(this.context.get().length, 0, [message]);
this.turnService.launch(GOAL_CONTINUATION_ORIGIN, message.id);
}
private normalizeAfterReplay(): void {
const state = this.state;
if (state === undefined) return;
@ -593,10 +765,51 @@ function normalizeCompletionCriterion(value: string | undefined): string | undef
return trimmed?.length ? trimmed : undefined;
}
function isGoalOutcomeReminder(message: ContextMessage | undefined): boolean {
if (message?.origin?.kind !== 'system_trigger') return false;
return (
message.origin.name === GOAL_COMPLETION_REMINDER_NAME ||
message.origin.name === GOAL_BLOCKED_REMINDER_NAME
);
}
function goalFailurePauseReason(error: unknown): string {
const payload = normalizeGoalErrorPayload(error);
switch (payload.code) {
case ErrorCodes.PROVIDER_RATE_LIMIT:
return GOAL_RATE_LIMIT_PAUSE_REASON;
case ErrorCodes.PROVIDER_CONNECTION_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, payload.message);
case ErrorCodes.PROVIDER_AUTH_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_AUTH_PAUSE_PREFIX, payload.message);
case ErrorCodes.PROVIDER_API_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_API_PAUSE_PREFIX, payload.message);
case ErrorCodes.MODEL_NOT_CONFIGURED:
return pauseReasonWithMessage(GOAL_MODEL_CONFIG_PAUSE_PREFIX, LLM_NOT_SET_MESSAGE);
case ErrorCodes.MODEL_CONFIG_INVALID:
return pauseReasonWithMessage(GOAL_MODEL_CONFIG_PAUSE_PREFIX, payload.message);
default:
return pauseReasonWithMessage(GOAL_RUNTIME_PAUSE_PREFIX, payload.message);
}
}
function normalizeGoalErrorPayload(error: unknown): KimiErrorPayload {
const payload = toKimiErrorPayload(error);
if (payload.code === ErrorCodes.MODEL_NOT_CONFIGURED) {
return { ...payload, message: LLM_NOT_SET_MESSAGE };
}
return payload;
}
function pauseReasonWithMessage(prefix: string, message: string | undefined): string {
const trimmed = message?.trim();
return trimmed === undefined || trimmed.length === 0 ? prefix : `${prefix}: ${trimmed}`;
}
registerScopedService(
LifecycleScope.Agent,
IAgentGoalService,
AgentGoalService,
InstantiationType.Delayed,
InstantiationType.Eager,
'goal',
);

View file

@ -6,25 +6,17 @@
*
* The argument is intentionally just a status enum no reason or evidence. The
* model explains itself in its own reply; the status is the machine-readable
* signal. The tool is only offered to the model while a goal exists.
* signal.
*/
import { z } from 'zod';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { IAgentSystemReminderService } from '#/agent/systemReminder';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import type { IAgentGoalService } from '#/agent/goal/goal';
import {
buildGoalBlockedReasonPrompt,
buildGoalCompletionSummaryPrompt,
} from './outcome-prompts';
import DESCRIPTION from './update-goal.md?raw';
const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion_summary';
const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked_reason';
export const UpdateGoalToolInputSchema = z
.object({
status: z
@ -40,10 +32,7 @@ export class UpdateGoalTool implements BuiltinTool<UpdateGoalToolInput> {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(UpdateGoalToolInputSchema);
constructor(
private readonly goal: IAgentGoalService,
private readonly reminders: IAgentSystemReminderService,
) {}
constructor(private readonly goal: IAgentGoalService) {}
resolveExecution(args: UpdateGoalToolInput): ToolExecution {
return {
@ -56,28 +45,11 @@ export class UpdateGoalTool implements BuiltinTool<UpdateGoalToolInput> {
return { output: 'Goal resumed.' };
}
if (args.status === 'complete') {
const completed = await this.goal.markComplete({}, 'model');
// `complete` is transient: markComplete announces then clears the
// record. Store the summary request as a system reminder, so the next
// provider request ends with a user message after the UpdateGoal tool
// result. Anthropic-compatible providers reject trailing assistant
// messages as unsupported prefill.
if (completed !== null) {
this.reminders.appendSystemReminder(buildGoalCompletionSummaryPrompt(completed), {
kind: 'system_trigger',
name: GOAL_COMPLETION_REMINDER_NAME,
});
}
await this.goal.markComplete({}, 'model');
return { output: 'Goal marked complete.', stopTurn: true };
}
if (args.status === 'blocked') {
const blocked = await this.goal.markBlocked({}, 'model');
if (blocked !== null) {
this.reminders.appendSystemReminder(buildGoalBlockedReasonPrompt(blocked), {
kind: 'system_trigger',
name: GOAL_BLOCKED_REMINDER_NAME,
});
}
await this.goal.markBlocked({}, 'model');
return { output: 'Goal marked blocked.', stopTurn: true };
}
await this.goal.pauseGoal({}, 'model');

View file

@ -5,15 +5,18 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentGoalService, type AgentGoalService } from '#/agent/goal';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentTurnService, type Turn, type TurnResult } from '#/agent/turn';
import type { PersistedWireRecord, WireRecord } from '#/agent/wireRecord';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import {
InMemoryWireRecordPersistence,
agentService,
createTestAgent,
telemetryServices,
wireRecordPersistenceServices,
type TestAgentContext,
} from '../harness';
import { stubTurn, type StubTurn } from '../turn/stubs';
type GoalServiceTestManager = IAgentGoalService & AgentGoalService;
type GoalRecord = Extract<PersistedWireRecord, { type: `goal.${string}` }>;
@ -35,6 +38,30 @@ async function restoreGoalRecords(
await ctx.restore(records as readonly PersistedWireRecord[]);
}
function makeTurn(id: number): Turn {
return {
id,
abortController: new AbortController(),
ready: Promise.resolve(),
result: Promise.resolve({ reason: 'completed' }),
};
}
async function runGoalStep(turnService: IAgentTurnService, turn: Turn): Promise<boolean> {
const step = { turn, continueTurn: false };
await turnService.hooks.beforeStep.run(step);
await turnService.hooks.afterStep.run(step);
return step.continueTurn;
}
async function endTurn(
turnService: IAgentTurnService,
turn: Turn,
result: TurnResult = { reason: 'completed' },
): Promise<void> {
await turnService.hooks.onEnded.run({ turn, result });
}
describe('AgentGoalService', () => {
let ctx: TestAgentContext;
let context: IAgentContextMemoryService;
@ -70,350 +97,473 @@ describe('AgentGoalService', () => {
}
});
describe('AgentGoalService creation', () => {
it('creates a goal and exposes it through getGoal', async () => {
const snapshot = await goals.createGoal({ objective: 'Ship feature X' });
describe('AgentGoalService creation', () => {
it('creates a goal and exposes it through getGoal', async () => {
const snapshot = await goals.createGoal({ objective: 'Ship feature X' });
expect(snapshot.objective).toBe('Ship feature X');
expect(snapshot.status).toBe('active');
expect(goals.getGoal().goal?.goalId).toBe(snapshot.goalId);
});
it('stores a completion criterion when provided', async () => {
const snapshot = await goals.createGoal({
objective: 'Ship feature X',
completionCriterion: ' tests pass ',
expect(snapshot.objective).toBe('Ship feature X');
expect(snapshot.status).toBe('active');
expect(goals.getGoal().goal?.goalId).toBe(snapshot.goalId);
});
expect(snapshot.completionCriterion).toBe('tests pass');
expect(goals.getGoal().goal?.completionCriterion).toBe('tests pass');
});
it('stores a completion criterion when provided', async () => {
const snapshot = await goals.createGoal({
objective: 'Ship feature X',
completionCriterion: ' tests pass ',
});
it('sets no default work caps when none is provided', async () => {
const snapshot = await goals.createGoal({ objective: 'Do work' });
expect(snapshot.budget.turnBudget).toBeNull();
expect(snapshot.budget.tokenBudget).toBeNull();
expect(snapshot.budget.wallClockBudgetMs).toBeNull();
expect(snapshot.budget.overBudget).toBe(false);
});
it('rejects empty and too-long objectives', async () => {
await expect(goals.createGoal({ objective: ' ' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_OBJECTIVE_EMPTY,
expect(snapshot.completionCriterion).toBe('tests pass');
expect(goals.getGoal().goal?.completionCriterion).toBe('tests pass');
});
await expect(goals.createGoal({ objective: 'x'.repeat(4001) })).rejects.toMatchObject({
code: ErrorCodes.GOAL_OBJECTIVE_TOO_LONG,
it('sets no default work caps when none is provided', async () => {
const snapshot = await goals.createGoal({ objective: 'Do work' });
expect(snapshot.budget.turnBudget).toBeNull();
expect(snapshot.budget.tokenBudget).toBeNull();
expect(snapshot.budget.wallClockBudgetMs).toBeNull();
expect(snapshot.budget.overBudget).toBe(false);
});
it('rejects empty and too-long objectives', async () => {
await expect(goals.createGoal({ objective: ' ' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_OBJECTIVE_EMPTY,
});
await expect(goals.createGoal({ objective: 'x'.repeat(4001) })).rejects.toMatchObject({
code: ErrorCodes.GOAL_OBJECTIVE_TOO_LONG,
});
});
it('rejects duplicate active, paused, and blocked goals without replace', async () => {
await goals.createGoal({ objective: 'first' });
await expect(goals.createGoal({ objective: 'second' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_ALREADY_EXISTS,
});
await goals.pauseGoal();
await expect(goals.createGoal({ objective: 'second' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_ALREADY_EXISTS,
});
await goals.resumeGoal();
await goals.markBlocked({ reason: 'stuck' });
await expect(goals.createGoal({ objective: 'second' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_ALREADY_EXISTS,
});
});
it('replaces an existing goal when replace is set', async () => {
const first = await goals.createGoal({ objective: 'first' });
const second = await goals.createGoal({ objective: 'second', replace: true });
await ctx.wireRecord.flush();
expect(second.goalId).not.toBe(first.goalId);
expect(goals.getGoal().goal?.objective).toBe('second');
expect(goalRecords(records).map((record) => record.type)).toEqual([
'goal.create',
'goal.clear',
'goal.create',
]);
});
it('cancels with dispatcher-style empty input', async () => {
await goals.createGoal({ objective: 'work' });
const removed = await goals.cancelGoal({});
expect(removed.status).toBe('active');
expect(goals.getGoal().goal).toBeNull();
});
});
it('rejects duplicate active, paused, and blocked goals without replace', async () => {
await goals.createGoal({ objective: 'first' });
await expect(goals.createGoal({ objective: 'second' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_ALREADY_EXISTS,
describe('AgentGoalService lifecycle', () => {
it('emits typed lifecycle and completion changes', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
expect(events.at(-1)?.change).toBeUndefined();
await goals.pauseGoal();
expect(events.at(-1)?.change).toMatchObject({ kind: 'lifecycle', status: 'paused' });
await goals.resumeGoal();
expect(events.at(-1)?.change).toMatchObject({ kind: 'lifecycle', status: 'active' });
await goals.markComplete({ reason: 'done' }, 'model');
const completion = events.find((event) => event.change?.kind === 'completion')?.change;
expect(completion).toMatchObject({ kind: 'completion', status: 'complete', reason: 'done' });
expect(goals.getGoal().goal).toBeNull();
expect(events.at(-1)?.snapshot).toBeNull();
});
await goals.pauseGoal();
await expect(goals.createGoal({ objective: 'second' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_ALREADY_EXISTS,
it('keeps blocked goals resumable', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
const blocked = await goals.markBlocked({ reason: 'need creds' });
expect(blocked?.status).toBe('blocked');
expect(blocked?.terminalReason).toBe('need creds');
const resumed = await goals.resumeGoal();
expect(resumed.status).toBe('active');
expect(resumed.terminalReason).toBeUndefined();
});
await goals.resumeGoal();
await goals.markBlocked({ reason: 'stuck' });
await expect(goals.createGoal({ objective: 'second' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_ALREADY_EXISTS,
it('pauseOnInterrupt parks active goals and no-ops for stopped goals', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
const paused = await goals.pauseOnInterrupt({ reason: 'Paused after interruption' });
expect(paused?.status).toBe('paused');
expect(paused?.terminalReason).toBe('Paused after interruption');
expect(await goals.pauseOnInterrupt({ reason: 'again' })).toBeNull();
expect(goals.getGoal().goal?.status).toBe('paused');
});
it('cancelGoal discards the goal and throws when missing', async () => {
await goals.createGoal({ objective: 'work' });
const removed = await goals.cancelGoal();
expect(removed.status).toBe('active');
expect(goals.getGoal()).toEqual({ goal: null });
const reminder = context.get().at(-1);
expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_cancelled' });
expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders');
await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND });
});
});
it('replaces an existing goal when replace is set', async () => {
const first = await goals.createGoal({ objective: 'first' });
const second = await goals.createGoal({ objective: 'second', replace: true });
await ctx.wireRecord.flush();
describe('AgentGoalService accounting and budgets', () => {
it('counts tokens and turns only while active', async () => {
await goals.createGoal({ objective: 'work' });
await goals.recordTokenUsage(30);
await goals.incrementTurn();
expect(goals.getGoal().goal).toMatchObject({ tokensUsed: 30, turnsUsed: 1 });
expect(second.goalId).not.toBe(first.goalId);
expect(goals.getGoal().goal?.objective).toBe('second');
expect(goalRecords(records).map((record) => record.type)).toEqual([
'goal.create',
'goal.clear',
'goal.create',
]);
});
});
describe('AgentGoalService lifecycle', () => {
it('emits typed lifecycle and completion changes', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
expect(events.at(-1)?.change).toBeUndefined();
await goals.pauseGoal();
expect(events.at(-1)?.change).toMatchObject({ kind: 'lifecycle', status: 'paused' });
await goals.resumeGoal();
expect(events.at(-1)?.change).toMatchObject({ kind: 'lifecycle', status: 'active' });
await goals.markComplete({ reason: 'done' }, 'model');
const completion = events.find((event) => event.change?.kind === 'completion')?.change;
expect(completion).toMatchObject({ kind: 'completion', status: 'complete', reason: 'done' });
expect(goals.getGoal().goal).toBeNull();
expect(events.at(-1)?.snapshot).toBeNull();
});
it('keeps blocked goals resumable', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
const blocked = await goals.markBlocked({ reason: 'need creds' });
expect(blocked?.status).toBe('blocked');
expect(blocked?.terminalReason).toBe('need creds');
const resumed = await goals.resumeGoal();
expect(resumed.status).toBe('active');
expect(resumed.terminalReason).toBeUndefined();
});
it('pauseOnInterrupt parks active goals and no-ops for stopped goals', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
const paused = await goals.pauseOnInterrupt({ reason: 'Paused after interruption' });
expect(paused?.status).toBe('paused');
expect(paused?.terminalReason).toBe('Paused after interruption');
expect(await goals.pauseOnInterrupt({ reason: 'again' })).toBeNull();
expect(goals.getGoal().goal?.status).toBe('paused');
});
it('cancelGoal discards the goal and throws when missing', async () => {
await goals.createGoal({ objective: 'work' });
const removed = await goals.cancelGoal();
expect(removed.status).toBe('active');
expect(goals.getGoal()).toEqual({ goal: null });
const reminder = context.get().at(-1);
expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_cancelled' });
expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders');
await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND });
});
});
describe('AgentGoalService accounting and budgets', () => {
it('counts tokens and turns only while active', async () => {
await goals.createGoal({ objective: 'work' });
await goals.recordTokenUsage(30);
await goals.incrementTurn();
expect(goals.getGoal().goal).toMatchObject({ tokensUsed: 30, turnsUsed: 1 });
await goals.pauseGoal();
await goals.recordTokenUsage(12);
await goals.incrementTurn();
expect(goals.getGoal().goal).toMatchObject({ tokensUsed: 30, turnsUsed: 1 });
});
it('sets budget limits through SetGoalBudget-style updates', async () => {
await goals.createGoal({ objective: 'work' });
const snapshot = await goals.setBudgetLimits({
budgetLimits: { tokenBudget: 100, turnBudget: 2, wallClockBudgetMs: 1000 },
}, 'model');
expect(snapshot.budget.tokenBudget).toBe(100);
expect(snapshot.budget.turnBudget).toBe(2);
expect(snapshot.budget.wallClockBudgetMs).toBe(1000);
});
it('tracks telemetry without goal text', async () => {
await goals.createGoal({ objective: 'private objective', replace: true });
await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 100 } }, 'model');
await goals.incrementTurn();
await goals.pauseGoal({ reason: 'private pause reason' });
await goals.resumeGoal();
await goals.markComplete({ reason: 'private completion reason' }, 'model');
expect(telemetry.map((record) => record.event)).toEqual([
'goal_created',
'goal_budget_set',
'goal_continued',
'goal_status_changed',
'goal_status_changed',
'goal_status_changed',
'goal_cleared',
]);
expect(telemetry[0]?.properties).toEqual({ actor: 'user', replace: true });
expect(telemetry[1]?.properties).toMatchObject({ actor: 'model', has_token_budget: true });
expect(telemetry[3]?.properties).toMatchObject({ status: 'paused', actor: 'user' });
expect(JSON.stringify(telemetry)).not.toContain('private objective');
expect(JSON.stringify(telemetry)).not.toContain('private pause reason');
expect(JSON.stringify(telemetry)).not.toContain('private completion reason');
});
});
describe('AgentGoalService records', () => {
it('records only replay-relevant create/update/clear fields', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
await goals.recordTokenUsage(5);
await goals.incrementTurn();
await goals.setBudgetLimits({ budgetLimits: { turnBudget: 2 } }, 'model');
await goals.markBlocked({ reason: 'stuck' });
await goals.cancelGoal();
await ctx.wireRecord.flush();
const recordsWithoutMetadata = goalRecords(records);
expect(recordsWithoutMetadata).toEqual([
expect.objectContaining({
type: 'goal.create',
goalId: expect.any(String),
objective: 'work',
completionCriterion: 'tests pass',
}),
expect.objectContaining({ type: 'goal.update', tokensUsed: 5 }),
expect.objectContaining({ type: 'goal.update', turnsUsed: 1 }),
expect.objectContaining({
type: 'goal.update',
budgetLimits: { turnBudget: 2 },
}),
expect.objectContaining({
type: 'goal.update',
status: 'blocked',
reason: 'stuck',
actor: 'runtime',
}),
expect.objectContaining({ type: 'goal.clear' }),
]);
expect(recordsWithoutMetadata[0]).not.toHaveProperty('actor');
expect(recordsWithoutMetadata[0]).not.toHaveProperty('budgetLimits');
expect(recordsWithoutMetadata[1]).not.toHaveProperty('goalId');
expect(recordsWithoutMetadata[1]).not.toHaveProperty('status');
expect(recordsWithoutMetadata.at(-1)).not.toHaveProperty('goalId');
expect(recordsWithoutMetadata.at(-1)).not.toHaveProperty('reason');
});
it('restores state from patch records', async () => {
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'work',
completionCriterion: 'tests pass',
time: Date.parse('2026-01-01T00:00:00.000Z'),
},
{ type: 'goal.update', tokensUsed: 5 },
{ type: 'goal.update', turnsUsed: 1 },
{ type: 'goal.update', budgetLimits: { turnBudget: 2 } },
{ type: 'goal.update', status: 'blocked', reason: 'stuck' },
]);
expect(goals.getGoal().goal).toMatchObject({
objective: 'work',
completionCriterion: 'tests pass',
status: 'blocked',
terminalReason: 'stuck',
tokensUsed: 5,
turnsUsed: 1,
await goals.pauseGoal();
await goals.recordTokenUsage(12);
await goals.incrementTurn();
expect(goals.getGoal().goal).toMatchObject({ tokensUsed: 30, turnsUsed: 1 });
});
it('sets budget limits through SetGoalBudget-style updates', async () => {
await goals.createGoal({ objective: 'work' });
const snapshot = await goals.setBudgetLimits({
budgetLimits: { tokenBudget: 100, turnBudget: 2, wallClockBudgetMs: 1000 },
}, 'model');
expect(snapshot.budget.tokenBudget).toBe(100);
expect(snapshot.budget.turnBudget).toBe(2);
expect(snapshot.budget.wallClockBudgetMs).toBe(1000);
});
it('tracks telemetry without goal text', async () => {
await goals.createGoal({ objective: 'private objective', replace: true });
await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 100 } }, 'model');
await goals.incrementTurn();
await goals.pauseGoal({ reason: 'private pause reason' });
await goals.resumeGoal();
await goals.markComplete({ reason: 'private completion reason' }, 'model');
expect(telemetry.map((record) => record.event)).toEqual([
'goal_created',
'goal_budget_set',
'goal_continued',
'goal_status_changed',
'goal_status_changed',
'goal_status_changed',
'goal_cleared',
]);
expect(telemetry[0]?.properties).toEqual({ actor: 'user', replace: true });
expect(telemetry[1]?.properties).toMatchObject({ actor: 'model', has_token_budget: true });
expect(telemetry[3]?.properties).toMatchObject({ status: 'paused', actor: 'user' });
expect(JSON.stringify(telemetry)).not.toContain('private objective');
expect(JSON.stringify(telemetry)).not.toContain('private pause reason');
expect(JSON.stringify(telemetry)).not.toContain('private completion reason');
});
expect(goals.getGoal().goal?.budget.turnBudget).toBe(2);
});
it('projects restored goal status changes into replay records', async () => {
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'work',
completionCriterion: 'tests pass',
time: Date.parse('2026-01-01T00:00:00.000Z'),
},
{ type: 'goal.update', tokensUsed: 5 },
{ type: 'goal.update', turnsUsed: 1 },
{
type: 'goal.update',
status: 'paused',
reason: 'break',
actor: 'runtime',
},
{ type: 'goal.update', status: 'active', actor: 'user' },
{
type: 'goal.update',
status: 'complete',
reason: 'done',
actor: 'model',
},
]);
describe('AgentGoalService records', () => {
it('records only replay-relevant create/update/clear fields', async () => {
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
await goals.recordTokenUsage(5);
await goals.incrementTurn();
await goals.setBudgetLimits({ budgetLimits: { turnBudget: 2 } }, 'model');
await goals.markBlocked({ reason: 'stuck' });
await goals.cancelGoal();
await ctx.wireRecord.flush();
expect(replayBuilder.buildResult()).toEqual([
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ objective: 'work', status: 'active' }),
change: { kind: 'created' },
}),
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ status: 'paused', terminalReason: 'break' }),
change: { kind: 'lifecycle', status: 'paused', reason: 'break', actor: 'runtime' },
}),
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ status: 'active' }),
change: { kind: 'lifecycle', status: 'active', reason: undefined, actor: 'user' },
}),
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({
status: 'complete',
terminalReason: 'done',
turnsUsed: 1,
tokensUsed: 5,
const recordsWithoutMetadata = goalRecords(records);
expect(recordsWithoutMetadata).toEqual([
expect.objectContaining({
type: 'goal.create',
goalId: expect.any(String),
objective: 'work',
completionCriterion: 'tests pass',
}),
change: {
kind: 'completion',
expect.objectContaining({ type: 'goal.update', tokensUsed: 5 }),
expect.objectContaining({ type: 'goal.update', turnsUsed: 1 }),
expect.objectContaining({
type: 'goal.update',
budgetLimits: { turnBudget: 2 },
}),
expect.objectContaining({
type: 'goal.update',
status: 'blocked',
reason: 'stuck',
actor: 'runtime',
}),
expect.objectContaining({ type: 'goal.clear' }),
]);
expect(recordsWithoutMetadata[0]).not.toHaveProperty('actor');
expect(recordsWithoutMetadata[0]).not.toHaveProperty('budgetLimits');
expect(recordsWithoutMetadata[1]).not.toHaveProperty('goalId');
expect(recordsWithoutMetadata[1]).not.toHaveProperty('status');
expect(recordsWithoutMetadata.at(-1)).not.toHaveProperty('goalId');
expect(recordsWithoutMetadata.at(-1)).not.toHaveProperty('reason');
});
it('restores state from patch records', async () => {
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'work',
completionCriterion: 'tests pass',
time: Date.parse('2026-01-01T00:00:00.000Z'),
},
{ type: 'goal.update', tokensUsed: 5 },
{ type: 'goal.update', turnsUsed: 1 },
{ type: 'goal.update', budgetLimits: { turnBudget: 2 } },
{ type: 'goal.update', status: 'blocked', reason: 'stuck' },
]);
expect(goals.getGoal().goal).toMatchObject({
objective: 'work',
completionCriterion: 'tests pass',
status: 'blocked',
terminalReason: 'stuck',
tokensUsed: 5,
turnsUsed: 1,
});
expect(goals.getGoal().goal?.budget.turnBudget).toBe(2);
});
it('projects restored goal status changes into replay records', async () => {
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'work',
completionCriterion: 'tests pass',
time: Date.parse('2026-01-01T00:00:00.000Z'),
},
{ type: 'goal.update', tokensUsed: 5 },
{ type: 'goal.update', turnsUsed: 1 },
{
type: 'goal.update',
status: 'paused',
reason: 'break',
actor: 'runtime',
},
{ type: 'goal.update', status: 'active', actor: 'user' },
{
type: 'goal.update',
status: 'complete',
reason: 'done',
stats: { turnsUsed: 1, tokensUsed: 5, wallClockMs: 0 },
actor: 'model',
},
}),
]);
expect(replayBuilder.buildResult()).toEqual([
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ objective: 'work', status: 'active' }),
change: { kind: 'created' },
}),
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ status: 'paused', terminalReason: 'break' }),
change: { kind: 'lifecycle', status: 'paused', reason: 'break', actor: 'runtime' },
}),
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ status: 'active' }),
change: { kind: 'lifecycle', status: 'active', reason: undefined, actor: 'user' },
}),
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({
status: 'complete',
terminalReason: 'done',
turnsUsed: 1,
tokensUsed: 5,
}),
change: {
kind: 'completion',
status: 'complete',
reason: 'done',
stats: { turnsUsed: 1, tokensUsed: 5, wallClockMs: 0 },
actor: 'model',
},
}),
]);
});
it('keeps resume-normalization pauses in core replay records', async () => {
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'work',
time: Date.parse('2026-01-01T00:00:00.000Z'),
},
{
type: 'goal.update',
status: 'paused',
reason: 'Paused after agent resume',
},
]);
expect(replayBuilder.buildResult().at(-1)).toMatchObject({
type: 'goal_updated',
snapshot: { status: 'paused', terminalReason: 'Paused after agent resume' },
change: {
kind: 'lifecycle',
status: 'paused',
reason: 'Paused after agent resume',
actor: undefined,
},
});
});
it('normalizes active replayed goals to paused', async () => {
records.length = 0;
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'resume me',
},
]);
expect(goals.getGoal().goal).toMatchObject({
status: 'paused',
terminalReason: 'Paused after agent resume',
});
expect(goalRecords(records)).toEqual([
expect.objectContaining({
type: 'goal.update',
status: 'paused',
reason: 'Paused after agent resume',
}),
]);
});
});
});
describe('AgentGoalService core workflow hooks', () => {
let ctx: TestAgentContext | undefined;
let context: IAgentContextMemoryService;
let goals: IAgentGoalService;
let turnService: StubTurn;
let eventSink: IAgentEventSinkService;
beforeEach(() => {
turnService = stubTurn();
turnService.hooks.beforeStep.register('turn-before-step-event', (_ctx, next) => next());
ctx = createTestAgent(agentService(IAgentTurnService, turnService));
context = ctx.get(IAgentContextMemoryService);
goals = ctx.get(IAgentGoalService);
eventSink = ctx.get(IAgentEventSinkService);
});
afterEach(async () => {
await ctx?.dispose();
});
it('counts an active goal turn and launches the next continuation', async () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(1);
await turnService.hooks.onLaunched.run({ turn });
await runGoalStep(turnService, turn);
await endTurn(turnService, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'active',
turnsUsed: 1,
});
expect(turnService.launches).toEqual([
{ kind: 'system_trigger', name: 'goal_continuation' },
]);
expect(context.get().at(-1)?.origin).toEqual({
kind: 'system_trigger',
name: 'goal_continuation',
});
expect(JSON.stringify(context.get().at(-1)?.content)).toContain('Continue working toward');
});
it('continues after creating a goal mid-turn without counting the starter turn', async () => {
const turn = makeTurn(2);
await turnService.hooks.onLaunched.run({ turn });
await runGoalStep(turnService, turn);
await goals.createGoal({ objective: 'finish the task' }, 'model');
await endTurn(turnService, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'active',
turnsUsed: 0,
});
expect(turnService.launches).toEqual([
{ kind: 'system_trigger', name: 'goal_continuation' },
]);
});
it('keeps resume-normalization pauses in core replay records', async () => {
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'work',
time: Date.parse('2026-01-01T00:00:00.000Z'),
},
{
type: 'goal.update',
status: 'paused',
reason: 'Paused after agent resume',
},
]);
it('requests one final outcome turn after model completion', async () => {
await goals.createGoal({ objective: 'finish the task' });
expect(replayBuilder.buildResult().at(-1)).toMatchObject({
type: 'goal_updated',
snapshot: { status: 'paused', terminalReason: 'Paused after agent resume' },
change: {
kind: 'lifecycle',
status: 'paused',
reason: 'Paused after agent resume',
actor: undefined,
},
const turn = makeTurn(3);
await turnService.hooks.onLaunched.run({ turn });
const step = { turn, continueTurn: false };
await turnService.hooks.beforeStep.run(step);
await goals.markComplete({}, 'model');
await turnService.hooks.afterStep.run(step);
await endTurn(turnService, turn);
expect(step.continueTurn).toBe(true);
expect(goals.getGoal().goal).toBeNull();
expect(turnService.launches).toEqual([]);
expect(context.get().at(-1)?.origin).toEqual({
kind: 'system_trigger',
name: 'goal_completion_summary',
});
});
it('normalizes active replayed goals to paused', async () => {
records.length = 0;
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'resume me',
},
]);
it('pauses active goals after failed turns', async () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(4);
await turnService.hooks.onLaunched.run({ turn });
await endTurn(turnService, turn, { reason: 'failed', error: new Error('boom') });
expect(goals.getGoal().goal).toMatchObject({
status: 'paused',
terminalReason: 'Paused after agent resume',
terminalReason: 'Paused after runtime error: boom',
});
expect(goalRecords(records)).toEqual([
expect.objectContaining({
type: 'goal.update',
status: 'paused',
reason: 'Paused after agent resume',
}),
]);
expect(turnService.launches).toEqual([]);
});
it('blocks active goals when the user prompt hook blocks the turn', async () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(5);
await turnService.hooks.onLaunched.run({ turn });
eventSink.emit({
type: 'hook.result',
turnId: turn.id,
hookEvent: 'UserPromptSubmit',
content: 'blocked',
blocked: true,
});
await endTurn(turnService, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',
terminalReason: 'Blocked by UserPromptSubmit hook',
});
expect(turnService.launches).toEqual([]);
});
});
});

View file

@ -34,6 +34,12 @@ const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
[ErrorCodes.PROMPT_NOT_FOUND]: ErrorCode.PROMPT_NOT_FOUND,
[ErrorCodes.SESSION_BUSY]: ErrorCode.SESSION_BUSY,
[ErrorCodes.PROMPT_ALREADY_COMPLETED]: ErrorCode.PROMPT_ALREADY_COMPLETED,
[ErrorCodes.GOAL_ALREADY_EXISTS]: ErrorCode.GOAL_ALREADY_EXISTS,
[ErrorCodes.GOAL_NOT_FOUND]: ErrorCode.GOAL_NOT_FOUND,
[ErrorCodes.GOAL_STATUS_INVALID]: ErrorCode.GOAL_STATUS_INVALID,
[ErrorCodes.GOAL_NOT_RESUMABLE]: ErrorCode.GOAL_NOT_RESUMABLE,
[ErrorCodes.GOAL_OBJECTIVE_EMPTY]: ErrorCode.GOAL_OBJECTIVE_EMPTY,
[ErrorCodes.GOAL_OBJECTIVE_TOO_LONG]: ErrorCode.GOAL_OBJECTIVE_TOO_LONG,
};
/**

View file

@ -29,6 +29,22 @@ interface SessionMetaWire {
archived: boolean;
}
interface GoalSnapshotWire {
goalId: string;
objective: string;
completionCriterion?: string;
status: 'active' | 'paused' | 'blocked' | 'complete';
turnsUsed: number;
tokensUsed: number;
wallClockMs: number;
budget: unknown;
terminalReason?: string;
}
interface GoalToolResultWire {
goal: GoalSnapshotWire | null;
}
describe('server-v2 /api/v2 RPC', () => {
let server: RunningServer | undefined;
let home: string | undefined;
@ -224,6 +240,77 @@ describe('server-v2 /api/v2 RPC', () => {
expect(body.data.isError).not.toBe(true);
});
it('controls goals through goal:* RPC', async () => {
const id = await createSession(home as string);
await createMainAgent(id);
const created = await call<GoalSnapshotWire>(
'POST',
`/api/v2/session/${id}/agent/main/goal:create`,
{ objective: 'finish the migration' },
);
expect(created.body.code).toBe(0);
expect(created.body.data).toMatchObject({
objective: 'finish the migration',
status: 'active',
});
const read = await call<GoalToolResultWire>(
'GET',
`/api/v2/session/${id}/agent/main/goal:get`,
);
expect(read.body.code).toBe(0);
expect(read.body.data.goal).toMatchObject({
objective: 'finish the migration',
status: 'active',
});
const paused = await call<GoalSnapshotWire>(
'POST',
`/api/v2/session/${id}/agent/main/goal:pause`,
{},
);
expect(paused.body.data.status).toBe('paused');
const resumed = await call<GoalSnapshotWire>(
'POST',
`/api/v2/session/${id}/agent/main/goal:resume`,
{},
);
expect(resumed.body.data.status).toBe('active');
const cancelled = await call<GoalSnapshotWire>(
'POST',
`/api/v2/session/${id}/agent/main/goal:cancel`,
{},
);
expect(cancelled.body.code).toBe(0);
expect(cancelled.body.data.status).toBe('active');
const afterCancel = await call<GoalToolResultWire>(
'GET',
`/api/v2/session/${id}/agent/main/goal:get`,
);
expect(afterCancel.body.data.goal).toBeNull();
});
it('maps goal errors through RPC envelopes', async () => {
const id = await createSession(home as string);
await createMainAgent(id);
await call<GoalSnapshotWire>(
'POST',
`/api/v2/session/${id}/agent/main/goal:create`,
{ objective: 'first' },
);
const duplicate = await call<null>(
'POST',
`/api/v2/session/${id}/agent/main/goal:create`,
{ objective: 'second' },
);
expect(duplicate.body.code).toBe(40913);
});
it('lists and installs plugins through plugins:* RPC', async () => {
const pluginRoot = await mkdtemp(join(tmpdir(), 'server-v2-plugin-source-'));
try {