Merge remote-tracking branch 'origin/kimi-code-v2' into kimi-code-v2

This commit is contained in:
haozhe.yang 2026-07-01 23:21:14 +08:00
commit 6633fcdd04
37 changed files with 1512 additions and 675 deletions

231
GOAL.md Normal file
View file

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

View file

@ -170,6 +170,7 @@ contextMemory --> wireRecord #34495E
contextMemory --> replayBuilder #34495E
contextInjector --> contextMemory #34495E
contextInjector --> turn #34495E
contextInjector --> loop #34495E
contextInjector --> systemReminder #34495E
contextSize --> contextMemory #34495E
contextSize --> record #34495E
@ -185,6 +186,7 @@ profile --> modelProvider #34495E
profile --> chatProvider #34495E
prompt --> contextMemory #34495E
prompt --> turn #34495E
prompt --> loop #34495E
prompt --> wireRecord #34495E
prompt --> record #34495E
turn --> loop #34495E
@ -217,7 +219,7 @@ llmRequestLog --> log #34495E
toolExecutor --> toolRegistry #34495E
toolStore --> wireRecord #34495E
toolDedup --> telemetry #34495E
toolDedup --> turn #34495E
toolDedup --> loop #34495E
toolDedup --> toolExecutor #34495E
permissionGate --> permissionMode #34495E
permissionGate --> permissionRules #34495E
@ -247,6 +249,11 @@ goal --> systemReminder #34495E
goal --> replayBuilder #34495E
goal --> telemetry #34495E
goal --> contextInjector #34495E
goal --> contextMemory #34495E
goal --> turn #34495E
goal --> loop #34495E
goal --> toolRegistry #34495E
goal --> permissionMode #34495E
skill --> prompt #34495E
skill --> record #34495E
skill --> wireRecord #34495E
@ -295,13 +302,14 @@ fullCompaction --> record #34495E
fullCompaction --> replayBuilder #34495E
fullCompaction --> externalHooks #34495E
fullCompaction --> turn #34495E
fullCompaction --> loop #34495E
microCompaction --> contextMemory #34495E
microCompaction --> contextSize #34495E
microCompaction --> wireRecord #34495E
microCompaction --> flag #34495E
microCompaction --> profile #34495E
microCompaction --> telemetry #34495E
microCompaction --> turn #34495E
microCompaction --> loop #34495E
externalHooks --> toolExecutor #34495E
externalHooks --> config #34495E
todoList --> contextMemory #34495E
@ -351,10 +359,13 @@ profile ..> wireRecord #16A085 : config.update / tools.set_active_tools
contextMemory ..> wireRecord #16A085 : context.splice
cron ..> wireRecord #16A085 : cron.add / delete / cursor
fullCompaction ..> wireRecord #16A085 : full_compaction.begin/cancel/complete
fullCompaction ..> turn #16A085 : hooks.onContextOverflow
fullCompaction ..> loop #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 ..> loop #16A085 : step/usage 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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Before After
Before After

View file

