mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat: implement goal core workflow
This commit is contained in:
parent
2627c9067d
commit
186ed6eaa5
8 changed files with 1019 additions and 354 deletions
231
GOAL.md
Normal file
231
GOAL.md
Normal 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 仍是 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 的目标。
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue