mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 05:35:34 +00:00
Merge remote-tracking branch 'origin/kimi-code-v2' into kimi-code-v2
This commit is contained in:
commit
6633fcdd04
37 changed files with 1512 additions and 675 deletions
231
GOAL.md
Normal file
231
GOAL.md
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
# Goal 功能拆分
|
||||
|
||||
本文把 agent-core 中 goal mode 的能力拆成三部分:
|
||||
|
||||
1. 核心工作流:没有它就不能运行 goal。
|
||||
2. 统计 / token 数限制:让 goal 可度量、可限额、可审计。
|
||||
3. 用户交互相关:让用户可以安全启动、理解、控制和恢复 goal。
|
||||
|
||||
## 1. 核心工作流
|
||||
|
||||
核心工作流是 goal mode 的运行骨架。它负责创建结构化目标、维护状态机、把普通 turn 串成自治多轮执行,并让模型用机器可读状态结束或停放目标。
|
||||
|
||||
### 目标状态
|
||||
|
||||
同一个 main agent 同时最多只有一个当前 goal。goal 不是普通聊天文本,而是 runtime 持有的结构化状态,至少包含目标、可选完成标准、当前状态、停止原因和运行统计。
|
||||
|
||||
状态分为四类:
|
||||
|
||||
- `active`:正在被 goal driver 推进。只有这个状态会自动运行下一轮。
|
||||
- `paused`:暂停但保留目标。通常来自用户暂停、中断、进程恢复后降级、provider 或 runtime 错误。可以恢复。
|
||||
- `blocked`:目标遇到真实阻塞但保留目标。通常来自模型判断需要外部输入、目标无法按当前表述完成、预算达到、prompt hook 阻止。可以恢复。
|
||||
- `complete`:瞬时完成状态。runtime 发出完成事件后立即清除 goal,不长期持久化。
|
||||
|
||||
没有 `cancelled` 状态。取消就是清除 goal,并提醒模型忽略之前关于该目标的 active reminder。
|
||||
|
||||
### 创建和替换
|
||||
|
||||
创建 goal 时,runtime 需要校验目标不能为空、不能过长。已有 active、paused 或 blocked goal 时,默认拒绝创建新 goal,防止静默覆盖。只有用户或调用方明确要求替换时,才先清除旧 goal,再创建新 goal。
|
||||
|
||||
新 goal 创建后进入 `active`,写入持久记录,并发出 goal 更新事件。
|
||||
|
||||
### 多轮驱动
|
||||
|
||||
goal driver 的职责是把一个 active goal 推进成连续的普通 turn:
|
||||
|
||||
- turn 开始时如果 goal 已经是 `active`,进入 goal driver。
|
||||
- 普通 turn 中如果模型创建了 goal,或把 paused/blocked goal 恢复成 active,当前 turn 结束后 goal driver 接管继续执行。
|
||||
- driver 每次只运行一个普通 turn。
|
||||
- 每个 turn 结束后读取 goal 状态。
|
||||
- goal 仍是 `active` 时,runtime 自动追加 continuation prompt 并启动下一轮。
|
||||
- goal 变成 `paused`、`blocked` 或被清除时,driver 停止。
|
||||
|
||||
模型如果不调用状态更新工具,且 goal 仍是 active,runtime 会继续下一轮。模型不能只靠自然语言说“完成了”来结束 goal,必须给出结构化状态信号。
|
||||
|
||||
### Goal 注入
|
||||
|
||||
每个 goal turn 的边界,runtime 会把当前 goal 状态注入上下文。注入内容包括:
|
||||
|
||||
- 当前正在 goal mode。
|
||||
- 目标和完成标准是什么。
|
||||
- 目标文本是用户提供的数据,不能覆盖 system/developer 指令、工具 schema、权限规则或 host 控制。
|
||||
- 当前状态和进度。
|
||||
- 模型应该做简短自审,然后推进一个连贯工作切片。
|
||||
- 简单、已完成、不可能、不安全、矛盾的目标,应在同一轮内直接标记 complete 或 blocked。
|
||||
- 只有全部要求完成、验证通过、没有下一步有用动作时,才能标记 complete。
|
||||
- 外部条件或用户输入阻塞时,应标记 blocked。
|
||||
- 不要只做了计划、总结、第一版或部分结果就标记 complete。
|
||||
|
||||
goal 注入只在 turn / continuation 边界做,不在每个 model step 都做,避免上下文重复膨胀,也有利于 prompt cache。
|
||||
|
||||
paused 和 blocked goal 的注入更轻:
|
||||
|
||||
- paused:提醒模型目标存在但当前不应自治推进,除非用户明确要求继续。
|
||||
- blocked:提醒模型目标被阻塞且当前不自治推进,除非用户要求处理或恢复。
|
||||
|
||||
### Continuation prompt
|
||||
|
||||
当 goal 仍是 active,runtime 会追加一个系统触发输入,含义相当于“继续朝当前 active goal 工作”。它不只是简单续跑,还要求模型每轮重新判断:
|
||||
|
||||
- 是否已经完成。
|
||||
- 是否遇到真实阻塞。
|
||||
- 是否应该只推进一个合理切片后继续下一轮。
|
||||
- 是否应该避免发散或启动无关工作。
|
||||
- 除非真实阻塞,否则不要向用户要输入。
|
||||
|
||||
### 完成、阻塞和暂停
|
||||
|
||||
模型通过结构化状态更新控制 goal 生命周期:
|
||||
|
||||
- `complete`:目标已满足,runtime 发出完成事件并清除 goal。
|
||||
- `blocked`:遇到真实阻塞,runtime 保留 goal 并停止自治推进。
|
||||
- `paused`:暂时放下 goal,runtime 保留 goal 并停止自治推进。
|
||||
- `active`:恢复 paused 或 blocked goal。
|
||||
|
||||
状态更新工具的输入应保持窄,只表达机器状态。完成总结或阻塞原因由模型随后给用户说明。
|
||||
|
||||
当模型标记 complete 后,runtime 应再给模型一次收尾机会,生成简短最终回复,说明 goal 已完成、主要做了什么、跑了什么验证。
|
||||
|
||||
当模型标记 blocked 后,runtime 应再给模型一次收尾机会,说明具体阻塞、需要什么输入或变化才能继续。
|
||||
|
||||
如果当前 turn 已经没有 step 预算,不应为了收尾总结强行再跑一步,避免把“没法写总结”变成 turn 失败。
|
||||
|
||||
### 错误停车
|
||||
|
||||
goal mode 把技术运行失败视为可恢复停车:
|
||||
|
||||
- 用户中断当前 turn:goal 变 paused。
|
||||
- provider rate limit:goal 变 paused。
|
||||
- provider 连接错误、认证错误、API 错误:goal 变 paused。
|
||||
- 模型配置错误:goal 变 paused。
|
||||
- runtime 异常:goal 变 paused。
|
||||
- provider safety filter:goal 变 paused。
|
||||
|
||||
业务、规则或外部条件阻塞则变 blocked:
|
||||
|
||||
- prompt hook 阻止目标。
|
||||
- 模型判断无法继续。
|
||||
- 预算达到。
|
||||
- 需要用户或外部系统提供新条件。
|
||||
|
||||
### 持久化和恢复
|
||||
|
||||
goal 的创建、更新、完成、阻塞、清除应写入可恢复记录。session 恢复时,runtime 用记录重建 goal。
|
||||
|
||||
恢复时如果发现 goal 原来是 active,不应自动继续跑,而是降级为 paused。因为旧进程中的 active turn 不可能还活着,自动继续会造成重启后偷偷消耗资源。
|
||||
|
||||
paused 和 blocked 原样保留。complete 理论上不长期存在,因为完成后会清除。
|
||||
|
||||
fork session 时不继承源 session 的 goal,并提醒模型不要继续源 session 的旧目标。
|
||||
|
||||
## 2. 统计 / token 数限制
|
||||
|
||||
这一部分让 goal 可度量、可限额、可审计。没有它,goal 仍然可以运行,但不可控。
|
||||
|
||||
### 运行统计
|
||||
|
||||
goal 统计包括:
|
||||
|
||||
- continuation turn 数。
|
||||
- token 数。
|
||||
- active wall-clock 时间。
|
||||
|
||||
统计只在 goal 是 `active` 时增长。paused 和 blocked 期间不继续计数。
|
||||
|
||||
turn 统计在每个 goal turn 准备运行时增加,因此模型在某一轮里标记 complete 时,这一轮也计入最终统计。
|
||||
|
||||
token 统计在 model step 结束后累计。没有 active goal 时,不记入 goal。token 统计应以静默更新为主,不应每一步都刷 UI。
|
||||
|
||||
时间统计只计算 active pursuit 时间。进入 active 时开启计时区间,离开 active 时折算进累计时间;pause/resume 会形成新的 active 区间。
|
||||
|
||||
### 预算
|
||||
|
||||
goal 预算包括:
|
||||
|
||||
- turn budget。
|
||||
- token budget。
|
||||
- wall-clock budget。
|
||||
|
||||
默认没有预算。只有用户明确给出硬限制时才设置,例如“最多 20 轮”“不超过 500k token”“30 分钟内”。模糊表达如“尽快”“别花太久”不能设置预算,模型也不能自行发明预算。
|
||||
|
||||
时间预算需要合理范围。过短或过长应拒绝。turn 和 token 预算应规范化为正整数。
|
||||
|
||||
### 预算硬停
|
||||
|
||||
预算检查应发生在 goal turn 开始前和结束后。token budget 还应在 model step 后触发停止,避免超额后继续下一步。
|
||||
|
||||
一旦达到预算,runtime 应直接把 goal 标记为 blocked,原因是配置预算已达到。这个 blocked 仍可恢复,但如果预算不变,恢复后可能立刻再次 blocked。
|
||||
|
||||
### 预算引导和最终统计
|
||||
|
||||
当预算未接近时,模型提示应鼓励稳定推进。当任一预算达到 75% 以上时,提示应转为收敛,避免启动新的可选工作。
|
||||
|
||||
complete 和 blocked 的最终回复提示应包含 worked turns、elapsed time、tokens used 等统计信息。UI 事件也应带当前 snapshot 和变化类型。
|
||||
|
||||
telemetry 可以记录 goal 创建、预算设置、continuation、状态变化、清除等事件,但不应包含目标文本、停止原因等敏感内容。
|
||||
|
||||
## 3. 用户交互相关
|
||||
|
||||
这一部分让用户可以安全启动、理解、控制和恢复 goal。没有它,runtime 仍可能运行,但交互体验和安全边界不足。
|
||||
|
||||
### 生命周期控制
|
||||
|
||||
用户可以直接控制 goal:
|
||||
|
||||
- 创建。
|
||||
- 查看。
|
||||
- 暂停。
|
||||
- 恢复。
|
||||
- 取消。
|
||||
|
||||
这些操作可以不经过模型 turn。pause 把 active goal 变 paused;resume 把 paused 或 blocked goal 变 active;cancel 直接清除当前 goal。
|
||||
|
||||
resume 会清除旧停止原因,表示开始新的尝试。paused/blocked goal 不会因为用户发普通消息就自动继续。
|
||||
|
||||
### 模型发起 goal 的确认
|
||||
|
||||
模型可以代表用户创建 goal,但只有在用户明确要求启动 goal、自治工作,或宿主 goal-intake 提示要求时才应该这样做。普通请求不能被模型擅自升级成 goal。
|
||||
|
||||
模型发起 CreateGoal 时,非 auto 权限模式下应触发用户确认。确认菜单允许用户选择本次 goal 的运行权限模式。用户拒绝则 goal 不创建。
|
||||
|
||||
`GetGoal`、`SetGoalBudget`、`UpdateGoal` 只改 goal runtime 状态,默认可以更容易批准。真正写文件、跑 shell、访问敏感路径等仍走普通权限系统。
|
||||
|
||||
### 暂停、阻塞和取消后的提示
|
||||
|
||||
paused goal 的上下文提示应说明目标存在但当前不应继续做,除非用户明确要求继续。
|
||||
|
||||
blocked goal 的上下文提示应说明目标被阻塞且当前不自治推进,可以在用户要求时帮助解阻,否则正常处理当前请求。
|
||||
|
||||
cancel 后应追加提醒,让模型忽略旧 goal 的 active reminder,避免旧上下文诱导模型继续已经取消的目标。
|
||||
|
||||
### 完成和阻塞的用户回复
|
||||
|
||||
complete 后,goal 被清除,模型应给用户一条简短完成总结,说明完成了什么、做了什么验证。
|
||||
|
||||
blocked 后,goal 保留,模型应给用户一条简短阻塞说明,说明具体阻塞和继续所需输入、权限、外部条件或变更。
|
||||
|
||||
### Tool 暴露和隔离
|
||||
|
||||
goal 工具只给 main agent。subagent 不应直接创建、恢复、结束主 goal。
|
||||
|
||||
没有 goal 时,模型不应看到 `UpdateGoal` 和 `SetGoalBudget`。有 goal 时才暴露这些控制工具。
|
||||
|
||||
goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有用户语义。
|
||||
|
||||
### 辅助写 goal
|
||||
|
||||
`write-goal` 类能力用于帮助用户把粗糙意图整理成适合 goal mode 的完成契约。好的 goal 应明确:
|
||||
|
||||
- end state:什么条件必须变成真。
|
||||
- proof:用什么可观察证据证明完成。
|
||||
- boundaries:工作范围和禁止触碰的内容。
|
||||
- loop:如何迭代推进。
|
||||
- stop rule:什么情况下停止并报告,而不是强行继续。
|
||||
|
||||
预算是 opt-in,不应默认加入,也不应把 turn cap 写进目标文本。
|
||||
|
||||
### UI 和会话语义
|
||||
|
||||
goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshot,UI 可以继续展示可恢复 goal。
|
||||
|
||||
session 恢复时,active goal 会变 paused,避免重启后自动继续。fork session 时不继承 goal,并提醒模型不要继续源 session 的目标。
|
||||
|
|
@ -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 |
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface IAgentGoalService {
|
|||
createGoal(input: CreateGoalInput, actor?: GoalActor): Promise<GoalSnapshot>;
|
||||
pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
|
||||
resumeGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
|
||||
cancelGoal(actor?: GoalActor): Promise<GoalSnapshot>;
|
||||
cancelGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
|
||||
setBudgetLimits(
|
||||
input: { readonly budgetLimits: GoalBudgetLimits },
|
||||
actor?: GoalActor,
|
||||
|
|
|
|||
|
|
@ -1,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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@
|
|||
|
||||
export * from './swarm';
|
||||
export * from './swarmService';
|
||||
export * from './subagentBatch';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export {
|
|||
agentServices,
|
||||
backgroundServices,
|
||||
configServices,
|
||||
createCommandKaos,
|
||||
createCommandRunner,
|
||||
createTestAgent,
|
||||
appService,
|
||||
|
|
@ -15,6 +16,7 @@ export {
|
|||
goalServices,
|
||||
homeDirServices,
|
||||
InMemoryWireRecordPersistence,
|
||||
kaosServices,
|
||||
llmGenerateServices,
|
||||
logServices,
|
||||
mcpServices,
|
||||
|
|
|
|||
|
|
@ -85,8 +85,6 @@ function createHarness(): Harness {
|
|||
hooks: {
|
||||
onLaunched: { run: async () => {} },
|
||||
onEnded: { run: async () => {} },
|
||||
beforeStep: { run: async () => {} },
|
||||
afterStep: { run: async () => {} },
|
||||
},
|
||||
} as unknown as IAgentTurnService;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
|
|||
[ErrorCodes.PROMPT_NOT_FOUND]: ErrorCode.PROMPT_NOT_FOUND,
|
||||
[ErrorCodes.SESSION_BUSY]: ErrorCode.SESSION_BUSY,
|
||||
[ErrorCodes.PROMPT_ALREADY_COMPLETED]: ErrorCode.PROMPT_ALREADY_COMPLETED,
|
||||
[ErrorCodes.GOAL_ALREADY_EXISTS]: ErrorCode.GOAL_ALREADY_EXISTS,
|
||||
[ErrorCodes.GOAL_NOT_FOUND]: ErrorCode.GOAL_NOT_FOUND,
|
||||
[ErrorCodes.GOAL_STATUS_INVALID]: ErrorCode.GOAL_STATUS_INVALID,
|
||||
[ErrorCodes.GOAL_NOT_RESUMABLE]: ErrorCode.GOAL_NOT_RESUMABLE,
|
||||
[ErrorCodes.GOAL_OBJECTIVE_EMPTY]: ErrorCode.GOAL_OBJECTIVE_EMPTY,
|
||||
[ErrorCodes.GOAL_OBJECTIVE_TOO_LONG]: ErrorCode.GOAL_OBJECTIVE_TOO_LONG,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,6 +29,22 @@ interface SessionMetaWire {
|
|||
archived: boolean;
|
||||
}
|
||||
|
||||
interface GoalSnapshotWire {
|
||||
goalId: string;
|
||||
objective: string;
|
||||
completionCriterion?: string;
|
||||
status: 'active' | 'paused' | 'blocked' | 'complete';
|
||||
turnsUsed: number;
|
||||
tokensUsed: number;
|
||||
wallClockMs: number;
|
||||
budget: unknown;
|
||||
terminalReason?: string;
|
||||
}
|
||||
|
||||
interface GoalToolResultWire {
|
||||
goal: GoalSnapshotWire | null;
|
||||
}
|
||||
|
||||
describe('server-v2 /api/v2 RPC', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
|
|
@ -224,6 +240,77 @@ describe('server-v2 /api/v2 RPC', () => {
|
|||
expect(body.data.isError).not.toBe(true);
|
||||
});
|
||||
|
||||
it('controls goals through goal:* RPC', async () => {
|
||||
const id = await createSession(home as string);
|
||||
await createMainAgent(id);
|
||||
|
||||
const created = await call<GoalSnapshotWire>(
|
||||
'POST',
|
||||
`/api/v2/session/${id}/agent/main/goal:create`,
|
||||
{ objective: 'finish the migration' },
|
||||
);
|
||||
expect(created.body.code).toBe(0);
|
||||
expect(created.body.data).toMatchObject({
|
||||
objective: 'finish the migration',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const read = await call<GoalToolResultWire>(
|
||||
'GET',
|
||||
`/api/v2/session/${id}/agent/main/goal:get`,
|
||||
);
|
||||
expect(read.body.code).toBe(0);
|
||||
expect(read.body.data.goal).toMatchObject({
|
||||
objective: 'finish the migration',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const paused = await call<GoalSnapshotWire>(
|
||||
'POST',
|
||||
`/api/v2/session/${id}/agent/main/goal:pause`,
|
||||
{},
|
||||
);
|
||||
expect(paused.body.data.status).toBe('paused');
|
||||
|
||||
const resumed = await call<GoalSnapshotWire>(
|
||||
'POST',
|
||||
`/api/v2/session/${id}/agent/main/goal:resume`,
|
||||
{},
|
||||
);
|
||||
expect(resumed.body.data.status).toBe('active');
|
||||
|
||||
const cancelled = await call<GoalSnapshotWire>(
|
||||
'POST',
|
||||
`/api/v2/session/${id}/agent/main/goal:cancel`,
|
||||
{},
|
||||
);
|
||||
expect(cancelled.body.code).toBe(0);
|
||||
expect(cancelled.body.data.status).toBe('active');
|
||||
|
||||
const afterCancel = await call<GoalToolResultWire>(
|
||||
'GET',
|
||||
`/api/v2/session/${id}/agent/main/goal:get`,
|
||||
);
|
||||
expect(afterCancel.body.data.goal).toBeNull();
|
||||
});
|
||||
|
||||
it('maps goal errors through RPC envelopes', async () => {
|
||||
const id = await createSession(home as string);
|
||||
await createMainAgent(id);
|
||||
|
||||
await call<GoalSnapshotWire>(
|
||||
'POST',
|
||||
`/api/v2/session/${id}/agent/main/goal:create`,
|
||||
{ objective: 'first' },
|
||||
);
|
||||
const duplicate = await call<null>(
|
||||
'POST',
|
||||
`/api/v2/session/${id}/agent/main/goal:create`,
|
||||
{ objective: 'second' },
|
||||
);
|
||||
expect(duplicate.body.code).toBe(40913);
|
||||
});
|
||||
|
||||
it('lists and installs plugins through plugins:* RPC', async () => {
|
||||
const pluginRoot = await mkdtemp(join(tmpdir(), 'server-v2-plugin-source-'));
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue