refactor: unify turn continuation handling

This commit is contained in:
_Kerman 2026-07-04 12:22:14 +08:00
parent f060872ffc
commit cc53ae6dd7
12 changed files with 220 additions and 101 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Before After
Before After

View file

@ -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<string | undefined> {
private async runStop(ctx: TurnAfterStepContext): Promise<string | undefined> {
ctx.signal.throwIfAborted();
if (this.stopHookContinuationUsed) return undefined;

View file

@ -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<void> {

View file

@ -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<TurnResult>;
}

View file

@ -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 = '<system>ERROR: Tool execution failed.</system>';
@ -44,7 +48,6 @@ export class AgentLoopService implements IAgentLoopService {
beforeStep: new OrderedHookSlot(),
afterStep: new OrderedHookSlot(),
onContextOverflow: new OrderedHookSlot(),
onWillStop: new OrderedHookSlot<TurnWillStopContext>(),
};
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<LoopControl>(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,
};
}

View file

@ -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();
});
}

View file

@ -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();

View file

@ -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[] = [];

View file

@ -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<boolean> {
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({

View file

@ -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();

View file

@ -53,7 +53,6 @@ function makeAgentLoopHookSlots(): IAgentLoopService['hooks'] {
'beforeStep',
'afterStep',
'onContextOverflow',
'onWillStop',
]) as IAgentLoopService['hooks'];
}