diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index c93b6d10a..08070aef2 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -300,6 +300,7 @@ microCompaction --> loop #34495E externalHooks --> config #34495E externalHooks --> bootstrap #34495E externalHooks --> plugin #34495E +externalHooks --> contextMemory #34495E todo --> agent_lifecycle #34495E usage --> wireRecord #34495E usage --> record #34495E diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg index 6e9632437..8fdae5a57 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)bootstrapAppIBootstrapServicelogAppILogServiceILogWriterServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmentauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceglobalSkillCatalogAppIGlobalSkillCatalogISkillCatalogStoresessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextsessionLogSessionISessionLogServiceILogWriterServicesessionSkillCatalogSessionISessionSkillCatalogexecContextSessionIExecContext (seed)agentFsSessionIAgentFileSystemIFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)wireRecordAgentIAgentWireRecordService (event hub)recordAgentIAgentRecordServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStoreAgentIAgentToolStoreServicetoolDedupAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServicetodoListAgentIAgentTodoListServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentIAgentFileToolsServiceshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmenteventSinkonDidChangeConfigurationcontext.splicehooks.onSplicedcontext_size.measuredconfig.update / tools.set_active_toolshooks.onSplicedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultplan_mode.enter/cancel/exitgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_tooltask.started/terminatedhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exitfull_compaction.begin/cancel/completehooks.onContextOverflowmicro_compaction.apply / full_compaction.completeusage.recordhook.result / goal.updatedhooks.onEndedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookSubagentStart/Stop (mirrorAgentRun)hooks.onResumeEndedScope = 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)bootstrapAppIBootstrapServicelogAppILogServiceILogWriterServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceglobalSkillCatalogAppIGlobalSkillCatalogISkillCatalogStoresessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextsessionLogSessionISessionLogServiceILogWriterServicesessionSkillCatalogSessionISessionSkillCatalogexecContextSessionIExecContext (seed)agentFsSessionIAgentFileSystemIFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)recordAgentIAgentRecordServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStoreAgentIAgentToolStoreServicetoolDedupAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolFileEditServiceshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmenteventSinkonDidChangeConfigurationcontext.splicehooks.onSplicedcontext_size.measuredconfig.update / tools.set_active_toolshooks.onSplicedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultplan_mode.enter/cancel/exitgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_tooltask.started/terminatedhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exitfull_compaction.begin/cancel/completehooks.onContextOverflowmicro_compaction.apply / full_compaction.completeusage.recordtodo.sethook.result / goal.updatedhooks.onEndedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookSubagentStart/Stop (mirrorAgentRun)hooks.onResumeEndedScope = 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/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts index 94330758e..4578316f2 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -5,7 +5,8 @@ * Listens to hook slots owned by the agent behavior/lifecycle domains * (`toolExecutor`, `permissionGate`, `turn`, `loop`, `fullCompaction`, and * `task`) and translates those minimal contexts into the configured external - * HookEngine events. The `SubagentStart` / `SubagentStop` pair is the one + * HookEngine events. Appends Stop hook continuation prompts through + * `contextMemory`. The `SubagentStart` / `SubagentStop` pair is the one * exception: the `agentLifecycle` tool wrapper has no hook service of its own, * so `mirrorAgentRun` invokes `runAgentTaskStart` / `notifyAgentTaskStop` on * this service directly. @@ -17,12 +18,13 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { isUserCancellation } from '#/_base/utils/abort'; import { isPlainRecord } from '#/_base/utils/canonical-args'; import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/task'; +import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentFullCompactionService, type FullCompactionDidCompactContext, type FullCompactionWillCompactContext, } from '#/agent/fullCompaction'; -import { IAgentLoopService, type TurnWillStopContext } from '#/agent/loop'; +import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop'; import { IAgentPermissionGate, type PermissionApprovalResultContext, @@ -81,6 +83,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter constructor( private readonly options: ExternalHooksServiceOptions = {}, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IInstantiationService private readonly instantiation: IInstantiationService, @IConfigService private readonly config: IConfigService, @IBootstrapService private readonly bootstrap: IBootstrapService, @@ -198,14 +201,27 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter private registerLoopHooks(loop: IAgentLoopService): void { this._register( - loop.hooks.onWillStop.register('externalHooks', async (ctx, next) => { + loop.hooks.afterStep.register('externalHooks', async (ctx, next) => { + await next(); + if ( + ctx.stopReason === 'tool_calls' || + ctx.stopReason === 'filtered' || + ctx.continue + ) { + return; + } const reason = await this.runStop(ctx); if (reason !== undefined) { this.stopHookContinuationUsed = true; - ctx.continuationPrompt = reason; + this.context.splice(this.context.get().length, 0, [{ + role: 'user', + content: [{ type: 'text', text: reason }], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'stop_hook' }, + }]); + ctx.continue = true; return; } - await next(); }), ); } @@ -326,7 +342,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter ); } - private async runStop(ctx: TurnWillStopContext): Promise { + private async runStop(ctx: TurnAfterStepContext): Promise { ctx.signal.throwIfAborted(); if (this.stopHookContinuationUsed) return undefined; diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index eb73b6216..e46fd9076 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -212,8 +212,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ); this._register( loopService.hooks.afterStep.register('goal-outcome-continuation', async (ctx, next) => { - await next(); this.handleAfterStep(ctx); + await next(); }), ); this._register( @@ -467,7 +467,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { if (this.goalOutcomeContinuationTurns.has(ctx.turnId)) return; if (!isGoalOutcomeReminder(this.context.get().at(-1))) return; this.goalOutcomeContinuationTurns.add(ctx.turnId); - ctx.continueTurn = true; + ctx.continue = true; } private async handleTurnEnded(ctx: TurnEndedContext): Promise { diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 15c41abf7..570baf6e3 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -1,5 +1,5 @@ import { createDecorator } from '#/_base/di'; -import type { TokenUsage } from '#/app/llmProtocol'; +import type { FinishReason, TokenUsage } from '#/app/llmProtocol'; import type { Hooks } from '#/hooks'; import type { TurnResult } from './types'; @@ -12,7 +12,8 @@ export interface TurnBeforeStepContext { export interface TurnAfterStepContext extends TurnBeforeStepContext { readonly usage: TokenUsage; - continueTurn: boolean; + readonly stopReason: FinishReason; + continue: boolean; } export interface TurnContextOverflowContext { @@ -22,11 +23,6 @@ export interface TurnContextOverflowContext { handled: boolean; } -export interface TurnWillStopContext { - readonly signal: AbortSignal; - continuationPrompt?: string; -} - export interface RunTurnOptions { readonly signal?: AbortSignal; /** Fires on the first model response event for a step, or at step completion. */ @@ -39,7 +35,6 @@ export interface IAgentLoopService { beforeStep: TurnBeforeStepContext; afterStep: TurnAfterStepContext; onContextOverflow: TurnContextOverflowContext; - onWillStop: TurnWillStopContext; }>; runTurn(turnId: number, options?: RunTurnOptions): Promise; } diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index fd0574f62..7b1920bea 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -28,7 +28,11 @@ import { isAbortError, isMaxStepsExceededError, } from './errors'; -import { IAgentLoopService, type RunTurnOptions, type TurnWillStopContext } from './loop'; +import { + IAgentLoopService, + type RunTurnOptions, + type TurnAfterStepContext, +} from './loop'; import type { LoopInterruptReason, TurnResult } from './types'; const TOOL_ERROR_STATUS = 'ERROR: Tool execution failed.'; @@ -44,7 +48,6 @@ export class AgentLoopService implements IAgentLoopService { beforeStep: new OrderedHookSlot(), afterStep: new OrderedHookSlot(), onContextOverflow: new OrderedHookSlot(), - onWillStop: new OrderedHookSlot(), }; constructor( @@ -64,62 +67,45 @@ export class AgentLoopService implements IAgentLoopService { const signal = options.signal ?? new AbortController().signal; this.profile.resolveModelContext(); + let steps = 0; + let activeStep: number | undefined; while (true) { - let steps = 0; - let activeStep: number | undefined; - try { + activeStep = undefined; + signal.throwIfAborted(); + const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; - while (true) { - signal.throwIfAborted(); - if (maxSteps !== undefined && maxSteps > 0 && steps >= maxSteps) { - throw createMaxStepsExceededError(maxSteps); - } - - steps += 1; - activeStep = steps; - const stepResult = await this.executeLoopStep( - turnId, - signal, - steps, - options.onStepStarted, - ); - activeStep = undefined; - - if (stepResult.stopReason === 'filtered') { - throw new KimiError( - ErrorCodes.PROVIDER_FILTERED, - 'Provider safety policy blocked the response.', - { - name: 'ProviderFilteredError', - details: { finishReason: 'filtered' }, - }, - ); - } - - if (stepResult.stopReason === 'tool_calls') { - continue; - } - - if (stepResult.continueTurn) { - continue; - } - - const context: TurnWillStopContext = { signal }; - await this.hooks.onWillStop.run(context); - if (context.continuationPrompt !== undefined) { - this.append({ - role: 'user', - content: [{ type: 'text', text: context.continuationPrompt }], - toolCalls: [], - origin: { kind: 'system_trigger', name: 'stop_hook' }, - }); - continue; - } - - break; + if (maxSteps !== undefined && maxSteps > 0 && steps >= maxSteps) { + throw createMaxStepsExceededError(maxSteps); } + + steps += 1; + activeStep = steps; + const stepResult = await this.executeLoopStep( + turnId, + signal, + steps, + options.onStepStarted, + ); + activeStep = undefined; + + if (stepResult.stopReason === 'filtered') { + throw new KimiError( + ErrorCodes.PROVIDER_FILTERED, + 'Provider safety policy blocked the response.', + { + name: 'ProviderFilteredError', + details: { finishReason: 'filtered' }, + }, + ); + } + + if (stepResult.stopReason === 'tool_calls' || stepResult.continue) { + continue; + } + + return { reason: 'completed', steps }; } catch (error) { if (isAbortError(error) || signal.aborted) { this.emitStepInterrupted(turnId, activeStep, 'aborted'); @@ -140,12 +126,13 @@ export class AgentLoopService implements IAgentLoopService { } catch (hookError) { return { reason: 'failed', error: hookError, steps }; } - if (context.handled) continue; + if (context.handled) { + activeStep = undefined; + continue; + } } return { reason: 'failed', error, steps }; } - - return { reason: 'completed', steps }; } } @@ -156,7 +143,7 @@ export class AgentLoopService implements IAgentLoopService { onStepStarted: ((step: number) => void) | undefined, ): Promise<{ readonly stopReason: FinishReason; - readonly continueTurn: boolean; + readonly continue: boolean; }> { await this.hooks.beforeStep.run({ turnId, step: currentStep, signal }); signal.throwIfAborted(); @@ -237,16 +224,24 @@ export class AgentLoopService implements IAgentLoopService { markStepStarted(); this.emitStepCompleted(turnId, currentStep, stepUuid, usage, finishReason, response); - const afterStepContext = { turnId, step: currentStep, signal, usage, continueTurn: false }; + const afterStepContext: TurnAfterStepContext = { + turnId, + step: currentStep, + signal, + usage, + stopReason: finishReason, + continue: false, + }; try { await this.hooks.afterStep.run(afterStepContext); - } catch { + } catch (error) { + if (isAbortError(error) || signal.aborted) throw error; // afterStep hook failures must not affect the turn result. } return { stopReason: finishReason, - continueTurn: finishReason !== 'tool_calls' && afterStepContext.continueTurn, + continue: afterStepContext.continue, }; } diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index ec9a76949..b4275221a 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -29,10 +29,10 @@ export class AgentPromptService implements IAgentPromptService { await next(); }); loopService.hooks.afterStep.register('prompt-service-steer', async (ctx, next) => { - await next(); if (this.flushSteerQueue()) { - ctx.continueTurn = true; + ctx.continue = true; } + await next(); }); } diff --git a/packages/agent-core-v2/test/externalHooks/integration.test.ts b/packages/agent-core-v2/test/externalHooks/integration.test.ts index e4c48d255..fdde0217b 100644 --- a/packages/agent-core-v2/test/externalHooks/integration.test.ts +++ b/packages/agent-core-v2/test/externalHooks/integration.test.ts @@ -7,6 +7,8 @@ import { type TestInstantiationService, } from '#/_base/di/test'; import { Event } from '#/_base/event'; +import { emptyUsage } from '#/app/llmProtocol/kosong'; +import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory'; import { IAgentTaskService } from '#/agent/task'; import { AgentExternalHooksService, @@ -19,7 +21,7 @@ import { hooksToToml, } from '#/agent/externalHooks/configSection'; import { IAgentFullCompactionService } from '#/agent/fullCompaction'; -import { IAgentLoopService, type TurnWillStopContext } from '#/agent/loop'; +import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop'; import { IAgentPermissionGate } from '#/agent/permissionGate'; import { IAgentToolExecutorService } from '#/agent/toolExecutor'; import { IAgentTurnService, type Turn } from '#/agent/turn'; @@ -55,6 +57,32 @@ function makeTurn(id: number): Turn { }; } +function makeAfterStep(signal: AbortSignal): TurnAfterStepContext { + return { + turnId: 0, + step: 1, + signal, + usage: emptyUsage(), + stopReason: 'completed', + continue: false, + }; +} + +function stubContextMemory(): IAgentContextMemoryService & { + readonly messages: readonly ContextMessage[]; +} { + const messages: ContextMessage[] = []; + return { + _serviceBrand: undefined, + get: () => [...messages], + splice: (start, deleteCount, inserted) => { + messages.splice(start, deleteCount, ...inserted); + }, + hooks: createHooks(['onSpliced']) as IAgentContextMemoryService['hooks'], + messages, + }; +} + describe('HookEngine integration', () => { it('blocks a dangerous Bash command and allows a safe one via a PreToolUse script hook', async () => { const engine = new HookEngine([ @@ -110,6 +138,7 @@ describe('HookEngine integration', () => { try { const loop = stubLoopWithHooks(); const turnService = stubTurnWithHooks(); + const context = stubContextMemory(); const stopInputs: unknown[] = []; const hookEngine = { trigger: async () => [], @@ -126,6 +155,7 @@ describe('HookEngine integration', () => { reg.defineInstance(IBootstrapService, stubBootstrap()); reg.definePartialInstance(IConfigService, {}); reg.definePartialInstance(IPluginService, {}); + reg.defineInstance(IAgentContextMemoryService, context); reg.defineInstance(IAgentLoopService, loop); reg.defineInstance(IAgentTurnService, turnService); reg.defineInstance(IAgentToolExecutorService, stubToolExecutor()); @@ -147,13 +177,29 @@ describe('HookEngine integration', () => { ix.get(IAgentExternalHooksService); const signal = new AbortController().signal; - const first: TurnWillStopContext = { signal }; - await loop.hooks.onWillStop.run(first); - expect(first.continuationPrompt).toBe('continue 1'); + const filtered: TurnAfterStepContext = { + ...makeAfterStep(signal), + stopReason: 'filtered', + }; + await loop.hooks.afterStep.run(filtered); + expect(filtered.continue).toBe(false); + expect(stopInputs).toEqual([]); + expect(context.messages).toEqual([]); - const second: TurnWillStopContext = { signal }; - await loop.hooks.onWillStop.run(second); - expect(second.continuationPrompt).toBeUndefined(); + const first = makeAfterStep(signal); + await loop.hooks.afterStep.run(first); + expect(first.continue).toBe(true); + expect(context.messages.at(-1)).toEqual( + expect.objectContaining({ + role: 'user', + content: [{ type: 'text', text: 'continue 1' }], + origin: { kind: 'system_trigger', name: 'stop_hook' }, + }), + ); + + const second = makeAfterStep(signal); + await loop.hooks.afterStep.run(second); + expect(second.continue).toBe(false); expect(stopInputs).toEqual([{ stopHookActive: false }]); await turnService.hooks.onEnded.run({ @@ -161,9 +207,16 @@ describe('HookEngine integration', () => { result: { reason: 'completed' }, }); - const nextTurn: TurnWillStopContext = { signal }; - await loop.hooks.onWillStop.run(nextTurn); - expect(nextTurn.continuationPrompt).toBe('continue 2'); + const nextTurn = makeAfterStep(signal); + await loop.hooks.afterStep.run(nextTurn); + expect(nextTurn.continue).toBe(true); + expect(context.messages.at(-1)).toEqual( + expect.objectContaining({ + role: 'user', + content: [{ type: 'text', text: 'continue 2' }], + origin: { kind: 'system_trigger', name: 'stop_hook' }, + }), + ); expect(stopInputs).toEqual([{ stopHookActive: false }, { stopHookActive: false }]); } finally { ix?.dispose(); diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index c8b20e14b..e5fb49430 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -1703,6 +1703,54 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('does not reset the step budget after provider context overflow compaction', async () => { + let callCount = 0; + const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + callCount += 1; + if (callCount === 1) { + throw new APIContextOverflowError(400, 'Context length exceeded', 'req-budget-overflow'); + } + if (callCount === 2) { + return textResult('Budget compacted summary.'); + } + await callbacks?.onMessagePart?.({ type: 'text', text: 'Should not run.' }); + return textResult('Should not run.'); + }; + const ctx = testAgent({ + generate, + initialConfig: { + providers: {}, + loopControl: { maxStepsPerTurn: 1 }, + }, + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after provider overflow' }] }); + const events = await ctx.untilTurnEnd(); + + expect(callCount).toBe(2); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ + reason: 'failed', + error: expect.objectContaining({ + code: 'loop.max_steps_exceeded', + details: expect.objectContaining({ + maxSteps: 1, + }), + }), + }), + }), + ); + await ctx.expectResumeMatches(); + }); + it('preserves thinking effort when compacting after provider context overflow', async () => { let callCount = 0; const records: TelemetryRecord[] = []; diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts index f0a94920f..64d9e9c47 100644 --- a/packages/agent-core-v2/test/goal/goal.test.ts +++ b/packages/agent-core-v2/test/goal/goal.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentEventSinkService } from '#/agent/eventSink'; import { IAgentGoalService, type AgentGoalService } from '#/agent/goal'; -import { IAgentLoopService } from '#/agent/loop'; +import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop'; import { IAgentRecordService } from '#/agent/record'; import { IAgentTurnService, type Turn, type TurnResult } from '#/agent/turn'; import type { PersistedWireRecord, WireRecord } from '#/agent/wireRecord'; @@ -63,16 +63,17 @@ async function runGoalStep(loopService: IAgentLoopService, turn: Turn): Promise< step: 1, signal: turn.abortController.signal, }; - const afterStep = { + const afterStep: TurnAfterStepContext = { turnId: turn.id, step: 1, signal: turn.abortController.signal, usage: zeroUsage, - continueTurn: false, + stopReason: 'completed' as const, + continue: false, }; await loopService.hooks.beforeStep.run(step); await loopService.hooks.afterStep.run(afterStep); - return afterStep.continueTurn; + return afterStep.continue; } async function runStepUsageHooks( @@ -81,12 +82,13 @@ async function runStepUsageHooks( turn: Turn, usage: TokenUsage, ): Promise { - const afterStep = { + const afterStep: TurnAfterStepContext = { turnId: turn.id, step: 1, signal: turn.abortController.signal, usage, - continueTurn: false, + stopReason: 'completed' as const, + continue: false, }; await loopService.hooks.afterStep.run(afterStep); return goals.getGoal().goal?.budget.overBudget === true; @@ -664,12 +666,13 @@ describe('AgentGoalService core workflow hooks', () => { step: 1, signal: turn.abortController.signal, }; - const afterStep = { + const afterStep: TurnAfterStepContext = { turnId: turn.id, step: 1, signal: turn.abortController.signal, usage: zeroUsage, - continueTurn: false, + stopReason: 'completed' as const, + continue: false, }; await loopService.hooks.beforeStep.run(step); @@ -677,7 +680,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.afterStep.run(afterStep); await endTurn(turnService, turn); - expect(afterStep.continueTurn).toBe(true); + expect(afterStep.continue).toBe(true); expect(goals.getGoal().goal).toBeNull(); expect(turnService.launches).toEqual([]); expect(context.get().at(-1)?.origin).toEqual({ diff --git a/packages/agent-core-v2/test/loop/basic.test.ts b/packages/agent-core-v2/test/loop/basic.test.ts index 3a3287aab..d5fcd5806 100644 --- a/packages/agent-core-v2/test/loop/basic.test.ts +++ b/packages/agent-core-v2/test/loop/basic.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IAgentProfileService } from '#/index'; import { IAgentLLMRequesterService, type LLMStreamTiming } from '#/agent/llmRequester'; +import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentLoopService } from '#/agent/loop'; import type { ExecutableTool } from '#/agent/tool'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; @@ -183,10 +184,18 @@ describe('Agent loop', () => { it('lets non-external stop hooks continue a turn more than once', async () => { profile.update({ activeToolNames: [] }); let continuations = 0; - loop.hooks.onWillStop.register('test-repeat-stop-continuation', async (hookCtx, next) => { + loop.hooks.afterStep.register('test-repeat-stop-continuation', async (hookCtx, next) => { if (continuations < 2) { continuations += 1; - hookCtx.continuationPrompt = `continue ${continuations}`; + const prompt = `continue ${continuations}`; + const context = ctx.get(IAgentContextMemoryService); + context.splice(context.get().length, 0, [{ + role: 'user', + content: [{ type: 'text', text: prompt }], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'stop_hook' }, + }]); + hookCtx.continue = true; return; } await next(); diff --git a/packages/agent-core-v2/test/turn/stubs.ts b/packages/agent-core-v2/test/turn/stubs.ts index ad8a80a99..5d953251d 100644 --- a/packages/agent-core-v2/test/turn/stubs.ts +++ b/packages/agent-core-v2/test/turn/stubs.ts @@ -53,7 +53,6 @@ function makeAgentLoopHookSlots(): IAgentLoopService['hooks'] { 'beforeStep', 'afterStep', 'onContextOverflow', - 'onWillStop', ]) as IAgentLoopService['hooks']; }