@ -110,7 +110,7 @@ const DOMAIN_LAYER = new Map([
['swarm', 4],
['scopeContext', 4],
['usage', 4],
['tooldedup', 4],
['toolDedupe', 4],
['contextMemory', 4],
['contextInjector', 4],
['systemReminder', 4],

View file

@ -9,7 +9,7 @@
* identity through `scopeContext`, creates child agents through
* `agent-lifecycle`, reads the parent check through `session-metadata`, gates
* background execution through the agent `profile`, and gathers git context
* through `execContext` (cwd) + `process` (runner).
* through `kaos` (cwd) + `process` (runner).
*/
import { Disposable } from '#/_base/di';
@ -19,7 +19,7 @@ import { IAgentBackgroundService } from '#/agent/background';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IExecContext } from '#/session/execContext';
import { IKaos } from '#/app/kaos';
import { ILogService } from '#/app/log';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { ISessionProcessRunner } from '#/session/process';
@ -40,7 +40,7 @@ export class AgentToolService extends Disposable implements IAgentToolService {
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
@IExecContext execCtx: IExecContext,
@IKaos kaos: IKaos,
@ISessionProcessRunner processRunner: ISessionProcessRunner,
@ILogService log?: ILogService,
) {
@ -53,7 +53,7 @@ export class AgentToolService extends Disposable implements IAgentToolService {
metadata,
background,
profile,
cwd: execCtx.cwd,
cwd: kaos.cwd,
processRunner,
log,
runOverride: runner,

View file

@ -6,6 +6,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentTurnService } from '#/agent/turn';
import type { ContextMessage } from '#/agent/contextMemory';
@ -32,11 +33,12 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentTurnService turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
) {
super();
this._register(
turnService.hooks.beforeStep.register('context-injector', async (_ctx, next) => {
loopService.hooks.beforeStep.register('context-injector', async (_ctx, next) => {
await next();
await this.inject();
}),

View file

@ -20,6 +20,7 @@ import { IAgentContextProjectorService } from '#/agent/contextProjector';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import { IAgentLLMRequesterService, type LLMEvent } from '#/agent/llmRequester';
import { IAgentLoopService } from '#/agent/loop';
import { isAbortError } from '#/agent/loop/errors';
import { retryBackoffDelays, sleepForRetry } from '#/agent/loop/retry';
import { IAgentProfileService } from '#/agent/profile';
@ -107,6 +108,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@IAgentReplayBuilderService private readonly replayBuilder: IAgentReplayBuilderService,
@IAgentExternalHooksService private readonly externalHooks: IAgentExternalHooksService,
@IAgentTurnService turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
) {
super();
this.strategy =
@ -119,19 +121,19 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
}),
);
this._register(
turnService.hooks.beforeStep.register('full-compaction', async (ctx, next) => {
loopService.hooks.beforeStep.register('full-compaction', async (ctx, next) => {
await this.beforeStep(ctx.turn.abortController.signal, ctx.turn.id);
await next();
}),
);
this._register(
turnService.hooks.afterStep.register('full-compaction', async (_ctx, next) => {
loopService.hooks.afterStep.register('full-compaction', async (_ctx, next) => {
await this.afterStep();
await next();
}),
);
this._register(
turnService.hooks.onContextOverflow.register('full-compaction', async (ctx, next) => {
loopService.hooks.onContextOverflow.register('full-compaction', async (ctx, next) => {
await this.onContextOverflow(ctx, next);
}),
);
@ -596,8 +598,8 @@ function isTodoItem(value: unknown): value is TodoItem {
export { AgentFullCompactionService as FullCompaction };
// Construct eagerly (not delayed): the service registers turn-lifecycle hooks
// (onLaunched / beforeStep / afterStep) in its constructor that drive auto
// Construct eagerly (not delayed): the service registers turn and loop hooks
// (onLaunched / beforeStep / afterStep / onContextOverflow) that drive auto
// compaction. With delayed instantiation the eager `accessor.get(IAgentFullCompactionService)`
// only realizes a proxy, so the hooks would not register until the first RPC —
// after turns have already run without the auto-compaction gate.

View file

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

View file

@ -1,16 +1,44 @@
import {
randomUUID } from 'node:crypto';
/**
* `goal` domain (L4) - `IAgentGoalService` implementation.
*
* Owns the per-agent goal lifecycle; persists records and broadcasts through
* `record`, injects reminders through `contextInjector`, drives continuation
* turns through `turn`, participates in steps through `loop`, 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 type { TokenUsage } from '@moonshot-ai/kosong';
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 { IAgentLoopService } from '#/agent/loop';
import { IAgentPermissionModeService } from '#/agent/permissionMode';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import {
IAgentTurnService,
type Turn,
type TurnEndedContext,
type TurnStepContext,
type TurnStepUsageContext,
} from '#/agent/turn';
import type { TelemetryProperties } from '#/app/telemetry';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -36,6 +64,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';
@ -74,6 +106,37 @@ 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 GOAL_BUDGET_BLOCK_PREFIX = 'Blocked after goal budget reached';
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;
@ -96,6 +159,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 = {},
@ -103,7 +170,10 @@ 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,
@IAgentLoopService loopService: IAgentLoopService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService,
) {
@ -151,11 +221,48 @@ 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(
loopService.hooks.beforeStep.register('goal-count-turn', async (ctx, next) => {
await this.handleBeforeStep(ctx);
await next();
}),
);
this._register(
loopService.hooks.onStepUsage.register('goal-record-step-usage', async (ctx, next) => {
this.handleStepUsage(ctx);
await next();
}),
);
this._register(
loopService.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(
this.record.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 {
@ -291,10 +398,13 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
actor,
...budgetTelemetryProperties(input.budgetLimits),
});
return this.toSnapshot(state);
return this.blockIfBudgetReached(state) ?? 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);
@ -319,7 +429,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(
@ -339,6 +456,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;
}
@ -348,12 +471,16 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
}
async recordTokenUsage(tokenDelta: number): Promise<GoalSnapshot | null> {
return this.accountTokenUsage(tokenDelta);
}
private accountTokenUsage(tokenDelta: number): GoalSnapshot | null {
const state = this.state;
if (state === undefined || state.status !== 'active') return null;
state.tokensUsed += Math.max(0, tokenDelta);
this.persistState(state, { silent: true });
this.appendGoalUpdate({ tokensUsed: state.tokensUsed });
return this.toSnapshot(state);
return this.blockIfBudgetReached(state) ?? this.toSnapshot(state);
}
async incrementTurn(): Promise<GoalSnapshot | null> {
@ -363,7 +490,76 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
this.persistState(state);
this.appendGoalUpdate({ turnsUsed: state.turnsUsed });
this.telemetry.track('goal_continued', { turns_used: state.turnsUsed });
return this.toSnapshot(state);
return this.blockIfBudgetReached(state) ?? 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 handleStepUsage(ctx: TurnStepUsageContext): void {
if (!this.goalDrivenTurns.has(ctx.turn.id)) return;
const snapshot = this.accountTokenUsage(tokenUsageTotal(ctx.usage));
if (snapshot?.budget.overBudget === true) {
ctx.stopTurn = true;
}
}
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.blockIfBudgetReached(this.state) !== null) 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 {
@ -547,6 +743,19 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
terminalReason: state.terminalReason,
};
}
private blockIfBudgetReached(state: GoalState): GoalSnapshot | null {
if (state.status !== 'active') return null;
const reason = goalBudgetBlockReason(this.toSnapshot(state).budget);
if (reason === undefined) return null;
this.applyStatus(state, 'blocked');
state.terminalReason = reason;
this.persistState(state, {
change: { kind: 'lifecycle', status: 'blocked', reason, actor: 'runtime' },
});
this.appendStatusUpdate(state, 'runtime', reason);
return this.toSnapshot(state);
}
}
function liveWallClockMs(state: GoalState, now: number = Date.now()): number {
@ -585,6 +794,20 @@ function computeBudgetReport(
};
}
function goalBudgetBlockReason(budget: GoalBudgetReport): string | undefined {
const reached: string[] = [];
if (budget.turnBudgetReached) {
reached.push(`turn budget ${budget.turnBudget ?? ''}`.trim());
}
if (budget.tokenBudgetReached) {
reached.push(`token budget ${budget.tokenBudget ?? ''}`.trim());
}
if (budget.wallClockBudgetReached) {
reached.push(`wall-clock budget ${budget.wallClockBudgetMs ?? ''}ms`.trim());
}
return reached.length === 0 ? undefined : `${GOAL_BUDGET_BLOCK_PREFIX}: ${reached.join(', ')}`;
}
function budgetTelemetryProperties(limits: GoalBudgetLimits): TelemetryProperties {
return {
has_token_budget: limits.tokenBudget !== undefined,
@ -593,15 +816,60 @@ function budgetTelemetryProperties(limits: GoalBudgetLimits): TelemetryPropertie
};
}
function tokenUsageTotal(usage: TokenUsage): number {
return usage.inputCacheRead + usage.inputCacheCreation + usage.inputOther + usage.output;
}
function normalizeCompletionCriterion(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed?.length ? trimmed : undefined;
}
function isGoalOutcomeReminder(message: ContextMessage | undefined): boolean {
if (message?.origin?.kind !== 'system_trigger') return false;
return (
message.origin.name === GOAL_COMPLETION_REMINDER_NAME ||
message.origin.name === GOAL_BLOCKED_REMINDER_NAME
);
}
function goalFailurePauseReason(error: unknown): string {
const payload = normalizeGoalErrorPayload(error);
switch (payload.code) {
case ErrorCodes.PROVIDER_RATE_LIMIT:
return GOAL_RATE_LIMIT_PAUSE_REASON;
case ErrorCodes.PROVIDER_CONNECTION_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, payload.message);
case ErrorCodes.PROVIDER_AUTH_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_AUTH_PAUSE_PREFIX, payload.message);
case ErrorCodes.PROVIDER_API_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_API_PAUSE_PREFIX, payload.message);
case ErrorCodes.MODEL_NOT_CONFIGURED:
return pauseReasonWithMessage(GOAL_MODEL_CONFIG_PAUSE_PREFIX, LLM_NOT_SET_MESSAGE);
case ErrorCodes.MODEL_CONFIG_INVALID:
return pauseReasonWithMessage(GOAL_MODEL_CONFIG_PAUSE_PREFIX, payload.message);
default:
return pauseReasonWithMessage(GOAL_RUNTIME_PAUSE_PREFIX, payload.message);
}
}
function normalizeGoalErrorPayload(error: unknown): KimiErrorPayload {
const payload = toKimiErrorPayload(error);
if (payload.code === ErrorCodes.MODEL_NOT_CONFIGURED) {
return { ...payload, message: LLM_NOT_SET_MESSAGE };
}
return payload;
}
function pauseReasonWithMessage(prefix: string, message: string | undefined): string {
const trimmed = message?.trim();
return trimmed === undefined || trimmed.length === 0 ? prefix : `${prefix}: ${trimmed}`;
}
registerScopedService(
LifecycleScope.Agent,
IAgentGoalService,
AgentGoalService,
InstantiationType.Delayed,
InstantiationType.Eager,
'goal',
);

View file

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

View file

@ -1,16 +1,22 @@
import { createDecorator } from "#/_base/di";
import type { HookSlot } from '#/hooks';
import type { Turn, TurnContextOverflowContext, TurnResult, TurnStepContext } from '#/agent/turn';
export interface LoopRunHooks {
readonly beforeStep: HookSlot<TurnStepContext>;
readonly afterStep: HookSlot<TurnStepContext>;
readonly onContextOverflow: HookSlot<TurnContextOverflowContext>;
}
import type { Hooks } from '#/hooks';
import type {
Turn,
TurnContextOverflowContext,
TurnResult,
TurnStepContext,
TurnStepUsageContext,
} from '#/agent/turn';
export interface IAgentLoopService {
readonly _serviceBrand: undefined;
runTurn(turn: Turn, hooks?: LoopRunHooks): Promise<TurnResult>;
readonly hooks: Hooks<{
beforeStep: TurnStepContext;
onStepUsage: TurnStepUsageContext;
afterStep: TurnStepContext;
onContextOverflow: TurnContextOverflowContext;
}>;
runTurn(turn: Turn): Promise<TurnResult>;
}
export const IAgentLoopService = createDecorator<IAgentLoopService>('agentLoopService');

View file

@ -43,6 +43,7 @@ import { IAgentContextSizeService } from '#/agent/contextSize';
import { IAgentRecordService } from '#/agent/record';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import { IAgentLLMRequesterService } from '#/agent/llmRequester';
import { OrderedHookSlot } from '#/hooks';
import { ILogService } from '#/app/log';
import { IAgentProfileService } from '#/agent/profile';
import { IConfigService } from '#/app/config';
@ -56,7 +57,7 @@ import type {
LoopRecordedEvent,
} from './events';
import type { LLM, LLMChatParams, LLMChatResponse } from './llm';
import { IAgentLoopService, type LoopRunHooks } from './loop';
import { IAgentLoopService } from './loop';
import {
LOOP_CONTROL_SECTION,
type LoopControl,
@ -78,6 +79,13 @@ type TelemetryProperties = Record<string, unknown>;
export class AgentLoopService extends Disposable implements IAgentLoopService {
declare readonly _serviceBrand: undefined;
readonly hooks: IAgentLoopService['hooks'] = {
beforeStep: new OrderedHookSlot(),
onStepUsage: new OrderedHookSlot(),
afterStep: new OrderedHookSlot(),
onContextOverflow: new OrderedHookSlot(),
};
private readonly openSteps = new Map<string, OpenStep>();
private readonly toolCallStartedAt = new Map<string, ToolCallTelemetryStart>();
private readonly toolCallDupType = new Map<string, 'normal' | 'cross_step'>();
@ -102,6 +110,11 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
@ILogService private readonly log: ILogService,
) {
super();
this._register(
this.hooks.beforeStep.register('turn-before-step-event', async (_ctx, next) => {
await next();
}),
);
this.context.hooks.onSpliced.register('loop-service-reconcile', async (_event, next) => {
if (this.ownSpliceDepth === 0) {
this.resetLiveStateFromHistory();
@ -117,11 +130,11 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
);
}
async runTurn(turn: Turn, hooks: LoopRunHooks | undefined): Promise<TurnResult> {
async runTurn(turn: Turn): Promise<TurnResult> {
const startedAt = Date.now();
this.protocolTurnId = turn.id;
const llm = this.createLLM(turn.id);
const loopHooks = this.loopHooks(turn, hooks);
const loopHooks = this.loopHooks(turn);
try {
// Preflight the model configuration before any step begins. Legacy reads
// `config.model` at the top of its step loop, so a missing model fails the
@ -141,17 +154,28 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
toolExecutor: this.toolExecutor,
maxSteps: this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.maxStepsPerTurn,
maxRetryAttempts: this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.maxRetriesPerStep,
recordStepUsage: (usage, context) => {
recordStepUsage: async (usage, context) => {
const tokens = tokenUsageTotal(usage);
if (tokens <= 0) return;
if (context.toolCallCount > 0) {
this.pendingMeasurements.set(context.stepUuid, {
tokens,
remainingToolCalls: context.toolCallCount,
});
} else {
this.contextSize.measured(this.measurementLength(context.stepUuid), tokens);
if (tokens > 0) {
if (context.toolCallCount > 0) {
this.pendingMeasurements.set(context.stepUuid, {
tokens,
remainingToolCalls: context.toolCallCount,
});
} else {
this.contextSize.measured(this.measurementLength(context.stepUuid), tokens);
}
}
const usageContext = {
turn,
usage,
stepNumber: context.stepNumber,
stepUuid: context.stepUuid,
toolCallCount: context.toolCallCount,
stopTurn: false,
};
await this.hooks.onStepUsage.run(usageContext);
return usageContext.stopTurn ? { stopTurn: true } : undefined;
},
});
if (result.stopReason === 'aborted') {
@ -164,7 +188,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
} catch (error) {
if (isContextOverflowError(error)) {
const context = { turn, error, handled: false };
await hooks?.onContextOverflow.run(context);
await this.hooks.onContextOverflow.run(context);
if (context.handled) continue;
}
throw error;
@ -558,17 +582,17 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
});
}
private loopHooks(turn: Turn, hooks: LoopRunHooks | undefined): LoopHooks {
private loopHooks(turn: Turn): LoopHooks {
let continueAfterStop = false;
let stopHookContinuationUsed = false;
return {
beforeStep: async () => {
await hooks?.beforeStep.run({ turn, continueTurn: false });
await this.hooks.beforeStep.run({ turn, continueTurn: false });
return undefined;
},
afterStep: async (context) => {
const turnContext = { turn, continueTurn: false };
await hooks?.afterStep.run(turnContext);
await this.hooks.afterStep.run(turnContext);
if (context.stopReason !== 'tool_use' && turnContext.continueTurn) {
continueAfterStop = true;
}

View file

@ -4,7 +4,7 @@
* Tracks cache-miss compaction cutoffs over `contextMemory`, sizes context via
* `contextSize`, resolves model capacity through `profile`, persists cutoffs
* through `wireRecord`, gates behavior through `flag`, emits telemetry, and
* participates in `turn` hooks. Bound at Agent scope.
* participates in `loop` hooks. Bound at Agent scope.
*/
import type { ContentPart } from '@moonshot-ai/kosong';
@ -23,9 +23,9 @@ import { IConfigService } from '#/app/config';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { IFlagService } from '#/app/flag';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentProfileService } from '#/agent/profile';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentTurnService } from '#/agent/turn';
import type { ContextMessage } from '#/agent/contextMemory';
import { IAgentRecordService } from '#/agent/record';
import {
@ -70,7 +70,7 @@ export class AgentMicroCompactionService
@IFlagService private readonly flags: IFlagService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentTurnService turn: IAgentTurnService,
@IAgentLoopService loop: IAgentLoopService,
@IConfigService private readonly config: IConfigService,
) {
super();
@ -83,7 +83,7 @@ export class AgentMicroCompactionService
}),
);
this._register(
turn.hooks.beforeStep.register(
loop.hooks.beforeStep.register(
'micro-compaction',
async (_ctx, next) => {
this.detect();

View file

@ -8,6 +8,7 @@ import {
USER_PROMPT_ORIGIN,
type ContextMessage,
} from '#/agent/contextMemory';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentRecordService } from '#/agent/record';
import { IAgentTurnService, type Turn } from '#/agent/turn';
import { IAgentPromptService } from './prompt';
@ -21,12 +22,13 @@ export class AgentPromptService implements IAgentPromptService {
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentLoopService loopService: IAgentLoopService,
) {
turnService.hooks.beforeStep.register('prompt-service-steer-before-step', async (_ctx, next) => {
loopService.hooks.beforeStep.register('prompt-service-steer-before-step', async (_ctx, next) => {
this.flushSteerQueue();
await next();
});
turnService.hooks.afterStep.register('prompt-service-steer', async (ctx, next) => {
loopService.hooks.afterStep.register('prompt-service-steer', async (ctx, next) => {
await next();
if (this.flushSteerQueue()) {
ctx.continueTurn = true;

View file

@ -14,6 +14,7 @@ import { userCancellationReason } from '#/_base/utils/abort';
import { IAgentPermissionGate } from '#/agent/permissionGate';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentPlanService } from '#/agent/plan';
import { IKaos } from '#/app/kaos';
import { expandCommandArguments, IPluginService } from '#/app/plugin';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentPromptService } from '#/agent/prompt';
@ -21,8 +22,6 @@ import { IAgentQuestionToolsService } from '#/agent/questionTools';
import { ISessionMetadata, type SessionMetaPatch } from '#/session/session-metadata';
import { BashTool, IAgentShellToolsService } from '#/agent/shellTools';
import { IAgentSkillService } from '#/agent/skill';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentToolService } from '#/agent/agentTool';
import { IAgentSwarmService } from '#/agent/swarm';
@ -86,8 +85,7 @@ export class AgentRPCService implements IAgentRPCService {
@IAgentFileToolsService private readonly fileTools: IAgentFileToolsService,
@IAgentShellToolsService private readonly shellTools: IAgentShellToolsService,
@ISessionProcessRunner private readonly processRunner: ISessionProcessRunner,
@IHostEnvironment private readonly env: IHostEnvironment,
@IExecContext private readonly ctx: IExecContext,
@IKaos private readonly kaos: IKaos,
@IAgentBackgroundService private readonly background: IAgentBackgroundService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@ -115,7 +113,7 @@ export class AgentRPCService implements IAgentRPCService {
private ensureBashTool() {
const existing = this.toolRegistry.resolve('Bash');
if (existing !== undefined) return existing;
const bash = new BashTool(this.processRunner, this.env, this.ctx, this.background);
const bash = new BashTool(this.processRunner, this.kaos, this.background);
this.toolRegistry.register(bash);
return bash;
}

View file

@ -6,3 +6,4 @@
export * from './swarm';
export * from './swarmService';
export * from './subagentBatch';

View file

@ -1,7 +1,7 @@
/**
* `toolDedup` domain (L4) `IAgentToolDedupeService` implementation.
*
* Self-wiring plugin: its constructor registers `turn` beforeStep/afterStep
* Self-wiring plugin: its constructor registers `loop` beforeStep/afterStep
* hooks and `toolExecutor` onWillExecuteTool/onDidExecuteTool hooks to drive
* same-step suppression and cross-step repeat reminders, and reports repeat
* telemetry through `telemetry`. Constructed eagerly at Agent scope so the
@ -13,8 +13,8 @@ import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { IAgentTurnService } from '#/agent/turn';
import type { ContentPart } from '@moonshot-ai/kosong';
import { IAgentToolDedupeService, type ToolDedupResult } from './toolDedupe';
@ -109,15 +109,15 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu
constructor(
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentTurnService turn: IAgentTurnService,
@IAgentLoopService loop: IAgentLoopService,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
) {
super();
turn.hooks.beforeStep.register('toolDedup', async (_ctx, next) => {
loop.hooks.beforeStep.register('toolDedup', async (_ctx, next) => {
this.beginStep();
await next();
});
turn.hooks.afterStep.register('toolDedup', async (_ctx, next) => {
loop.hooks.afterStep.register('toolDedup', async (_ctx, next) => {
this.endStep();
await next();
});

View file

@ -1,4 +1,5 @@
import { createDecorator } from "#/_base/di";
import type { TokenUsage } from '@moonshot-ai/kosong';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
import type { Hooks } from '#/hooks';
@ -21,6 +22,15 @@ export interface TurnStepContext {
continueTurn: boolean;
}
export interface TurnStepUsageContext {
readonly turn: Turn;
readonly usage: TokenUsage;
readonly stepNumber: number;
readonly stepUuid: string;
readonly toolCallCount: number;
stopTurn: boolean;
}
export interface TurnContextOverflowContext {
readonly turn: Turn;
readonly error: unknown;
@ -53,9 +63,6 @@ export interface IAgentTurnService {
readonly hooks: Hooks<{
onLaunched: { turn: Turn };
onEnded: TurnEndedContext;
beforeStep: TurnStepContext;
afterStep: TurnStepContext;
onContextOverflow: TurnContextOverflowContext;
}>;
}

View file

@ -11,10 +11,8 @@ import { ITelemetryService } from '#/app/telemetry';
import { IAgentRecordService } from '#/agent/record';
import type {
Turn,
TurnContextOverflowContext,
TurnEndedContext,
TurnResult,
TurnStepContext,
} from './turn';
import { IAgentTurnService } from './turn';
@ -42,9 +40,6 @@ export class AgentTurnService implements IAgentTurnService {
readonly hooks = {
onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
onEnded: new OrderedHookSlot<TurnEndedContext>(),
beforeStep: new OrderedHookSlot<TurnStepContext>(),
afterStep: new OrderedHookSlot<TurnStepContext>(),
onContextOverflow: new OrderedHookSlot<TurnContextOverflowContext>(),
};
constructor(
@ -59,10 +54,14 @@ export class AgentTurnService implements IAgentTurnService {
this.restoreLaunch(r.turnId);
},
});
this.hooks.beforeStep.register('turn-before-step-event', async (ctx, next) => {
await next();
this.resolveReady(ctx.turn);
});
this.loop.hooks.beforeStep.register(
'turn-ready-before-step',
async (ctx, next) => {
await next();
this.resolveReady(ctx.turn);
},
{ before: 'turn-before-step-event' },
);
this.record.on((event) => {
if (event.type === 'agent.status.updated' && event.planMode !== undefined) {
this.planModeActive = event.planMode;
@ -131,11 +130,7 @@ export class AgentTurnService implements IAgentTurnService {
result = promptHookResult;
return result;
}
result = await this.loop.runTurn(turn, {
beforeStep: this.hooks.beforeStep,
afterStep: this.hooks.afterStep,
onContextOverflow: this.hooks.onContextOverflow,
});
result = await this.loop.runTurn(turn);
return result;
} catch (error) {
if (turn.abortController.signal.aborted) {

View file

@ -36,7 +36,7 @@ export * from '#/agent/plan';
export * from '#/agent/goal';
export * from '#/agent/swarm';
export * from '#/agent/usage';
export * from '#/agent/tooldedup';
export * from '#/agent/toolDedupe';
export * from '#/agent/background';
import '#/agent/cron';
@ -58,8 +58,7 @@ export * from '#/app/gateway';
export * from '#/session/workspaceContext';
export * from '#/app/workspaceRegistry';
export * from '#/app/hostFolderBrowser';
export * from '#/app/hostEnvironment';
export * from '#/session/execContext';
export * from '#/app/kaos';
export * from '#/session/agentFs';
export * from '#/session/process';
export * from '#/session/terminal';

View file

@ -5,12 +5,12 @@ import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IKaos } from '#/app/kaos';
import { ILogService } from '#/app/log';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { AgentToolService, IAgentToolService } from '#/agent/agentTool';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IExecContext, createExecContext } from '#/session/execContext';
import { ISessionMetadata } from '#/session/session-metadata';
import { ISessionProcessRunner } from '#/session/process';
@ -35,7 +35,7 @@ describe('AgentToolService DI wiring', () => {
ix.stub(IAgentToolRegistryService, { register });
ix.stub(IAgentBackgroundService, {});
ix.stub(IAgentProfileService, { isToolActive: vi.fn().mockReturnValue(false) });
ix.stub(IExecContext, createExecContext('/repo'));
ix.stub(IKaos, { cwd: '/repo' });
ix.stub(ISessionProcessRunner, { exec: vi.fn() });
ix.stub(ILogService, { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() });
ix.set(IAgentToolService, new SyncDescriptor(AgentToolService, [undefined]));

View file

@ -23,6 +23,7 @@ import type { HookEngine } from '#/agent/externalHooks/engine';
import { IAgentPromptService } from '#/agent/prompt';
import type { SubagentDetachHandle } from '#/agent/background';
import type { SubagentHandle } from '#/agent/agentTool';
import { ISessionMetadata } from '#/session/session-metadata';
import {
configServices,
createTestAgent,
@ -238,6 +239,17 @@ function createBackgroundManager(options: {
};
}
async function cleanupSessionDir(
sessionDir: string,
fixture?: BackgroundServiceFixture,
): Promise<void> {
if (fixture !== undefined) {
await fixture.ctx.get(ISessionMetadata).ready;
await fixture.ctx.dispose();
}
await rm(sessionDir, { recursive: true, force: true });
}
function firstAppendedContextMessage(agent: FakeBackgroundAgent): TestContextMessage {
const call = agent.context.appendUserMessage.mock.calls[0] as unknown as [
number,
@ -367,6 +379,7 @@ describe('BackgroundManager — event emission', () => {
it('emits background.task.terminated when a restored task is marked lost', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-reconcile-'));
let fixture: BackgroundServiceFixture | undefined;
try {
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(
@ -379,7 +392,8 @@ describe('BackgroundManager — event emission', () => {
status: 'running',
}),
);
const { agent, manager } = createBackgroundManager({ sessionDir });
fixture = createBackgroundManager({ sessionDir });
const { agent, manager } = fixture;
await manager.loadFromDisk();
await manager.reconcile();
@ -392,7 +406,7 @@ describe('BackgroundManager — event emission', () => {
}),
});
} finally {
await rm(sessionDir, { recursive: true, force: true });
await cleanupSessionDir(sessionDir, fixture);
}
});
});
@ -477,11 +491,13 @@ describe('BackgroundManager — notification delivery', () => {
it('replays restored terminal agent task notifications when undelivered', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-'));
let fixture: BackgroundServiceFixture | undefined;
try {
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedAgent());
await persistence.appendTaskOutput('agent-done0000', 'restored subagent summary');
const { agent, manager } = createBackgroundManager({ sessionDir });
fixture = createBackgroundManager({ sessionDir });
const { agent, manager } = fixture;
await manager.loadFromDisk();
await manager.reconcile();
@ -503,17 +519,19 @@ describe('BackgroundManager — notification delivery', () => {
expect(text).toContain('<output-file');
expect(text).toContain(persistence.taskOutputFile('agent-done0000'));
} finally {
await rm(sessionDir, { recursive: true, force: true });
await cleanupSessionDir(sessionDir, fixture);
}
});
it('replays restored terminal process task notifications when undelivered', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-replay-'));
let fixture: BackgroundServiceFixture | undefined;
try {
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedProcess());
await persistence.appendTaskOutput('bash-done0000', 'restored shell output');
const { agent, manager } = createBackgroundManager({ sessionDir });
fixture = createBackgroundManager({ sessionDir });
const { agent, manager } = fixture;
await manager.loadFromDisk();
await manager.reconcile();
@ -535,19 +553,21 @@ describe('BackgroundManager — notification delivery', () => {
expect(text).toContain('<output-file');
expect(text).toContain(persistence.taskOutputFile('bash-done0000'));
} finally {
await rm(sessionDir, { recursive: true, force: true });
await cleanupSessionDir(sessionDir, fixture);
}
});
it('references persisted output without reading a tail for restored process notifications', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-tail-'));
let fixture: BackgroundServiceFixture | undefined;
try {
const taskId = 'bash-large000';
const largeOutput = `early-output-marker\n${'x'.repeat(8_000)}\nfinal output line`;
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedProcess({ taskId }));
await persistence.appendTaskOutput(taskId, largeOutput);
const { agent, manager } = createBackgroundManager({ sessionDir });
fixture = createBackgroundManager({ sessionDir });
const { agent, manager } = fixture;
await manager.loadFromDisk();
await manager.reconcile();
@ -562,12 +582,13 @@ describe('BackgroundManager — notification delivery', () => {
expect(text).not.toContain('final output line');
expect(text).not.toContain('early-output-marker');
} finally {
await rm(sessionDir, { recursive: true, force: true });
await cleanupSessionDir(sessionDir, fixture);
}
});
it('does not replay restored notifications already marked delivered', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-'));
let fixture: BackgroundServiceFixture | undefined;
try {
const origin = {
kind: 'background_task',
@ -578,7 +599,8 @@ describe('BackgroundManager — notification delivery', () => {
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedAgent({ taskId: 'agent-seen0000' }));
await persistence.appendTaskOutput('agent-seen0000', 'already delivered summary');
const { agent, ctx, manager } = createBackgroundManager({ sessionDir });
fixture = createBackgroundManager({ sessionDir });
const { agent, ctx, manager } = fixture;
const context = ctx.get(IAgentContextMemoryService);
context.splice(context.get().length, 0, [
{
@ -597,12 +619,13 @@ describe('BackgroundManager — notification delivery', () => {
expect(agent.turn.steer).not.toHaveBeenCalled();
expect(agent.context.appendUserMessage).not.toHaveBeenCalled();
} finally {
await rm(sessionDir, { recursive: true, force: true });
await cleanupSessionDir(sessionDir, fixture);
}
});
it('does not double-notify newly lost restored agent tasks', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-lost-'));
let fixture: BackgroundServiceFixture | undefined;
try {
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(
@ -613,7 +636,8 @@ describe('BackgroundManager — notification delivery', () => {
status: 'running',
}),
);
const { agent, manager } = createBackgroundManager({ sessionDir });
fixture = createBackgroundManager({ sessionDir });
const { agent, manager } = fixture;
await manager.loadFromDisk();
await manager.reconcile();
@ -634,7 +658,7 @@ describe('BackgroundManager — notification delivery', () => {
'Background agent lost',
);
} finally {
await rm(sessionDir, { recursive: true, force: true });
await cleanupSessionDir(sessionDir, fixture);
}
});

View file

@ -8,6 +8,7 @@ import {
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
@ -17,7 +18,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentToolStoreService } from '#/agent/toolStore';
import { IAgentTurnService } from '#/agent/turn';
import { registerContextMemoryServices } from '../contextMemory/stubs';
import { stubTurnWithHooks } from '../turn/stubs';
import { stubLoopWithHooks, stubTurnWithHooks } from '../turn/stubs';
type InjectableContextInjector = IAgentContextInjectorService & {
inject(): Promise<void>;
@ -66,6 +67,7 @@ describe('AgentContextInjectorService', () => {
base: [registerContextMemoryServices],
strict: true,
additionalServices: (reg) => {
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
reg.define(IAgentContextInjectorService, AgentContextInjectorService);
@ -218,6 +220,7 @@ describe('AgentContextInjectorService registration', () => {
base: [registerContextMemoryServices],
strict: true,
additionalServices: (reg) => {
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
reg.define(IAgentContextInjectorService, AgentContextInjectorService);

View file

@ -869,35 +869,43 @@ describe('FullCompaction', () => {
it('names truncated compaction responses when retries are exhausted', async () => {
vi.useFakeTimers();
const firstAttemptFinished = deferred<void>();
let attempts = 0;
const generate: GenerateFn = async () => {
attempts += 1;
if (attempts === 1) {
firstAttemptFinished.resolve();
}
return {
...textResult('Partial summary.'),
finishReason: 'truncated',
rawFinishReason: 'length',
};
};
const ctx = testAgent({ generate, fullCompaction: { compactionStrategy: alwaysCompactOnce } });
ctx.configure();
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger truncated auto compaction' }] });
await ctx.rpc.beginCompaction({});
await firstAttemptFinished.promise;
await vi.advanceTimersByTimeAsync(60_000);
const events = await ctx.untilTurnEnd();
await failed;
expect(attempts).toBe(5);
expect(events).toContainEqual(
expect(ctx.newEvents()).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: {
turnId: 0,
reason: 'failed',
error: expect.objectContaining({
code: 'compaction.failed',
message:
'CompactionTruncatedError: Compaction response was truncated before producing a complete summary.',
}),
},
event: 'error',
args: expect.objectContaining({
code: 'compaction.failed',
message:
'CompactionTruncatedError: Compaction response was truncated before producing a complete summary.',
name: 'KimiError',
}),
}),
);
await ctx.expectResumeMatches();
@ -1905,12 +1913,13 @@ describe('FullCompaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
// Set maxOutputSize on the harness's internal kimiConfig — the
// compaction path reads it via resolveModelContext().maxOutputSize.
// Set maxOutputSize on the harness's internal kimiConfig. Keep it below
// the Kimi model context window so provider-side context clipping does not
// hide whether compaction passed this configured value through.
const models = (ctx as unknown as MutableKimiConfig).kimiConfig.models;
models![CATALOGUED_PROVIDER.model] = {
...models![CATALOGUED_PROVIDER.model]!,
maxOutputSize: 384000,
maxOutputSize: 64_000,
};
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
@ -1919,7 +1928,7 @@ describe('FullCompaction', () => {
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(compactionMaxCompletionTokens).toEqual([384000]);
expect(compactionMaxCompletionTokens).toEqual([64_000]);
});
it('uses default 128k hardCap when maxOutputSize is not configured', async () => {

View file

@ -1,19 +1,24 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { TokenUsage } from '@moonshot-ai/kosong';
import { ErrorCodes } from '#/errors';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentGoalService, type AgentGoalService } from '#/agent/goal';
import { IAgentLoopService } from '#/agent/loop';
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 { stubLoopWithHooks, stubTurn, type StubTurn } from '../turn/stubs';
type GoalServiceTestManager = IAgentGoalService & AgentGoalService;
type GoalRecord = Extract<PersistedWireRecord, { type: `goal.${string}` }>;
@ -35,6 +40,47 @@ 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(loopService: IAgentLoopService, turn: Turn): Promise<boolean> {
const step = { turn, continueTurn: false };
await loopService.hooks.beforeStep.run(step);
await loopService.hooks.afterStep.run(step);
return step.continueTurn;
}
async function recordStepUsage(
loopService: IAgentLoopService,
turn: Turn,
usage: TokenUsage,
): Promise<boolean> {
const usageContext = {
turn,
usage,
stepNumber: 1,
stepUuid: 'step-1',
toolCallCount: 0,
stopTurn: false,
};
await loopService.hooks.onStepUsage.run(usageContext);
return usageContext.stopTurn;
}
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 +116,573 @@ 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',
]);
});
});
await goals.pauseGoal();
await goals.recordTokenUsage(12);
await goals.incrementTurn();
expect(goals.getGoal().goal).toMatchObject({ tokensUsed: 30, turnsUsed: 1 });
});
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();
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');
await goals.pauseGoal();
expect(events.at(-1)?.change).toMatchObject({ kind: 'lifecycle', status: 'paused' });
expect(snapshot.budget.tokenBudget).toBe(100);
expect(snapshot.budget.turnBudget).toBe(2);
expect(snapshot.budget.wallClockBudgetMs).toBe(1000);
});
await goals.resumeGoal();
expect(events.at(-1)?.change).toMatchObject({ kind: 'lifecycle', status: 'active' });
it('blocks when a token budget is reached', async () => {
await goals.createGoal({ objective: 'work' });
await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 10 } }, 'model');
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();
});
const snapshot = await goals.recordTokenUsage(10);
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',
expect(snapshot).toMatchObject({
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,
tokensUsed: 10,
terminalReason: 'Blocked after goal budget reached: token budget 10',
});
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',
budget: {
tokenBudgetReached: true,
overBudget: true,
},
});
});
it('blocks when a newly set budget is already exhausted', async () => {
await goals.createGoal({ objective: 'work' });
await goals.incrementTurn();
const snapshot = await goals.setBudgetLimits(
{ budgetLimits: { turnBudget: 1 } },
'model',
);
expect(snapshot).toMatchObject({
status: 'blocked',
terminalReason: 'Blocked after goal budget reached: turn budget 1',
});
});
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',
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',
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',
},
]);
status: 'blocked',
terminalReason: 'stuck',
tokensUsed: 5,
turnsUsed: 1,
});
expect(goals.getGoal().goal?.budget.turnBudget).toBe(2);
});
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',
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 loopService: IAgentLoopService;
let eventSink: IAgentEventSinkService;
beforeEach(() => {
turnService = stubTurn({ hasActiveTurn: true });
loopService = stubLoopWithHooks();
loopService.hooks.beforeStep.register('turn-before-step-event', (_ctx, next) => next());
ctx = createTestAgent(
agentService(IAgentTurnService, turnService),
agentService(IAgentLoopService, loopService),
);
context = ctx.get(IAgentContextMemoryService);
goals = ctx.get(IAgentGoalService);
eventSink = ctx.get(IAgentEventSinkService);
});
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',
},
]);
afterEach(async () => {
await ctx?.dispose();
});
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('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(loopService, 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('blocks at the turn budget instead of launching a continuation', async () => {
await goals.createGoal({ objective: 'finish the task' });
await goals.setBudgetLimits({ budgetLimits: { turnBudget: 1 } }, 'model');
const turn = makeTurn(11);
await turnService.hooks.onLaunched.run({ turn });
await runGoalStep(loopService, turn);
await endTurn(turnService, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',
turnsUsed: 1,
terminalReason: 'Blocked after goal budget reached: turn budget 1',
});
expect(turnService.launches).toEqual([]);
});
it('accounts step usage through the loop usage hook for active goal turns', async () => {
await goals.createGoal({ objective: 'finish the task' });
await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 7 } }, 'model');
const turn = turnService.launch({ kind: 'user' });
await turnService.hooks.onLaunched.run({ turn });
expect(await recordStepUsage(loopService, turn, {
inputCacheRead: 0,
inputCacheCreation: 0,
inputOther: 4,
output: 0,
})).toBe(false);
expect(await recordStepUsage(loopService, turn, {
inputCacheRead: 0,
inputCacheCreation: 0,
inputOther: 0,
output: 3,
})).toBe(true);
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',
tokensUsed: 7,
terminalReason: 'Blocked after goal budget reached: token budget 7',
});
});
it('normalizes active replayed goals to paused', async () => {
records.length = 0;
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
objective: 'resume me',
},
it('ignores step usage for non-goal turns', async () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(99);
expect(await recordStepUsage(loopService, turn, {
inputCacheRead: 0,
inputCacheCreation: 0,
inputOther: 10,
output: 5,
})).toBe(false);
expect(goals.getGoal().goal).toMatchObject({
status: 'active',
tokensUsed: 0,
});
});
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(loopService, 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('requests one final outcome turn after model completion', async () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(3);
await turnService.hooks.onLaunched.run({ turn });
const step = { turn, continueTurn: false };
await loopService.hooks.beforeStep.run(step);
await goals.markComplete({}, 'model');
await loopService.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('pauses active goals after failed turns', async () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(4);
await turnService.hooks.onLaunched.run({ turn });
await endTurn(turnService, turn, { reason: 'failed', error: new Error('boom') });
expect(goals.getGoal().goal).toMatchObject({
status: 'paused',
terminalReason: 'Paused after agent resume',
terminalReason: 'Paused after runtime error: boom',
});
expect(goalRecords(records)).toEqual([
expect.objectContaining({
type: 'goal.update',
status: 'paused',
reason: 'Paused after agent resume',
}),
]);
expect(turnService.launches).toEqual([]);
});
it('blocks active goals when the user prompt hook blocks the turn', async () => {
await goals.createGoal({ objective: 'finish the task' });
const turn = makeTurn(5);
await turnService.hooks.onLaunched.run({ turn });
eventSink.emit({
type: 'hook.result',
turnId: turn.id,
hookEvent: 'UserPromptSubmit',
content: 'blocked',
blocked: true,
});
await endTurn(turnService, turn);
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',
terminalReason: 'Blocked by UserPromptSubmit hook',
});
expect(turnService.launches).toEqual([]);
});
});
});

View file

@ -174,15 +174,16 @@ describe('GoalInjection content', () => {
expect(text).toContain('avoid starting new discretionary work');
});
it('has no separate over-budget guidance (the runtime auto-blocks instead)', async () => {
it('shows a blocked note once a budget is reached', async () => {
const text = (await readGoalReminder(async (goals) => {
await goals.createGoal({ objective: 'work' });
await goals.setBudgetLimits({ budgetLimits: { turnBudget: 2 } }, 'model');
await goals.incrementTurn();
await goals.incrementTurn(); // 2/2 = 100%
}))!;
expect(text).not.toContain('report the best terminal state');
expect(text).toContain('nearing a budget');
expect(text).toContain('currently blocked');
expect(text).toContain('Blocked after goal budget reached: turn budget 2');
expect(text).not.toContain('Budget guidance');
});
it('tells the model to call UpdateGoal to finish', async () => {

View file

@ -3,6 +3,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
import { Readable, type Writable } from 'node:stream';
import { createControlledPromise } from '@antfu/utils';
import type { Kaos } from '@moonshot-ai/kaos';
import {
isToolCall,
isToolCallPart,
@ -32,6 +33,7 @@ import {
IAppendLogStore,
IAppendLogStorage,
ISessionApprovalService,
ISessionMetadata,
IAtomicDocumentStorage,
IAgentBackgroundService,
IBlobStorage,
@ -45,7 +47,7 @@ import {
IAgentExternalHooksService,
IAgentFileToolsService,
IAgentFullCompactionService,
IHostEnvironment,
IKaos,
IAgentLLMRequesterService,
ILogService,
IAgentMcpService,
@ -91,7 +93,6 @@ import {
type ServiceIdentifier,
type AgentToolRunOverride,
} from '#/index';
import { IExecContext, createExecContext, execContextSeed } from '#/session/execContext';
import type { IProcess } from '#/session/process';
import type { AgentSwarmToolHost } from '#/agent/swarm/tools/agent-swarm';
import { Event } from '#/_base/event';
@ -149,7 +150,8 @@ import type {
} from '#/agent/wireRecord';
import { IAgentRecordService } from '#/agent/record';
import type { PathAccessOperation } from '#/session/workspaceContext';
import { createFakeAgentFs, createFakeHostEnvironment, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createScriptedGenerate } from './scripted-generate';
import {
@ -160,8 +162,7 @@ import {
} from './snapshots';
import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events';
const TEST_HOST_ENVIRONMENT: IHostEnvironment = createFakeHostEnvironment();
const TEST_HOME_DIR: string = TEST_HOST_ENVIRONMENT.homeDir;
const TEST_HOME_DIR = '/home/test';
const MOCK_PROVIDER = {
type: 'kimi',
@ -401,69 +402,80 @@ function defineServiceValue<T>(
}
}
type KaosOverride =
| IKaos
| Kaos
| { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] };
/**
* Session-scope override for the execution-environment atoms
* (`IHostEnvironment` / `IExecContext` / `ISessionAgentFileSystem` /
* `ISessionProcessRunner`). Replaces the v1 `kaosServices(kaos)` helper
* tests now pass just the atoms they care about.
* Session-scope override for the execution environment and derived atoms.
*/
export interface ExecEnvOverride {
readonly hostEnvironment?: IHostEnvironment | Partial<IHostEnvironment>;
readonly execContext?: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] };
readonly kaos?: KaosOverride;
readonly execContext?: { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] };
readonly agentFs?: ISessionAgentFileSystem | Partial<ISessionAgentFileSystem>;
readonly processRunner?: ISessionProcessRunner | Partial<ISessionProcessRunner>;
}
/**
* Register a fake execution-environment atom set for a test session. Any
* unspecified atom keeps the harness default (fake host env, `/workspace`
* exec ctx, throwing fs/runner).
* Register a fake execution-environment set for a test session. Any
* unspecified atom keeps the harness default fake `IKaos` and the real
* services backed by it.
*/
export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServiceOverride {
return [
override.hostEnvironment !== undefined
? appService(IHostEnvironment, resolveHostEnvironmentOverride(override.hostEnvironment))
: appServices(() => {}),
sessionServices((reg) => {
if (override.execContext !== undefined) {
const ctx = resolveExecContextOverride(override.execContext);
for (const [id, value] of execContextSeed(ctx)) {
reg.defineInstance(id as ServiceIdentifier<unknown>, value);
}
}
if (override.agentFs !== undefined) {
reg.defineInstance(ISessionAgentFileSystem, resolveAgentFsOverride(override.agentFs));
}
if (override.processRunner !== undefined) {
reg.defineInstance(ISessionProcessRunner, resolveProcessRunnerOverride(override.processRunner));
}
reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService));
}),
];
return sessionServices((reg) => {
const kaosOverride = override.kaos ?? override.execContext;
if (kaosOverride !== undefined) {
reg.defineInstance(IKaos, resolveKaosOverride(kaosOverride));
}
if (override.agentFs !== undefined) {
reg.defineInstance(ISessionAgentFileSystem, resolveAgentFsOverride(override.agentFs));
}
if (override.processRunner !== undefined) {
reg.defineInstance(ISessionProcessRunner, resolveProcessRunnerOverride(override.processRunner));
}
reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService));
});
}
function resolveHostEnvironmentOverride(
input: IHostEnvironment | Partial<IHostEnvironment>,
): IHostEnvironment {
// A full `IHostEnvironment` is an object literal (constructed via
// `createFakeHostEnvironment` or the real service). Anything that is not a
// plain-object literal (no prototype tricks) is treated as a full instance
// and passed through. Plain object literals with partial fields fall
// through to the fake factory. Since both `createFakeHostEnvironment` and
// real services produce plain-shape objects, we discriminate on the
// presence of `osKind` — a full env always has it.
if (typeof (input as IHostEnvironment).osKind === 'string') {
return input as IHostEnvironment;
}
return createFakeHostEnvironment(input as Partial<IHostEnvironment>);
export function kaosServices(input: IKaos | Kaos): TestAgentServiceOverride {
return sessionService(IKaos, resolveKaosOverride(input));
}
function resolveExecContextOverride(
input: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] },
): IExecContext {
if ('withCwd' in input && 'withEnv' in input) return input as IExecContext;
const partial = input as { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] };
return createExecContext(partial.cwd ?? '/workspace', partial.envLayers ?? []);
function resolveKaosOverride(input: KaosOverride): IKaos {
if (isIKaos(input)) return input;
if (isKaosBackend(input)) return wrapKaos(input);
return wrapKaos(createFakeKaos(undefined, input.envLayers).withCwd(input.cwd ?? '/workspace'));
}
function isIKaos(input: KaosOverride): input is IKaos {
return typeof (input as IKaos).backend === 'object' && typeof (input as IKaos).getcwd === 'function';
}
function isKaosBackend(input: KaosOverride): input is Kaos {
return typeof (input as Kaos).execWithEnv === 'function' && typeof (input as Kaos).getcwd === 'function';
}
function wrapKaos(backend: Kaos): IKaos {
return {
_serviceBrand: undefined,
get name() {
return backend.name;
},
get cwd() {
return backend.getcwd();
},
get osEnv() {
return backend.osEnv;
},
backend,
pathClass: () => backend.pathClass(),
normpath: (path) => backend.normpath(path),
gethome: () => backend.gethome(),
getcwd: () => backend.getcwd(),
withCwd: (cwd) => wrapKaos(backend.withCwd(cwd)),
withEnv: (env) => wrapKaos(backend.withEnv(env)),
};
}
function resolveAgentFsOverride(
@ -690,6 +702,13 @@ export function createCommandRunner(stdout: string, exitCode = 0): ISessionProce
});
}
export function createCommandKaos(stdout: string, exitCode = 0): Kaos {
const runner = createCommandRunner(stdout, exitCode);
return createFakeKaos({
execWithEnv: async (args, env) => runner.exec(args, { env }) as unknown as ReturnType<Kaos['execWithEnv']>,
});
}
export function testAgent(...inputs: readonly TestAgentInput[]): AgentTestContext {
return createTestAgent(...inputs);
}
@ -967,12 +986,6 @@ export class AgentTestContext {
})) {
reg.defineInstance(id, value);
}
// Fake `IHostEnvironment` — a real `HostEnvironmentService` would kick
// off an async probe (spawn `sh --version` on Windows, `os.homedir()`,
// etc.) at App-scope construction; the harness stubs it with a
// deterministic Linux/bash snapshot instead. Tests can override with
// `execEnvServices({ hostEnvironment: … })`.
reg.defineInstance(IHostEnvironment, TEST_HOST_ENVIRONMENT);
// In-memory Storage-layer backend. The `InMemoryStorageService` is no
// longer auto-registered, so the harness seeds it here to keep a
// workable default for storage-backed services. Tests that need durable
@ -1023,14 +1036,10 @@ export class AgentTestContext {
reg.defineInstance(ISessionInteractionService, this.createInteractionService());
reg.defineInstance(ISessionApprovalService, this.createApprovalService());
reg.defineInstance(ISessionQuestionService, this.createQuestionService());
// Seed the session `IExecContext` (was `IKaos` in the old harness).
for (const [id, value] of execContextSeed(createExecContext(this.cwd))) {
reg.defineInstance(id as ServiceIdentifier<unknown>, value);
}
reg.defineInstance(IKaos, wrapKaos(createFakeKaos().withCwd(this.cwd)));
// Note: `ISessionAgentFileSystem` and `ISessionProcessRunner` are
// auto-registered by their service files (backed by `IExecContext`
// and Node fs/spawn). Tests that need a fake override them via
// `execEnvServices({ agentFs: … })` / `execEnvServices({ processRunner: … })`.
// auto-registered by their service files and backed by `IKaos`.
// Tests that need a fake override them via `execEnvServices`.
reg.defineInstance(ISessionTerminalBackend, createTerminalBackend());
reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService));
reg.defineDescriptor(ISessionModelResolver, new SyncDescriptor(ConfigBackedModelResolver, [{}]));
@ -1502,6 +1511,7 @@ export class AgentTestContext {
}
async expectResumeMatches(): Promise<void> {
await this.waitForSessionMetadata();
await this.drainWirePersistence();
const profile = this.get(IAgentProfileService);
const configSnapshot = structuredClone(this.get(IConfigService).getAll() as KimiConfig);
@ -1518,14 +1528,20 @@ export class AgentTestContext {
try {
await resumed.restorePersisted();
await resumed.waitForSessionMetadata();
// oxlint-disable-next-line jest/no-standalone-expect
expect(resumeStateSnapshot(resumed)).toEqual(resumeStateSnapshot(this));
} finally {
await resumed.waitForSessionMetadata();
await resumed.dispose();
}
}
private async waitForSessionMetadata(): Promise<void> {
await this.session.accessor.get(ISessionMetadata).ready;
}
private async drainWirePersistence(): Promise<void> {
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();

View file

@ -4,6 +4,7 @@ export {
agentServices,
backgroundServices,
configServices,
createCommandKaos,
createCommandRunner,
createTestAgent,
appService,
@ -15,6 +16,7 @@ export {
goalServices,
homeDirServices,
InMemoryWireRecordPersistence,
kaosServices,
llmGenerateServices,
logServices,
mcpServices,

View file

@ -85,8 +85,6 @@ function createHarness(): Harness {
hooks: {
onLaunched: { run: async () => {} },
onEnded: { run: async () => {} },
beforeStep: { run: async () => {} },
afterStep: { run: async () => {} },
},
} as unknown as IAgentTurnService;

View file

@ -3,16 +3,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices } from '#/_base/di/test';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentLoopService } from '#/agent/loop';
import {
IAgentToolDedupeService,
AgentToolDedupeService,
__testing as toolDedupTesting,
} from '#/agent/tooldedup';
import type { ToolDedupResult } from '#/agent/tooldedup';
} from '#/agent/toolDedupe';
import type { ToolDedupResult } from '#/agent/toolDedupe';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { IAgentTurnService } from '#/agent/turn';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import { stubToolExecutor, stubTurnWithHooks } from '../turn/stubs';
import { stubLoopWithHooks, stubToolExecutor, stubTurnWithHooks } from '../turn/stubs';
const { REMINDER_TEXT_1, REMINDER_TEXT_3, makeReminderText2 } = toolDedupTesting;
@ -30,6 +31,7 @@ function createDeduper(telemetry = recordingTelemetry(telemetryEvents)): ToolDed
const ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(ITelemetryService, telemetry);
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
reg.define(IAgentToolDedupeService, AgentToolDedupeService);

View file

@ -1,153 +1,37 @@
/**
* Fake execution-environment atoms minimal stubs for tool constructor
* injection in tests.
*
* Replaces the old `fake-kaos.ts` fixture. The v2 tools no longer take a
* single god-object `IKaos`; instead they receive the pieces they actually
* use:
*
* - `IHostEnvironment` (App-scope) sync OS/shell/path/home facts.
* - `IExecContext` (Session-scope) the session cwd and env layers.
* - `ISessionAgentFileSystem` (Session-scope) file IO.
* - `ISessionProcessRunner` (Session-scope) process spawn.
*
* The `createFake*` factories default every method to a "not implemented"
* throw; individual tests override the specific methods they exercise with
* `vi.fn()`.
*
* Also re-exports `PERMISSIVE_WORKSPACE` (`/` as workspaceDir) most tool
* tests care about behaviour, not path safety, so they default to a
* workspace that accepts any absolute path. Attack-vector tests create
* their own `WorkspaceConfig` with narrower bounds.
*/
import type { ExecutableToolResult } from '#/agent/tool';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { createExecContext, type IExecContext } from '#/session/execContext';
import type { ISessionProcessRunner } from '#/session/process';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
// ── Host environment ─────────────────────────────────────────────────
export const FAKE_HOST_ENVIRONMENT: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home/test',
ready: Promise.resolve(),
};
export function createFakeHostEnvironment(
overrides?: Partial<IHostEnvironment>,
): IHostEnvironment {
return { ...FAKE_HOST_ENVIRONMENT, ...overrides };
function notImplemented(name: string): never {
throw new Error(`${name} not implemented - override it in the test`);
}
// ── Exec context ─────────────────────────────────────────────────────
export function createFakeExecContext(
cwd: string = '/workspace',
envLayers: readonly Record<string, string>[] = [],
): IExecContext {
return createExecContext(cwd, envLayers);
}
// ── Process runner ───────────────────────────────────────────────────
function notImplemented(surface: string, method: string): never {
throw new Error(`${surface}.${method} not implemented — override in test`);
}
/**
* Fake `ISessionProcessRunner`. `exec` throws by default; tests override with
* `vi.fn()`. `envLayers` preserves the merge behaviour that the old
* `createFakeKaos.execWithEnv` provided extra layers are applied on top of
* the per-call `options.env`, later layers winning, mirroring how the real
* `IExecContext` overlays env for every spawned process.
*/
export function createFakeProcessRunner(
overrides?: Partial<ISessionProcessRunner>,
envLayers: readonly Record<string, string>[] = [],
overrides: Partial<ISessionProcessRunner> = {},
): ISessionProcessRunner {
const baseExec: ISessionProcessRunner['exec'] = async (args, options) => {
if (overrides?.exec !== undefined) {
const mergedEnv = mergeEnvLayers(options?.env, envLayers);
return overrides.exec(
args,
mergedEnv !== options?.env ? { ...options, env: mergedEnv } : options,
);
}
return notImplemented('FakeProcessRunner', 'exec');
};
return {
_serviceBrand: undefined,
exec: () => notImplemented('FakeProcessRunner.exec'),
...overrides,
exec: baseExec,
};
}
function mergeEnvLayers(
invocationEnv: Record<string, string> | undefined,
envLayers: readonly Record<string, string>[],
): Record<string, string> | undefined {
if (envLayers.length === 0) return invocationEnv;
const merged: Record<string, string> = { ...invocationEnv };
for (const layer of envLayers) Object.assign(merged, layer);
return merged;
}
// ── Agent filesystem ─────────────────────────────────────────────────
/**
* Fake `ISessionAgentFileSystem`. Every method throws by default; tests
* override the specific ones they exercise. `withCwd` returns a fresh fake
* with the new `cwd` baked in but the same overrides, matching how
* consumers use it in tests.
*/
export function createFakeAgentFs(
overrides?: Partial<ISessionAgentFileSystem>,
cwd: string = '/workspace',
overrides: Partial<ISessionAgentFileSystem> = {},
): ISessionAgentFileSystem {
const fake: ISessionAgentFileSystem = {
const cwd = overrides.cwd ?? '/workspace';
const fs: ISessionAgentFileSystem = {
_serviceBrand: undefined,
cwd,
readText: () => notImplemented('FakeAgentFs', 'readText'),
writeText: () => notImplemented('FakeAgentFs', 'writeText'),
readBytes: () => notImplemented('FakeAgentFs', 'readBytes'),
readLines: () => notImplemented('FakeAgentFs', 'readLines'),
writeBytes: () => notImplemented('FakeAgentFs', 'writeBytes'),
stat: () => notImplemented('FakeAgentFs', 'stat'),
readdir: () => notImplemented('FakeAgentFs', 'readdir'),
glob: () => notImplemented('FakeAgentFs', 'glob'),
mkdir: () => notImplemented('FakeAgentFs', 'mkdir'),
withCwd: (next: string) => createFakeAgentFs(overrides, next),
...overrides,
readText: () => notImplemented('FakeAgentFs.readText'),
writeText: () => notImplemented('FakeAgentFs.writeText'),
readBytes: () => notImplemented('FakeAgentFs.readBytes'),
readLines: () => notImplemented('FakeAgentFs.readLines'),
writeBytes: () => notImplemented('FakeAgentFs.writeBytes'),
stat: () => notImplemented('FakeAgentFs.stat'),
readdir: () => notImplemented('FakeAgentFs.readdir'),
glob: () => notImplemented('FakeAgentFs.glob'),
mkdir: () => notImplemented('FakeAgentFs.mkdir'),
withCwd: (nextCwd) => createFakeAgentFs({ ...overrides, cwd: nextCwd }),
};
return fake;
}
// ── Test-wide helpers ────────────────────────────────────────────────
export const PERMISSIVE_WORKSPACE: WorkspaceConfig = {
workspaceDir: '/',
additionalDirs: [],
};
/**
* Assert that a `ToolResult`'s `content` is a string and return it.
* Keeps the lint rule `typescript-eslint(no-base-to-string)` happy by
* narrowing the `string | ToolResultContent[]` union in one place.
*/
export function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
if (typeof c !== 'string') {
throw new TypeError(`expected string content, got ${typeof c}`);
}
return c;
return { ...fs, ...overrides };
}

View file

@ -7,6 +7,7 @@
import { createHooks } from '#/hooks';
import type { PromptOrigin } from '#/agent/contextMemory';
import type { IAgentLoopService } from '#/agent/loop';
import type { IAgentTurnService, Turn } from '#/agent/turn';
import type { IAgentToolExecutorService } from '#/agent/toolExecutor';
@ -43,10 +44,16 @@ function makeHooks(): IAgentTurnService['hooks'] {
return createHooks([
'onLaunched',
'onEnded',
]) as IAgentTurnService['hooks'];
}
function makeLoopHooks(): IAgentLoopService['hooks'] {
return createHooks([
'beforeStep',
'onStepUsage',
'afterStep',
'onContextOverflow',
]) as IAgentTurnService['hooks'];
]) as IAgentLoopService['hooks'];
}
/** A configurable `IAgentTurnService` stub backed by real `OrderedHookSlot`s. */
@ -74,9 +81,9 @@ export function stubTurn(options: StubTurnOptions = {}): StubTurn {
/**
* An `IAgentTurnService` stub backed by real `OrderedHookSlot`s. Use when the system
* under test registers turn-lifecycle hooks (`onLaunched` / `beforeStep` /
* `afterStep`) in its constructor, or when a test needs to drive those hooks
* directly. `launch` returns a minimal {@link Turn}; `getActiveTurn` is a no-op.
* under test registers turn-lifecycle hooks (`onLaunched` / `onEnded`) in its
* constructor, or when a test needs to drive those hooks directly. `launch`
* returns a minimal {@link Turn}; `getActiveTurn` is a no-op.
*/
export function stubTurnWithHooks(): IAgentTurnService {
const turn = makeTurn(0);
@ -89,6 +96,17 @@ export function stubTurnWithHooks(): IAgentTurnService {
};
}
/** An `IAgentLoopService` stub backed by real loop lifecycle hook slots. */
export function stubLoopWithHooks(): IAgentLoopService {
const hooks = makeLoopHooks();
hooks.beforeStep.register('turn-before-step-event', (_ctx, next) => next());
return {
_serviceBrand: undefined,
hooks,
runTurn: async () => ({ reason: 'completed' }),
};
}
/**
* An `IAgentToolExecutorService` stub whose tool-execution hooks (`onWillExecuteTool` /
* `onDidExecuteTool`) are real `OrderedHookSlot`s, so services that register

View file

@ -18,10 +18,10 @@ import { describe, expect, it, vi } from 'vitest';
import { abortError, abortable } from '#/_base/utils/abort';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import type { ContextMessage } from '#/agent/contextMemory';
import { IKaos } from '#/app/kaos';
import { IOAuthService } from '#/app/auth';
import { ErrorCodes, KimiError } from '#/errors';
import { HookEngine } from '#/agent/externalHooks/engine';
import { IHostEnvironment } from '#/app/hostEnvironment';
import type { ILogger as Logger, LogPayload } from '#/app/log';
import { IAgentMcpService } from '#/agent/mcp';
import { McpConnectionManager } from '#/agent/mcp/connection-manager';
@ -1723,7 +1723,7 @@ describe('Agent turn flow', () => {
});
const registration = registerMediaTools(ctx.get(IAgentToolRegistryService), {
fs: ctx.get(ISessionAgentFileSystem),
env: ctx.get(IHostEnvironment),
kaos: ctx.get(IKaos),
workspace: { workspaceDir: '/workspace', additionalDirs: [] },
capabilities: mediaCapabilities(),
videoUploader,

View file

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

View file

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