refactor: generalize loop error recovery hook

This commit is contained in:
_Kerman 2026-07-04 13:49:22 +08:00
parent 58516f4900
commit 4c2c3956fe
8 changed files with 104 additions and 36 deletions

View file

@ -352,7 +352,7 @@ contextMemory ..> wireRecord #16A085 : context.splice
cron ..> wireRecord #16A085 : cron.add / delete / cursor
todo ..> wireRecord #16A085 : todo.set
fullCompaction ..> wireRecord #16A085 : full_compaction.begin/cancel/complete
fullCompaction ..> loop #16A085 : hooks.onContextOverflow
fullCompaction ..> loop #16A085 : hooks.onError
permissionRules ..> wireRecord #16A085 : permission.rules.add / record_approval_result
skill ..> wireRecord #16A085 : skill.activate
goal ..> wireRecord #16A085 : goal.create/update/clear

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Before After
Before After

View file

@ -14,8 +14,8 @@ import {
sleepForRetry,
type LLMRequestFinish,
} from '#/agent/llmRequester';
import { IAgentLoopService, type TurnContextOverflowContext } from '#/agent/loop';
import { isAbortError } from '#/agent/loop/errors';
import { IAgentLoopService, type TurnErrorContext } from '#/agent/loop';
import { isAbortError, isContextOverflowError } from '#/agent/loop/errors';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentRecordService } from '#/agent/record';
import { IAgentTurnService } from '#/agent/turn';
@ -141,8 +141,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
}),
);
this._register(
loopService.hooks.onContextOverflow.register('full-compaction', async (ctx, next) => {
await this.onContextOverflow(ctx, next);
loopService.hooks.onError.register('full-compaction', async (ctx, next) => {
await this.onLoopError(ctx, next);
}),
);
this._register(
@ -241,10 +241,14 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
this.consecutiveOverflowCompactions = 0;
}
private async onContextOverflow(
context: TurnContextOverflowContext,
private async onLoopError(
context: TurnErrorContext,
next: () => Promise<void>,
): Promise<void> {
if (!isContextOverflowError(context.error)) {
await next();
return;
}
this.consecutiveOverflowCompactions += 1;
const maxAttempts = this.strategy.maxOverflowCompactionAttempts;
if (this.consecutiveOverflowCompactions > maxAttempts) {
@ -259,7 +263,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
await next();
return;
}
context.handled = true;
context.retry = true;
await this.block(context.signal, context.turnId);
}
@ -615,7 +619,7 @@ function isTodoItem(value: unknown): value is TodoItem {
export { AgentFullCompactionService as FullCompaction };
// Construct eagerly (not delayed): the service registers turn and loop hooks
// (onLaunched / beforeStep / afterStep / onContextOverflow) that drive auto
// (onLaunched / beforeStep / afterStep / onError) that drive auto
// compaction. With delayed instantiation the eager `accessor.get(IAgentFullCompactionService)`
// only realizes a proxy, so the hooks would not register until the first RPC —
// after turns have already run without the auto-compaction gate.

View file

@ -2,7 +2,8 @@
* `loop` domain error codes and loop-local error helpers.
*/
import { KimiError, registerErrorDomain, type ErrorDomain } from '#/_base/errors';
import { KimiError, isKimiError, registerErrorDomain, type ErrorDomain } from '#/_base/errors';
import { APIContextOverflowError } from '#/app/llmProtocol';
export const LoopErrors = {
codes: {
@ -42,6 +43,13 @@ export function isMaxStepsExceededError(error: unknown): boolean {
return error instanceof KimiError && error.code === LoopErrors.codes.LOOP_MAX_STEPS_EXCEEDED;
}
export function isContextOverflowError(error: unknown): boolean {
return (
error instanceof APIContextOverflowError ||
(isKimiError(error) && error.code === LoopErrors.codes.CONTEXT_OVERFLOW)
);
}
export function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
return err.name === 'AbortError';

View file

@ -16,11 +16,17 @@ export interface TurnAfterStepContext extends TurnBeforeStepContext {
continue: boolean;
}
export interface TurnContextOverflowContext {
export interface TurnErrorContext {
readonly turnId: number;
/** The currently executing step, or undefined for turn-level failures. */
readonly step?: number;
readonly signal: AbortSignal;
readonly error: unknown;
handled: boolean;
/**
* Set to true only after a handler has changed state enough for the loop to
* retry. Handlers that do not recognize the error must call next().
*/
retry: boolean;
}
export interface RunTurnOptions {
@ -34,7 +40,7 @@ export interface IAgentLoopService {
readonly hooks: Hooks<{
beforeStep: TurnBeforeStepContext;
afterStep: TurnAfterStepContext;
onContextOverflow: TurnContextOverflowContext;
onError: TurnErrorContext;
}>;
runTurn(turnId: number, options?: RunTurnOptions): Promise<TurnResult>;
}

View file

@ -9,7 +9,6 @@ import type { ToolResult } from '#/agent/tool';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { IConfigService } from '#/app/config';
import {
APIContextOverflowError,
createToolMessage,
type ContentPart,
type FinishReason,
@ -17,7 +16,7 @@ import {
type TokenUsage,
} from '#/app/llmProtocol';
import { ILogService } from '#/app/log';
import { ErrorCodes, KimiError, isKimiError } from '#/errors';
import { ErrorCodes, KimiError } from '#/errors';
import { OrderedHookSlot } from '#/hooks';
import { IAgentContextMemoryService, newMessageId, type ContextMessage } from '../contextMemory';
@ -47,7 +46,7 @@ export class AgentLoopService implements IAgentLoopService {
readonly hooks: IAgentLoopService['hooks'] = {
beforeStep: new OrderedHookSlot(),
afterStep: new OrderedHookSlot(),
onContextOverflow: new OrderedHookSlot(),
onError: new OrderedHookSlot(),
};
constructor(
@ -119,17 +118,15 @@ export class AgentLoopService implements IAgentLoopService {
const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error';
this.emitStepInterrupted(turnId, activeStep, reason, errorMessage(error));
if (isContextOverflowError(error)) {
const context = { turnId, signal, error, handled: false };
try {
await this.hooks.onContextOverflow.run(context);
} catch (hookError) {
return { reason: 'failed', error: hookError, steps };
}
if (context.handled) {
activeStep = undefined;
continue;
}
const context = { turnId, step: activeStep, signal, error, retry: false };
try {
await this.hooks.onError.run(context);
} catch (hookError) {
return { reason: 'failed', error: hookError, steps };
}
if (context.retry) {
activeStep = undefined;
continue;
}
return { reason: 'failed', error, steps };
}
@ -359,13 +356,6 @@ export class AgentLoopService implements IAgentLoopService {
}
}
function isContextOverflowError(error: unknown): boolean {
return (
error instanceof APIContextOverflowError ||
(isKimiError(error) && error.code === ErrorCodes.CONTEXT_OVERFLOW)
);
}
function toolResultOutputForModel(result: ToolResult): string | ContentPart[] {
const output = result.output;
if (typeof output === 'string') {

View file

@ -98,6 +98,66 @@ describe('Agent loop', () => {
});
});
it('lets onError recover a non-context loop error by retrying', async () => {
profile.update({ activeToolNames: [] });
const seenErrors: Array<{ readonly step: number | undefined; readonly message: string }> = [];
loop.hooks.onError.register('test-recover-generate-error', async (hookCtx, next) => {
seenErrors.push({
step: hookCtx.step,
message: hookCtx.error instanceof Error ? hookCtx.error.message : String(hookCtx.error),
});
if (seenErrors.length === 1) {
ctx.mockNextResponse({ type: 'text', text: 'Recovered.' });
hookCtx.retry = true;
return;
}
await next();
});
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
await ctx.untilTurnEnd();
expect(seenErrors).toEqual([
{ step: 1, message: 'Unexpected generate call #1' },
]);
expect(ctx.allEvents).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'completed' }),
}),
);
});
it('does not run onError for aborted turns', async () => {
let called = false;
loop.hooks.onError.register('test-abort-not-recoverable', async (_hookCtx, next) => {
called = true;
await next();
});
const controller = new AbortController();
controller.abort(new Error('stop'));
const result = await loop.runTurn(0, { signal: controller.signal });
expect(result.reason).toBe('cancelled');
expect(called).toBe(false);
});
it('fails with the onError handler error when recovery throws', async () => {
const recoveryError = new Error('recovery failed');
loop.hooks.onError.register('test-throw-recovery-error', async () => {
throw recoveryError;
});
const result = await loop.runTurn(0);
expect(result.reason).toBe('failed');
if (result.reason === 'failed') {
expect(result.error).toBe(recoveryError);
}
});
it('runs an agent turn through registered tool approval and execution', async () => {
const lookupCall: ToolCall = {
type: 'function',

View file

@ -52,7 +52,7 @@ function makeAgentLoopHookSlots(): IAgentLoopService['hooks'] {
return createHooks([
'beforeStep',
'afterStep',
'onContextOverflow',
'onError',
]) as IAgentLoopService['hooks'];
}