diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 000000000..c0fdc2a36 --- /dev/null +++ b/GOAL.md @@ -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 仍是 active,runtime 会继续下一轮。模型不能只靠自然语言说“完成了”来结束 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 仍是 active,runtime 会追加一个系统触发输入,含义相当于“继续朝当前 active goal 工作”。它不只是简单续跑,还要求模型每轮重新判断: + +- 是否已经完成。 +- 是否遇到真实阻塞。 +- 是否应该只推进一个合理切片后继续下一轮。 +- 是否应该避免发散或启动无关工作。 +- 除非真实阻塞,否则不要向用户要输入。 + +### 完成、阻塞和暂停 + +模型通过结构化状态更新控制 goal 生命周期: + +- `complete`:目标已满足,runtime 发出完成事件并清除 goal。 +- `blocked`:遇到真实阻塞,runtime 保留 goal 并停止自治推进。 +- `paused`:暂时放下 goal,runtime 保留 goal 并停止自治推进。 +- `active`:恢复 paused 或 blocked goal。 + +状态更新工具的输入应保持窄,只表达机器状态。完成总结或阻塞原因由模型随后给用户说明。 + +当模型标记 complete 后,runtime 应再给模型一次收尾机会,生成简短最终回复,说明 goal 已完成、主要做了什么、跑了什么验证。 + +当模型标记 blocked 后,runtime 应再给模型一次收尾机会,说明具体阻塞、需要什么输入或变化才能继续。 + +如果当前 turn 已经没有 step 预算,不应为了收尾总结强行再跑一步,避免把“没法写总结”变成 turn 失败。 + +### 错误停车 + +goal mode 把技术运行失败视为可恢复停车: + +- 用户中断当前 turn:goal 变 paused。 +- provider rate limit:goal 变 paused。 +- provider 连接错误、认证错误、API 错误:goal 变 paused。 +- 模型配置错误:goal 变 paused。 +- runtime 异常:goal 变 paused。 +- provider safety filter:goal 变 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 变 paused;resume 把 paused 或 blocked goal 变 active;cancel 直接清除当前 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 保留 snapshot,UI 可以继续展示可恢复 goal。 + +session 恢复时,active goal 会变 paused,避免重启后自动继续。fork session 时不继承 goal,并提醒模型不要继续源 session 的目标。 diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index 2927ece5a..14916f4fa 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -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 diff --git a/packages/agent-core-v2/src/agent/goal/goal.ts b/packages/agent-core-v2/src/agent/goal/goal.ts index 7fe24a13e..ae9d7be04 100644 --- a/packages/agent-core-v2/src/agent/goal/goal.ts +++ b/packages/agent-core-v2/src/agent/goal/goal.ts @@ -17,7 +17,7 @@ export interface IAgentGoalService { createGoal(input: CreateGoalInput, actor?: GoalActor): Promise; pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; resumeGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; - cancelGoal(actor?: GoalActor): Promise; + cancelGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; setBudgetLimits( input: { readonly budgetLimits: GoalBudgetLimits }, actor?: GoalActor, diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 2f18aefc7..2180e2a12 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -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(); + private readonly countedGoalTurns = new Set(); + private readonly goalOutcomeContinuationTurns = new Set(); + private readonly promptHookBlockedTurns = new Set(); 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 { + async cancelGoal( + _input: GoalReasonInput = {}, + actor: GoalActor = 'user', + ): Promise { 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 { + 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 { + 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', ); diff --git a/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts b/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts index 96ceebbe7..28a5d9bae 100644 --- a/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts +++ b/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts @@ -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 { readonly description: string = DESCRIPTION; readonly parameters: Record = 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 { 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'); diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts index 8915e9f4f..60cb89115 100644 --- a/packages/agent-core-v2/test/goal/goal.test.ts +++ b/packages/agent-core-v2/test/goal/goal.test.ts @@ -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; @@ -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 { + 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 { + 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([]); }); }); -}); diff --git a/packages/server-v2/src/transport/errors.ts b/packages/server-v2/src/transport/errors.ts index dd53a847c..9d0331b97 100644 --- a/packages/server-v2/src/transport/errors.ts +++ b/packages/server-v2/src/transport/errors.ts @@ -34,6 +34,12 @@ const KIMI_TO_PROTOCOL: Record = { [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, }; /** diff --git a/packages/server-v2/test/rpc.test.ts b/packages/server-v2/test/rpc.test.ts index ab7c82baa..0dc87d0aa 100644 --- a/packages/server-v2/test/rpc.test.ts +++ b/packages/server-v2/test/rpc.test.ts @@ -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( + '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( + '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( + 'POST', + `/api/v2/session/${id}/agent/main/goal:pause`, + {}, + ); + expect(paused.body.data.status).toBe('paused'); + + const resumed = await call( + 'POST', + `/api/v2/session/${id}/agent/main/goal:resume`, + {}, + ); + expect(resumed.body.data.status).toBe('active'); + + const cancelled = await call( + '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( + '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( + 'POST', + `/api/v2/session/${id}/agent/main/goal:create`, + { objective: 'first' }, + ); + const duplicate = await call( + '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 {