diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 000000000..c0fdc2a36 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,231 @@ +# Goal 功能拆分 + +本文把 agent-core 中 goal mode 的能力拆成三部分: + +1. 核心工作流:没有它就不能运行 goal。 +2. 统计 / token 数限制:让 goal 可度量、可限额、可审计。 +3. 用户交互相关:让用户可以安全启动、理解、控制和恢复 goal。 + +## 1. 核心工作流 + +核心工作流是 goal mode 的运行骨架。它负责创建结构化目标、维护状态机、把普通 turn 串成自治多轮执行,并让模型用机器可读状态结束或停放目标。 + +### 目标状态 + +同一个 main agent 同时最多只有一个当前 goal。goal 不是普通聊天文本,而是 runtime 持有的结构化状态,至少包含目标、可选完成标准、当前状态、停止原因和运行统计。 + +状态分为四类: + +- `active`:正在被 goal driver 推进。只有这个状态会自动运行下一轮。 +- `paused`:暂停但保留目标。通常来自用户暂停、中断、进程恢复后降级、provider 或 runtime 错误。可以恢复。 +- `blocked`:目标遇到真实阻塞但保留目标。通常来自模型判断需要外部输入、目标无法按当前表述完成、预算达到、prompt hook 阻止。可以恢复。 +- `complete`:瞬时完成状态。runtime 发出完成事件后立即清除 goal,不长期持久化。 + +没有 `cancelled` 状态。取消就是清除 goal,并提醒模型忽略之前关于该目标的 active reminder。 + +### 创建和替换 + +创建 goal 时,runtime 需要校验目标不能为空、不能过长。已有 active、paused 或 blocked goal 时,默认拒绝创建新 goal,防止静默覆盖。只有用户或调用方明确要求替换时,才先清除旧 goal,再创建新 goal。 + +新 goal 创建后进入 `active`,写入持久记录,并发出 goal 更新事件。 + +### 多轮驱动 + +goal driver 的职责是把一个 active goal 推进成连续的普通 turn: + +- turn 开始时如果 goal 已经是 `active`,进入 goal driver。 +- 普通 turn 中如果模型创建了 goal,或把 paused/blocked goal 恢复成 active,当前 turn 结束后 goal driver 接管继续执行。 +- driver 每次只运行一个普通 turn。 +- 每个 turn 结束后读取 goal 状态。 +- goal 仍是 `active` 时,runtime 自动追加 continuation prompt 并启动下一轮。 +- goal 变成 `paused`、`blocked` 或被清除时,driver 停止。 + +模型如果不调用状态更新工具,且 goal 仍是 active,runtime 会继续下一轮。模型不能只靠自然语言说“完成了”来结束 goal,必须给出结构化状态信号。 + +### Goal 注入 + +每个 goal turn 的边界,runtime 会把当前 goal 状态注入上下文。注入内容包括: + +- 当前正在 goal mode。 +- 目标和完成标准是什么。 +- 目标文本是用户提供的数据,不能覆盖 system/developer 指令、工具 schema、权限规则或 host 控制。 +- 当前状态和进度。 +- 模型应该做简短自审,然后推进一个连贯工作切片。 +- 简单、已完成、不可能、不安全、矛盾的目标,应在同一轮内直接标记 complete 或 blocked。 +- 只有全部要求完成、验证通过、没有下一步有用动作时,才能标记 complete。 +- 外部条件或用户输入阻塞时,应标记 blocked。 +- 不要只做了计划、总结、第一版或部分结果就标记 complete。 + +goal 注入只在 turn / continuation 边界做,不在每个 model step 都做,避免上下文重复膨胀,也有利于 prompt cache。 + +paused 和 blocked goal 的注入更轻: + +- paused:提醒模型目标存在但当前不应自治推进,除非用户明确要求继续。 +- blocked:提醒模型目标被阻塞且当前不自治推进,除非用户要求处理或恢复。 + +### Continuation prompt + +当 goal 仍是 active,runtime 会追加一个系统触发输入,含义相当于“继续朝当前 active goal 工作”。它不只是简单续跑,还要求模型每轮重新判断: + +- 是否已经完成。 +- 是否遇到真实阻塞。 +- 是否应该只推进一个合理切片后继续下一轮。 +- 是否应该避免发散或启动无关工作。 +- 除非真实阻塞,否则不要向用户要输入。 + +### 完成、阻塞和暂停 + +模型通过结构化状态更新控制 goal 生命周期: + +- `complete`:目标已满足,runtime 发出完成事件并清除 goal。 +- `blocked`:遇到真实阻塞,runtime 保留 goal 并停止自治推进。 +- `paused`:暂时放下 goal,runtime 保留 goal 并停止自治推进。 +- `active`:恢复 paused 或 blocked goal。 + +状态更新工具的输入应保持窄,只表达机器状态。完成总结或阻塞原因由模型随后给用户说明。 + +当模型标记 complete 后,runtime 应再给模型一次收尾机会,生成简短最终回复,说明 goal 已完成、主要做了什么、跑了什么验证。 + +当模型标记 blocked 后,runtime 应再给模型一次收尾机会,说明具体阻塞、需要什么输入或变化才能继续。 + +如果当前 turn 已经没有 step 预算,不应为了收尾总结强行再跑一步,避免把“没法写总结”变成 turn 失败。 + +### 错误停车 + +goal mode 把技术运行失败视为可恢复停车: + +- 用户中断当前 turn:goal 变 paused。 +- provider rate limit:goal 变 paused。 +- provider 连接错误、认证错误、API 错误:goal 变 paused。 +- 模型配置错误:goal 变 paused。 +- runtime 异常:goal 变 paused。 +- provider safety filter:goal 变 paused。 + +业务、规则或外部条件阻塞则变 blocked: + +- prompt hook 阻止目标。 +- 模型判断无法继续。 +- 预算达到。 +- 需要用户或外部系统提供新条件。 + +### 持久化和恢复 + +goal 的创建、更新、完成、阻塞、清除应写入可恢复记录。session 恢复时,runtime 用记录重建 goal。 + +恢复时如果发现 goal 原来是 active,不应自动继续跑,而是降级为 paused。因为旧进程中的 active turn 不可能还活着,自动继续会造成重启后偷偷消耗资源。 + +paused 和 blocked 原样保留。complete 理论上不长期存在,因为完成后会清除。 + +fork session 时不继承源 session 的 goal,并提醒模型不要继续源 session 的旧目标。 + +## 2. 统计 / token 数限制 + +这一部分让 goal 可度量、可限额、可审计。没有它,goal 仍然可以运行,但不可控。 + +### 运行统计 + +goal 统计包括: + +- continuation turn 数。 +- token 数。 +- active wall-clock 时间。 + +统计只在 goal 是 `active` 时增长。paused 和 blocked 期间不继续计数。 + +turn 统计在每个 goal turn 准备运行时增加,因此模型在某一轮里标记 complete 时,这一轮也计入最终统计。 + +token 统计在 model step 结束后累计。没有 active goal 时,不记入 goal。token 统计应以静默更新为主,不应每一步都刷 UI。 + +时间统计只计算 active pursuit 时间。进入 active 时开启计时区间,离开 active 时折算进累计时间;pause/resume 会形成新的 active 区间。 + +### 预算 + +goal 预算包括: + +- turn budget。 +- token budget。 +- wall-clock budget。 + +默认没有预算。只有用户明确给出硬限制时才设置,例如“最多 20 轮”“不超过 500k token”“30 分钟内”。模糊表达如“尽快”“别花太久”不能设置预算,模型也不能自行发明预算。 + +时间预算需要合理范围。过短或过长应拒绝。turn 和 token 预算应规范化为正整数。 + +### 预算硬停 + +预算检查应发生在 goal turn 开始前和结束后。token budget 还应在 model step 后触发停止,避免超额后继续下一步。 + +一旦达到预算,runtime 应直接把 goal 标记为 blocked,原因是配置预算已达到。这个 blocked 仍可恢复,但如果预算不变,恢复后可能立刻再次 blocked。 + +### 预算引导和最终统计 + +当预算未接近时,模型提示应鼓励稳定推进。当任一预算达到 75% 以上时,提示应转为收敛,避免启动新的可选工作。 + +complete 和 blocked 的最终回复提示应包含 worked turns、elapsed time、tokens used 等统计信息。UI 事件也应带当前 snapshot 和变化类型。 + +telemetry 可以记录 goal 创建、预算设置、continuation、状态变化、清除等事件,但不应包含目标文本、停止原因等敏感内容。 + +## 3. 用户交互相关 + +这一部分让用户可以安全启动、理解、控制和恢复 goal。没有它,runtime 仍可能运行,但交互体验和安全边界不足。 + +### 生命周期控制 + +用户可以直接控制 goal: + +- 创建。 +- 查看。 +- 暂停。 +- 恢复。 +- 取消。 + +这些操作可以不经过模型 turn。pause 把 active goal 变 paused;resume 把 paused 或 blocked goal 变 active;cancel 直接清除当前 goal。 + +resume 会清除旧停止原因,表示开始新的尝试。paused/blocked goal 不会因为用户发普通消息就自动继续。 + +### 模型发起 goal 的确认 + +模型可以代表用户创建 goal,但只有在用户明确要求启动 goal、自治工作,或宿主 goal-intake 提示要求时才应该这样做。普通请求不能被模型擅自升级成 goal。 + +模型发起 CreateGoal 时,非 auto 权限模式下应触发用户确认。确认菜单允许用户选择本次 goal 的运行权限模式。用户拒绝则 goal 不创建。 + +`GetGoal`、`SetGoalBudget`、`UpdateGoal` 只改 goal runtime 状态,默认可以更容易批准。真正写文件、跑 shell、访问敏感路径等仍走普通权限系统。 + +### 暂停、阻塞和取消后的提示 + +paused goal 的上下文提示应说明目标存在但当前不应继续做,除非用户明确要求继续。 + +blocked goal 的上下文提示应说明目标被阻塞且当前不自治推进,可以在用户要求时帮助解阻,否则正常处理当前请求。 + +cancel 后应追加提醒,让模型忽略旧 goal 的 active reminder,避免旧上下文诱导模型继续已经取消的目标。 + +### 完成和阻塞的用户回复 + +complete 后,goal 被清除,模型应给用户一条简短完成总结,说明完成了什么、做了什么验证。 + +blocked 后,goal 保留,模型应给用户一条简短阻塞说明,说明具体阻塞和继续所需输入、权限、外部条件或变更。 + +### Tool 暴露和隔离 + +goal 工具只给 main agent。subagent 不应直接创建、恢复、结束主 goal。 + +没有 goal 时,模型不应看到 `UpdateGoal` 和 `SetGoalBudget`。有 goal 时才暴露这些控制工具。 + +goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有用户语义。 + +### 辅助写 goal + +`write-goal` 类能力用于帮助用户把粗糙意图整理成适合 goal mode 的完成契约。好的 goal 应明确: + +- end state:什么条件必须变成真。 +- proof:用什么可观察证据证明完成。 +- boundaries:工作范围和禁止触碰的内容。 +- loop:如何迭代推进。 +- stop rule:什么情况下停止并报告,而不是强行继续。 + +预算是 opt-in,不应默认加入,也不应把 turn cap 写进目标文本。 + +### UI 和会话语义 + +goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshot,UI 可以继续展示可恢复 goal。 + +session 恢复时,active goal 会变 paused,避免重启后自动继续。fork session 时不继承 goal,并提醒模型不要继续源 session 的目标。 diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index c456a503e..c570d5d2d 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -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 diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg index 35d6e9c84..6a3845bca 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.svg +++ b/packages/agent-core-v2/docs/di-scope-domains.svg @@ -1 +1 @@ -App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelogAppILogServiceILogWriterServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewayIWSBroadcastServicesession-lifecycleAppISessionLifecycleServicesession-indexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserkaosAppIKaosFactoryauthAppIOAuthServiceIAuthSummaryServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServicesessionSessionISessionServicesession-contextSessionISessionContext (seed)session-metadataSessionISessionMetadatasession-activitySessionISessionActivityagent-lifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextsessionLogSessionISessionLogServiceILogWriterServicekaosSessionIKaos (seed)agentFsSessionIAgentFileSystemIFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServicesubagentHostSessionISubagentHostprocessSessionIProcessRunnerIProcessterminalSessionITerminalServiceITerminalBackendmodelProviderSessionIModelProvider (seed)wireRecordAgentIWireRecord (event hub)eventSinkAgentIEventSinkblobStoreAgentIBlobStoreServicecontextMemoryAgentIContextMemorycontextProjectorAgentIContextProjectorcontextInjectorAgentIContextInjectorcontextSizeAgentIContextSizeServicesystemReminderAgentISystemReminderServicereplayBuilderAgentIReplayBuilderServiceprofileAgentIProfileServicepromptAgentIPromptServiceturnAgentITurnServiceloopAgentILoopServicellmRequesterAgentILLMRequesterllmRequestLogAgentILLMRequestLogServicetoolRegistryAgentIToolRegistrytoolExecutorAgentIToolExecutortoolStoreAgentIToolStoreServicetoolDedupAgentIToolDedupepermissionAgentIPermissionGatepermissionModeAgentIPermissionModeServicepermissionPolicyAgentIPermissionPolicyServicepermissionRulesAgentIPermissionRulesServiceplanAgentIPlanServicegoalAgentIGoalServiceskillAgentIAgentSkillServiceuserToolAgentIUserToolServicebackgroundAgentIBackgroundServicecronAgentICronServiceswarmAgentISwarmServicemcpAgentIMcpServicefullCompactionAgentIFullCompactionmicroCompactionAgentIMicroCompactionServiceexternalHooksAgentIExternalHooksServicetodoListAgentITodoListServiceusageAgentIUsageServicerpcAgentIAgentRPCServicefileToolsAgentIFileToolsServiceshellToolsAgentIShellToolsServiceonDidChangecontext.splicehooks.onSplicedcontext_size.measuredconfig.update / tools.set_active_toolshooks.onSplicedhooks.onResumeEndedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultplan_mode.enter/cancel/exitgoal.create/update/clearskill.activatetools.register_/unregister_user_toolbackground.task.started/terminatedhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exitfull_compaction.begin/cancel/completehooks.onContextOverflowmicro_compaction.apply / full_compaction.completeusage.recordsubscribehooks.onEndedScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file +App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelogAppILogServiceILogWriterServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysession-lifecycleAppISessionLifecycleServicesession-indexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserkaosAppIKaosFactoryauthAppIOAuthServiceIAuthSummaryServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceglobalSkillCatalogAppIGlobalSkillCatalogISkillCatalogStoresessionSessionISessionServicesession-contextSessionISessionContext (seed)session-metadataSessionISessionMetadatasession-activitySessionISessionActivityagent-lifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextsessionLogSessionISessionLogServiceILogWriterServicesessionSkillCatalogSessionISessionSkillCatalogkaosSessionIKaos (seed)agentFsSessionIAgentFileSystemIFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalSessionITerminalServiceITerminalBackendmodelProviderSessionIModelProvider (seed)wireRecordAgentIAgentWireRecordService (event hub)eventSinkAgentIAgentEventSinkServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServicereplayBuilderAgentIAgentReplayBuilderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicellmRequestLogAgentIAgentLLMRequestLogServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStoreAgentIAgentToolStoreServicetoolDedupAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicebackgroundAgentIAgentBackgroundServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServicetodoListAgentIAgentTodoListServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentIAgentFileToolsServiceshellToolsAgentIAgentShellToolsServiceagentToolAgentIAgentToolServicescopeContextAgentIAgentScopeContext (seed)onDidChangeConfigurationcontext.splicehooks.onSplicedcontext_size.measuredconfig.update / tools.set_active_toolshooks.onSplicedhooks.onResumeEndedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultplan_mode.enter/cancel/exitgoal.create/update/clearhook.result / goal.updatedturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolbackground.task.started/terminatedhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exitfull_compaction.begin/cancel/completehooks.onContextOverflowmicro_compaction.apply / full_compaction.completeusage.recordhooks.onEndedScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index d0edc5d44..96ad67a51 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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], diff --git a/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts b/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts index dae20a419..87e9b1d4b 100644 --- a/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts +++ b/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts @@ -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, diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 5cea5d766..ce84d4622 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -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(); }), diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 3b02e5d2d..9f58b5d81 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -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. diff --git a/packages/agent-core-v2/src/agent/goal/goal.ts b/packages/agent-core-v2/src/agent/goal/goal.ts index 7fe24a13e..ae9d7be04 100644 --- a/packages/agent-core-v2/src/agent/goal/goal.ts +++ b/packages/agent-core-v2/src/agent/goal/goal.ts @@ -17,7 +17,7 @@ export interface IAgentGoalService { createGoal(input: CreateGoalInput, actor?: GoalActor): Promise; pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; resumeGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; - cancelGoal(actor?: GoalActor): Promise; + cancelGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; setBudgetLimits( input: { readonly budgetLimits: GoalBudgetLimits }, actor?: GoalActor, diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 53ecb9d3d..655a544de 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -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(); + private readonly countedGoalTurns = new Set(); + private readonly goalOutcomeContinuationTurns = new Set(); + private readonly promptHookBlockedTurns = new Set(); 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 { + async cancelGoal( + _input: GoalReasonInput = {}, + actor: GoalActor = 'user', + ): Promise { 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 { + 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 { @@ -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 { + 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 { + 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', ); diff --git a/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts b/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts index 96ceebbe7..28a5d9bae 100644 --- a/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts +++ b/packages/agent-core-v2/src/agent/goal/tools/update-goal.ts @@ -6,25 +6,17 @@ * * The argument is intentionally just a status enum — no reason or evidence. The * model explains itself in its own reply; the status is the machine-readable - * signal. The tool is only offered to the model while a goal exists. + * signal. */ import { z } from 'zod'; import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; -import type { IAgentSystemReminderService } from '#/agent/systemReminder'; import type { BuiltinTool, ToolExecution } from '#/agent/tool'; import type { IAgentGoalService } from '#/agent/goal/goal'; -import { - buildGoalBlockedReasonPrompt, - buildGoalCompletionSummaryPrompt, -} from './outcome-prompts'; import DESCRIPTION from './update-goal.md?raw'; -const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion_summary'; -const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked_reason'; - export const UpdateGoalToolInputSchema = z .object({ status: z @@ -40,10 +32,7 @@ export class UpdateGoalTool implements BuiltinTool { readonly description: string = DESCRIPTION; readonly parameters: Record = toInputJsonSchema(UpdateGoalToolInputSchema); - constructor( - private readonly goal: IAgentGoalService, - private readonly reminders: IAgentSystemReminderService, - ) {} + constructor(private readonly goal: IAgentGoalService) {} resolveExecution(args: UpdateGoalToolInput): ToolExecution { return { @@ -56,28 +45,11 @@ export class UpdateGoalTool implements BuiltinTool { return { output: 'Goal resumed.' }; } if (args.status === 'complete') { - const completed = await this.goal.markComplete({}, 'model'); - // `complete` is transient: markComplete announces then clears the - // record. Store the summary request as a system reminder, so the next - // provider request ends with a user message after the UpdateGoal tool - // result. Anthropic-compatible providers reject trailing assistant - // messages as unsupported prefill. - if (completed !== null) { - this.reminders.appendSystemReminder(buildGoalCompletionSummaryPrompt(completed), { - kind: 'system_trigger', - name: GOAL_COMPLETION_REMINDER_NAME, - }); - } + await this.goal.markComplete({}, 'model'); return { output: 'Goal marked complete.', stopTurn: true }; } if (args.status === 'blocked') { - const blocked = await this.goal.markBlocked({}, 'model'); - if (blocked !== null) { - this.reminders.appendSystemReminder(buildGoalBlockedReasonPrompt(blocked), { - kind: 'system_trigger', - name: GOAL_BLOCKED_REMINDER_NAME, - }); - } + await this.goal.markBlocked({}, 'model'); return { output: 'Goal marked blocked.', stopTurn: true }; } await this.goal.pauseGoal({}, 'model'); diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index c760cf490..1dfd6d756 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -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; - readonly afterStep: HookSlot; - readonly onContextOverflow: HookSlot; -} +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; + readonly hooks: Hooks<{ + beforeStep: TurnStepContext; + onStepUsage: TurnStepUsageContext; + afterStep: TurnStepContext; + onContextOverflow: TurnContextOverflowContext; + }>; + runTurn(turn: Turn): Promise; } export const IAgentLoopService = createDecorator('agentLoopService'); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index cc6ecbcbf..bbbaba35e 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -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; 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(); private readonly toolCallStartedAt = new Map(); private readonly toolCallDupType = new Map(); @@ -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 { + async runTurn(turn: Turn): Promise { 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(LOOP_CONTROL_SECTION)?.maxStepsPerTurn, maxRetryAttempts: this.config.get(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; } diff --git a/packages/agent-core-v2/src/agent/microCompaction/microCompactionService.ts b/packages/agent-core-v2/src/agent/microCompaction/microCompactionService.ts index 1ad25ad99..232035f05 100644 --- a/packages/agent-core-v2/src/agent/microCompaction/microCompactionService.ts +++ b/packages/agent-core-v2/src/agent/microCompaction/microCompactionService.ts @@ -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(); diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 779f630ae..ec9a76949 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -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; diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 9fb03ef32..108839a1b 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -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; } diff --git a/packages/agent-core-v2/src/agent/swarm/index.ts b/packages/agent-core-v2/src/agent/swarm/index.ts index 831d46c42..e5ecb2dbd 100644 --- a/packages/agent-core-v2/src/agent/swarm/index.ts +++ b/packages/agent-core-v2/src/agent/swarm/index.ts @@ -6,3 +6,4 @@ export * from './swarm'; export * from './swarmService'; +export * from './subagentBatch'; diff --git a/packages/agent-core-v2/src/agent/tooldedup/index.ts b/packages/agent-core-v2/src/agent/toolDedupe/index.ts similarity index 100% rename from packages/agent-core-v2/src/agent/tooldedup/index.ts rename to packages/agent-core-v2/src/agent/toolDedupe/index.ts diff --git a/packages/agent-core-v2/src/agent/tooldedup/toolDedupe.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts similarity index 100% rename from packages/agent-core-v2/src/agent/tooldedup/toolDedupe.ts rename to packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts diff --git a/packages/agent-core-v2/src/agent/tooldedup/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts similarity index 96% rename from packages/agent-core-v2/src/agent/tooldedup/toolDedupeService.ts rename to packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index e5399d3e7..84d1fe62b 100644 --- a/packages/agent-core-v2/src/agent/tooldedup/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -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(); }); diff --git a/packages/agent-core-v2/src/agent/turn/turn.ts b/packages/agent-core-v2/src/agent/turn/turn.ts index 04ba16d29..344098e5c 100644 --- a/packages/agent-core-v2/src/agent/turn/turn.ts +++ b/packages/agent-core-v2/src/agent/turn/turn.ts @@ -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; }>; } diff --git a/packages/agent-core-v2/src/agent/turn/turnService.ts b/packages/agent-core-v2/src/agent/turn/turnService.ts index d8d7f322a..6b101d031 100644 --- a/packages/agent-core-v2/src/agent/turn/turnService.ts +++ b/packages/agent-core-v2/src/agent/turn/turnService.ts @@ -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(), - beforeStep: new OrderedHookSlot(), - afterStep: new OrderedHookSlot(), - onContextOverflow: new OrderedHookSlot(), }; 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) { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3815b5790..effede2db 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/test/agentTool/agentToolService.test.ts b/packages/agent-core-v2/test/agentTool/agentToolService.test.ts index 78425c8a8..140d289f6 100644 --- a/packages/agent-core-v2/test/agentTool/agentToolService.test.ts +++ b/packages/agent-core-v2/test/agentTool/agentToolService.test.ts @@ -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])); diff --git a/packages/agent-core-v2/test/background/rpc-events.test.ts b/packages/agent-core-v2/test/background/rpc-events.test.ts index fe889f30c..64ef207b9 100644 --- a/packages/agent-core-v2/test/background/rpc-events.test.ts +++ b/packages/agent-core-v2/test/background/rpc-events.test.ts @@ -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 { + 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(' { 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(' { 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); } }); diff --git a/packages/agent-core-v2/test/contextInjector/manager.test.ts b/packages/agent-core-v2/test/contextInjector/manager.test.ts index 4e45ec83e..402043f5a 100644 --- a/packages/agent-core-v2/test/contextInjector/manager.test.ts +++ b/packages/agent-core-v2/test/contextInjector/manager.test.ts @@ -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; @@ -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); diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index c1313e38a..15903dfd5 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -869,35 +869,43 @@ describe('FullCompaction', () => { it('names truncated compaction responses when retries are exhausted', async () => { vi.useFakeTimers(); + const firstAttemptFinished = deferred(); 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 () => { diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts index 8915e9f4f..1e3f637bc 100644 --- a/packages/agent-core-v2/test/goal/goal.test.ts +++ b/packages/agent-core-v2/test/goal/goal.test.ts @@ -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; @@ -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 { + 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 { + 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 { + 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([]); }); }); -}); diff --git a/packages/agent-core-v2/test/goal/injection.test.ts b/packages/agent-core-v2/test/goal/injection.test.ts index 4321da8fd..715365443 100644 --- a/packages/agent-core-v2/test/goal/injection.test.ts +++ b/packages/agent-core-v2/test/goal/injection.test.ts @@ -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 () => { diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 14d473da1..680ea9f68 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -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( } } +type KaosOverride = + | IKaos + | Kaos + | { readonly cwd?: string; readonly envLayers?: readonly Record[] }; + /** - * 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; - readonly execContext?: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record[] }; + readonly kaos?: KaosOverride; + readonly execContext?: { readonly cwd?: string; readonly envLayers?: readonly Record[] }; readonly agentFs?: ISessionAgentFileSystem | Partial; readonly processRunner?: ISessionProcessRunner | Partial; } /** - * 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, 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 { - // 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); +export function kaosServices(input: IKaos | Kaos): TestAgentServiceOverride { + return sessionService(IKaos, resolveKaosOverride(input)); } -function resolveExecContextOverride( - input: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record[] }, -): IExecContext { - if ('withCwd' in input && 'withEnv' in input) return input as IExecContext; - const partial = input as { readonly cwd?: string; readonly envLayers?: readonly Record[] }; - 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, + }); +} + 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, 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 { + 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 { + await this.session.accessor.get(ISessionMetadata).ready; + } + private async drainWirePersistence(): Promise { for (let i = 0; i < 5; i += 1) { await Promise.resolve(); diff --git a/packages/agent-core-v2/test/harness/index.ts b/packages/agent-core-v2/test/harness/index.ts index 772d36128..8977ad19f 100644 --- a/packages/agent-core-v2/test/harness/index.ts +++ b/packages/agent-core-v2/test/harness/index.ts @@ -4,6 +4,7 @@ export { agentServices, backgroundServices, configServices, + createCommandKaos, createCommandRunner, createTestAgent, appService, @@ -15,6 +16,7 @@ export { goalServices, homeDirServices, InMemoryWireRecordPersistence, + kaosServices, llmGenerateServices, logServices, mcpServices, diff --git a/packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts b/packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts index 556708388..a344cc8fb 100644 --- a/packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts +++ b/packages/agent-core-v2/test/promptLegacy/promptLegacyService.test.ts @@ -85,8 +85,6 @@ function createHarness(): Harness { hooks: { onLaunched: { run: async () => {} }, onEnded: { run: async () => {} }, - beforeStep: { run: async () => {} }, - afterStep: { run: async () => {} }, }, } as unknown as IAgentTurnService; diff --git a/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts b/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts index 2474cd29b..c387c324a 100644 --- a/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts +++ b/packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts @@ -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); diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts index 6e9c3ad26..c719ee47d 100644 --- a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts +++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts @@ -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 { - 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[] = [], -): 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, - envLayers: readonly Record[] = [], + overrides: Partial = {}, ): 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 | undefined, - envLayers: readonly Record[], -): Record | undefined { - if (envLayers.length === 0) return invocationEnv; - const merged: Record = { ...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, - cwd: string = '/workspace', + overrides: Partial = {}, ): 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 }; } diff --git a/packages/agent-core-v2/test/turn/stubs.ts b/packages/agent-core-v2/test/turn/stubs.ts index c3fcaebff..cc0341829 100644 --- a/packages/agent-core-v2/test/turn/stubs.ts +++ b/packages/agent-core-v2/test/turn/stubs.ts @@ -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 diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts index ebbc967b2..01ed27854 100644 --- a/packages/agent-core-v2/test/turn/turn.test.ts +++ b/packages/agent-core-v2/test/turn/turn.test.ts @@ -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, diff --git a/packages/server-v2/src/transport/errors.ts b/packages/server-v2/src/transport/errors.ts index dd53a847c..9d0331b97 100644 --- a/packages/server-v2/src/transport/errors.ts +++ b/packages/server-v2/src/transport/errors.ts @@ -34,6 +34,12 @@ const KIMI_TO_PROTOCOL: Record = { [ErrorCodes.PROMPT_NOT_FOUND]: ErrorCode.PROMPT_NOT_FOUND, [ErrorCodes.SESSION_BUSY]: ErrorCode.SESSION_BUSY, [ErrorCodes.PROMPT_ALREADY_COMPLETED]: ErrorCode.PROMPT_ALREADY_COMPLETED, + [ErrorCodes.GOAL_ALREADY_EXISTS]: ErrorCode.GOAL_ALREADY_EXISTS, + [ErrorCodes.GOAL_NOT_FOUND]: ErrorCode.GOAL_NOT_FOUND, + [ErrorCodes.GOAL_STATUS_INVALID]: ErrorCode.GOAL_STATUS_INVALID, + [ErrorCodes.GOAL_NOT_RESUMABLE]: ErrorCode.GOAL_NOT_RESUMABLE, + [ErrorCodes.GOAL_OBJECTIVE_EMPTY]: ErrorCode.GOAL_OBJECTIVE_EMPTY, + [ErrorCodes.GOAL_OBJECTIVE_TOO_LONG]: ErrorCode.GOAL_OBJECTIVE_TOO_LONG, }; /** diff --git a/packages/server-v2/test/rpc.test.ts b/packages/server-v2/test/rpc.test.ts index ab7c82baa..0dc87d0aa 100644 --- a/packages/server-v2/test/rpc.test.ts +++ b/packages/server-v2/test/rpc.test.ts @@ -29,6 +29,22 @@ interface SessionMetaWire { archived: boolean; } +interface GoalSnapshotWire { + goalId: string; + objective: string; + completionCriterion?: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + turnsUsed: number; + tokensUsed: number; + wallClockMs: number; + budget: unknown; + terminalReason?: string; +} + +interface GoalToolResultWire { + goal: GoalSnapshotWire | null; +} + describe('server-v2 /api/v2 RPC', () => { let server: RunningServer | undefined; let home: string | undefined; @@ -224,6 +240,77 @@ describe('server-v2 /api/v2 RPC', () => { expect(body.data.isError).not.toBe(true); }); + it('controls goals through goal:* RPC', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const created = await call( + 'POST', + `/api/v2/session/${id}/agent/main/goal:create`, + { objective: 'finish the migration' }, + ); + expect(created.body.code).toBe(0); + expect(created.body.data).toMatchObject({ + objective: 'finish the migration', + status: 'active', + }); + + const read = await call( + 'GET', + `/api/v2/session/${id}/agent/main/goal:get`, + ); + expect(read.body.code).toBe(0); + expect(read.body.data.goal).toMatchObject({ + objective: 'finish the migration', + status: 'active', + }); + + const paused = await call( + 'POST', + `/api/v2/session/${id}/agent/main/goal:pause`, + {}, + ); + expect(paused.body.data.status).toBe('paused'); + + const resumed = await call( + 'POST', + `/api/v2/session/${id}/agent/main/goal:resume`, + {}, + ); + expect(resumed.body.data.status).toBe('active'); + + const cancelled = await call( + 'POST', + `/api/v2/session/${id}/agent/main/goal:cancel`, + {}, + ); + expect(cancelled.body.code).toBe(0); + expect(cancelled.body.data.status).toBe('active'); + + const afterCancel = await call( + 'GET', + `/api/v2/session/${id}/agent/main/goal:get`, + ); + expect(afterCancel.body.data.goal).toBeNull(); + }); + + it('maps goal errors through RPC envelopes', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + await call( + 'POST', + `/api/v2/session/${id}/agent/main/goal:create`, + { objective: 'first' }, + ); + const duplicate = await call( + 'POST', + `/api/v2/session/${id}/agent/main/goal:create`, + { objective: 'second' }, + ); + expect(duplicate.body.code).toBe(40913); + }); + it('lists and installs plugins through plugins:* RPC', async () => { const pluginRoot = await mkdtemp(join(tmpdir(), 'server-v2-plugin-source-')); try {