fix: dedupe turn

This commit is contained in:
_Kerman 2026-06-25 17:51:55 +08:00
parent 886faeb4b9
commit 3cd3edb47c
7 changed files with 360 additions and 536 deletions

View file

@ -1,9 +1,6 @@
/**
* `turn` domain barrel re-exports the turn contract (`turn`) and its scoped
* services (`turnService`, `loopRunner`). Importing this barrel registers the
* `ITurnService` and `ILoopRunner` bindings into the scope registry.
* `turnRunner` domain barrel - re-exports the turnRunner service contract and implementation.
*/
export * from './turn';
export * from './turnService';
export * from './loopRunner';

View file

@ -1,19 +0,0 @@
/**
* `turn` domain (L4) `ILoopRunner` implementation.
*
* Runs the per-turn loop. Bound at Turn scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ILoopRunner } from './turn';
export class LoopRunner implements ILoopRunner {
declare readonly _serviceBrand: undefined;
run(): Promise<void> {
return Promise.resolve();
}
}
registerScopedService(LifecycleScope.Turn, ILoopRunner, LoopRunner, InstantiationType.Delayed, 'turn');

View file

@ -1,61 +1,51 @@
/**
* `turn` domain (L4) drives the turn lifecycle.
*
* Defines the public contract of a turn: the `ITurnService` used by upper layers
* to start, steer, retry, and cancel a turn and to observe its events, the
* per-turn `ITurnContext`, and the `ILoopRunner` that runs the turn loop.
* `ITurnService` is Agent-scoped; `ILoopRunner` is Turn-scoped.
*/
import { createDecorator } from "#/_base/di";
import type { ContextMessage, PromptOrigin } from '#/context';
import type { Event } from '#/_base/event';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Hooks } from '#/hooks';
export interface TurnStartEvent {
readonly turnId: string;
}
export interface TurnToolEvent {
readonly turnId: string;
readonly toolCallId: string;
readonly toolName: string;
}
export interface TurnStepEvent {
readonly turnId: string;
readonly step: number;
}
export interface TurnEndEvent {
readonly turnId: string;
readonly reason: string;
export interface TurnResult {
readonly reason: 'completed' | 'cancelled' | 'failed' | 'filtered';
readonly error?: unknown;
}
export interface ITurnService {
readonly _serviceBrand: undefined;
readonly onWillStartTurn: Event<TurnStartEvent>;
readonly onWillExecuteTool: Event<TurnToolEvent>;
readonly onDidFinalizeTool: Event<TurnToolEvent>;
readonly onDidEndStep: Event<TurnStepEvent>;
readonly onDidEndTurn: Event<TurnEndEvent>;
readonly hasActiveTurn: boolean;
readonly currentId: string | undefined;
prompt(input: string): Promise<void>;
steer(content: string, origin?: string): void;
retry(): Promise<void>;
cancel(reason?: string): void;
export interface Turn {
readonly id: number;
readonly abortController: AbortController;
readonly ready: Promise<void>;
readonly result: Promise<TurnResult>;
}
export const ITurnService: ServiceIdentifier<ITurnService> =
createDecorator<ITurnService>('turnService');
export interface ITurnContext {
readonly turnId: string;
export interface TurnStepContext {
readonly turn: Turn;
continueTurn: boolean;
}
export const ITurnContext: ServiceIdentifier<ITurnContext> =
createDecorator<ITurnContext>('turnContext');
export interface ILoopRunner {
readonly _serviceBrand: undefined;
run(): Promise<void>;
export interface TurnRunContext {
readonly turn: Turn;
readonly origin: PromptOrigin;
readonly promptMessage?: ContextMessage;
result?: TurnResult;
}
export const ILoopRunner: ServiceIdentifier<ILoopRunner> =
createDecorator<ILoopRunner>('loopRunner');
export interface TurnEndedContext {
readonly turn: Turn;
readonly result: TurnResult;
}
export interface ITurnRunner {
launch(origin: PromptOrigin): Turn;
getActiveTurn(): Turn | undefined;
cancel(turnId?: number, reason?: unknown): void;
readonly hooks: Hooks<{
onLaunched: { turn: Turn };
onEnded: TurnEndedContext;
beforeStep: TurnStepContext;
afterStep: TurnStepContext;
}>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ITurnRunner = createDecorator<ITurnRunner>('agentTurnRunnerService');

View file

@ -1,117 +1,324 @@
/**
* `turn` domain (L4) `ITurnService` implementation.
*
* Drives the turn lifecycle and emits its events; runs the turn loop through
* `loopRunner`, drives agent lifecycle through `agent-lifecycle`, reads
* history through `context`, enqueues follow-up through `injection`, drives
* LLM generation through `kosong`, logs through `log`, checks permissions
* through `permission`, reports telemetry through `telemetry`, executes tools
* through `tool`, and checks usage through `usage`. Bound at Agent scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/agent-lifecycle/agentLifecycle';
import { IContextService } from '#/context/context';
import { IInjectionService } from '#/injection/injection';
import { ILLMService } from '#/kosong/kosong';
import { ILogService } from '#/log/log';
import { IPermissionService } from '#/permission/permission';
import { ITelemetryService } from '#/telemetry/telemetry';
import { IToolService } from '#/tool/tool';
import { IUsageService } from '#/usage/usage';
import {
type TurnEndEvent,
type TurnStartEvent,
type TurnStepEvent,
type TurnToolEvent,
ILoopRunner,
ITurnService,
IInstantiationService,
registerSingleton,
SyncDescriptor,
} from "#/_base/di";
import { toKimiErrorPayload, type KimiErrorPayload } from "#/_base/errors";
import { isUserCancellation, userCancellationReason } from "#/_base/utils/abort";
import type { ContextMessage, PromptOrigin } from '#/context';
import { USER_PROMPT_ORIGIN } from '#/context';
import { IContextMemory } from '#/contextMemory/contextMemory';
import { IEventBus } from '#/eventBus/eventBus';
import { IExternalHooksService } from '#/externalHooks/externalHooks';
import { OrderedHookSlot } from '#/hooks';
import { ILoopService } from '#/loop/loop';
import { IPlanService } from '#/plan/plan';
import { ITelemetryService } from '#/telemetry/telemetry';
import { IUsageService } from '#/usage/usage';
import { IWireRecord } from '#/wireRecord/wireRecord';
import type {
Turn,
TurnEndedContext,
TurnResult,
TurnStepContext,
} from './turn';
import { ITurnRunner } from './turn';
let nextTurnId = 0;
export class TurnService extends Disposable implements ITurnService {
declare readonly _serviceBrand: undefined;
private readonly _onWillStartTurn = this._register(new Emitter<TurnStartEvent>());
readonly onWillStartTurn: Event<TurnStartEvent> = this._onWillStartTurn.event;
private readonly _onWillExecuteTool = this._register(new Emitter<TurnToolEvent>());
readonly onWillExecuteTool: Event<TurnToolEvent> = this._onWillExecuteTool.event;
private readonly _onDidFinalizeTool = this._register(new Emitter<TurnToolEvent>());
readonly onDidFinalizeTool: Event<TurnToolEvent> = this._onDidFinalizeTool.event;
private readonly _onDidEndStep = this._register(new Emitter<TurnStepEvent>());
readonly onDidEndStep: Event<TurnStepEvent> = this._onDidEndStep.event;
private readonly _onDidEndTurn = this._register(new Emitter<TurnEndEvent>());
readonly onDidEndTurn: Event<TurnEndEvent> = this._onDidEndTurn.event;
private active: { readonly turnId: string; cancelled: boolean } | undefined;
private readonly steerBuffer: { content: string; origin?: string }[] = [];
constructor(
@IContextService _context: IContextService,
@IToolService _tool: IToolService,
@IPermissionService _permission: IPermissionService,
@ILLMService _llm: ILLMService,
@IInjectionService _injection: IInjectionService,
@IUsageService _usage: IUsageService,
@ITelemetryService _telemetry: ITelemetryService,
@ILogService _log: ILogService,
@IAgentLifecycleService _agentLifecycle: IAgentLifecycleService,
@ILoopRunner private readonly loopRunner: ILoopRunner,
) {
super();
}
get hasActiveTurn(): boolean {
return this.active !== undefined;
}
get currentId(): string | undefined {
return this.active?.turnId;
}
async prompt(input: string): Promise<void> {
if (this.active !== undefined) {
this.steer(input);
return;
}
await this.launch(input);
}
steer(content: string, origin?: string): void {
this.steerBuffer.push({ content, origin });
}
retry(): Promise<void> {
throw new Error('TODO: TurnService.retry');
}
cancel(reason?: string): void {
if (this.active === undefined) return;
this.active.cancelled = true;
const turnId = this.active.turnId;
this.active = undefined;
this._onDidEndTurn.fire({ turnId, reason: reason ?? 'cancelled' });
}
private async launch(input: string): Promise<void> {
const turnId = `turn-${nextTurnId++}`;
this.active = { turnId, cancelled: false };
this._onWillStartTurn.fire({ turnId });
try {
await this.loopRunner.run();
this._onDidEndStep.fire({ turnId, step: 0 });
} finally {
if (this.active?.turnId === turnId) {
this.active = undefined;
this._onDidEndTurn.fire({ turnId, reason: 'completed' });
}
}
void input;
declare module '../types' {
interface WireRecordMap {
'turn.launch': {
turnId: number;
origin: PromptOrigin;
};
}
}
registerScopedService(LifecycleScope.Agent, ITurnService, TurnService, InstantiationType.Delayed, 'turn');
export class TurnRunnerService implements ITurnRunner {
private nextTurnId = 0;
private activeTurn: Turn | undefined;
private readonly readyControllers = new WeakMap<Turn, ControlledPromise<void>>();
private readonly readySettled = new WeakSet<Turn>();
private readonly currentStepByTurn = new Map<number, number>();
private readonly interruptedTelemetryTurnIds = new Set<number>();
private readonly telemetryModeByTurn = new Map<number, 'agent' | 'plan'>();
readonly hooks = {
onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
onEnded: new OrderedHookSlot<TurnEndedContext>(),
beforeStep: new OrderedHookSlot<TurnStepContext>(),
afterStep: new OrderedHookSlot<TurnStepContext>(),
};
constructor(
@ILoopService private readonly loop: ILoopService,
@IUsageService private readonly usage: IUsageService,
@IEventBus private readonly events: IEventBus,
@IWireRecord private readonly wireRecord: IWireRecord,
@IContextMemory private readonly context: IContextMemory,
@IExternalHooksService private readonly externalHooks: IExternalHooksService,
@IInstantiationService private readonly instantiation: IInstantiationService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {
wireRecord.register('turn.launch', (record) => {
this.restoreLaunch(record.turnId);
});
this.hooks.beforeStep.register('turn-before-step-event', async (ctx, next) => {
await next();
this.resolveReady(ctx.turn);
});
this.events.on((event) => {
if (event.type === 'turn.step.started') {
this.currentStepByTurn.set(event.turnId, event.step);
return;
}
if (event.type === 'turn.step.interrupted') {
this.trackTurnInterrupted(event.turnId, event.step);
}
});
}
launch(origin: PromptOrigin): Turn {
if (this.activeTurn !== undefined) {
throw new Error(`Cannot launch a new turn while turn ${this.activeTurn.id} is active`);
}
const turnId = this.nextTurnId;
this.wireRecord.append({ type: 'turn.launch', turnId, origin });
this.restoreLaunch(turnId);
const abortController = new AbortController();
const ready = createControlledPromise<void>();
const turn: MutableTurn = {
id: turnId,
abortController,
ready: ready.promise,
result: Promise.resolve({ reason: 'failed' }),
};
this.readyControllers.set(turn, ready);
void ready.promise.catch(() => undefined);
this.activeTurn = turn;
turn.result = this.runTurn(turn, origin);
void this.hooks.onLaunched.run({ turn });
return turn;
}
getActiveTurn(): Turn | undefined {
return this.activeTurn;
}
cancel(turnId?: number, reason?: unknown): void {
const turn = this.activeTurn;
if (turn === undefined) return;
if (turnId !== undefined && turn.id !== turnId) return;
turn.abortController.abort(reason ?? userCancellationReason());
}
private async runTurn(turn: Turn, origin: PromptOrigin): Promise<TurnResult> {
const startedAt = Date.now();
const telemetryMode = this.telemetryMode();
this.telemetryModeByTurn.set(turn.id, telemetryMode);
let result: TurnResult | undefined;
try {
this.usage.beginTurn();
this.telemetry.track('turn_started', { mode: telemetryMode });
this.events.emit({ type: 'turn.started', turnId: turn.id, origin });
const promptHookResult = await this.applyUserPromptHook(turn, origin);
if (promptHookResult !== undefined) {
result = promptHookResult;
return result;
}
result = await this.loop.runTurn(turn, {
beforeStep: this.hooks.beforeStep,
afterStep: this.hooks.afterStep,
});
return result;
} catch (error) {
if (turn.abortController.signal.aborted) {
result = { reason: 'cancelled', error: turn.abortController.signal.reason };
this.rejectReady(turn, turn.abortController.signal.reason);
return result;
}
this.externalHooks.triggerStopFailure(error, turn.abortController.signal);
this.rejectReady(turn, error);
result = { reason: 'failed', error };
return result;
} finally {
if (result !== undefined) {
this.rejectReady(turn, result);
}
this.usage.endTurn();
if (this.activeTurn === turn) {
this.activeTurn = undefined;
}
if (result !== undefined) {
const ended = toTurnEndedEvent(turn, result, Date.now() - startedAt);
if (
ended.reason === 'cancelled' &&
isUserCancellation(turn.abortController.signal.reason)
) {
this.externalHooks.triggerInterrupt({ turnId: turn.id, reason: 'cancelled' });
}
this.events.emit(ended);
if (ended.error !== undefined) {
this.events.emit({ type: 'error', ...ended.error });
}
if (ended.reason !== 'completed') {
this.trackTurnInterrupted(turn.id, this.currentStepByTurn.get(turn.id) ?? 0);
}
}
if (result !== undefined) {
await this.hooks.onEnded.run({ turn, result });
}
this.currentStepByTurn.delete(turn.id);
this.interruptedTelemetryTurnIds.delete(turn.id);
this.telemetryModeByTurn.delete(turn.id);
}
}
private resolveReady(turn: Turn): void {
if (this.readySettled.has(turn)) return;
this.readySettled.add(turn);
this.readyControllers.get(turn)?.resolve();
}
private restoreLaunch(turnId: number): void {
if (Number.isInteger(turnId) && turnId >= this.nextTurnId) {
this.nextTurnId = turnId + 1;
}
}
private async applyUserPromptHook(
turn: Turn,
origin: PromptOrigin,
): Promise<TurnResult | undefined> {
if (origin.kind !== 'user') return undefined;
const promptMessage = this.context.getHistory().at(-1);
if (!shouldRunUserPromptHook(promptMessage)) return undefined;
const hookResult = await this.externalHooks.triggerUserPromptSubmit(
promptMessage.content,
turn.abortController.signal,
);
if (hookResult?.action === 'block') {
this.append({
role: 'assistant',
content: [{ type: 'text', text: hookResult.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: hookResult.event, blocked: true },
});
this.events.emit({
type: 'hook.result',
turnId: turn.id,
hookEvent: hookResult.event,
content: hookResult.message,
blocked: true,
});
return { reason: 'completed' };
}
if (hookResult?.action === 'append') {
this.append({
role: 'user',
content: [{ type: 'text', text: hookResult.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: hookResult.event },
});
this.events.emit({
type: 'hook.result',
turnId: turn.id,
hookEvent: hookResult.event,
content: hookResult.message,
});
}
return undefined;
}
private append(...messages: ContextMessage[]): void {
if (messages.length === 0) return;
this.context.spliceHistory(this.context.getHistory().length, 0, messages);
}
private rejectReady(turn: Turn, reason: unknown): void {
if (this.readySettled.has(turn)) return;
this.readySettled.add(turn);
this.readyControllers.get(turn)?.reject(reason);
}
private trackTurnInterrupted(turnId: number, atStep: number): void {
if (this.interruptedTelemetryTurnIds.has(turnId)) return;
this.interruptedTelemetryTurnIds.add(turnId);
this.telemetry.track('turn_interrupted', {
mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(),
at_step: atStep,
});
}
private telemetryMode(): 'agent' | 'plan' {
const planMode = this.instantiation.invokeFunction((accessor) =>
accessor.get(IPlanService),
);
return planMode.isActive ? 'plan' : 'agent';
}
}
function shouldRunUserPromptHook(message: ContextMessage | undefined): message is ContextMessage {
if (message === undefined || message.role !== 'user') return false;
return (message.origin ?? USER_PROMPT_ORIGIN).kind === 'user';
}
function toTurnEndedEvent(
turn: Turn,
result: TurnResult,
durationMs: number,
): {
type: 'turn.ended';
turnId: number;
reason: TurnResult['reason'];
error?: KimiErrorPayload;
durationMs: number;
} {
if (result.reason !== 'failed' || result.error === undefined) {
return { type: 'turn.ended', turnId: turn.id, reason: result.reason, durationMs };
}
return {
type: 'turn.ended',
turnId: turn.id,
reason: result.reason,
error: summarizeTurnError(result.error, turn.id),
durationMs,
};
}
const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload {
const payload = toKimiErrorPayload(error);
const details = { ...payload.details, turnId };
// Substitute a friendlier, login-aware message for model-not-configured. The
// raw "Model not set" / "Provider not set" text is not actionable.
if (payload.code === 'model.not_configured') {
return { ...payload, message: LLM_NOT_SET_MESSAGE, details };
}
return { ...payload, details };
}
interface ControlledPromise<T> {
readonly promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
reject(reason?: unknown): void;
}
type MutableTurn = {
-readonly [K in keyof Turn]: Turn[K];
};
function createControlledPromise<T>(): ControlledPromise<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
registerSingleton(ITurnRunner, new SyncDescriptor(TurnRunnerService, [], true));

View file

@ -1,6 +0,0 @@
/**
* `turnRunner` domain barrel - re-exports the turnRunner service contract and implementation.
*/
export * from './turnRunner';
export * from './turnRunnerService';

View file

@ -1,21 +0,0 @@
import { createDecorator } from "#/_base/di";
import type { PromptOrigin } from '../../../agent/context';
import type { Hooks } from '../hooks';
import type { Turn, TurnEndedContext, TurnStepContext } from '../types';
export interface ITurnRunner {
launch(origin: PromptOrigin): Turn;
getActiveTurn(): Turn | undefined;
cancel(turnId?: number, reason?: unknown): void;
readonly hooks: Hooks<{
onLaunched: { turn: Turn };
onEnded: TurnEndedContext;
beforeStep: TurnStepContext;
afterStep: TurnStepContext;
}>;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const ITurnRunner = createDecorator<ITurnRunner>('agentTurnRunnerService');

View file

@ -1,324 +0,0 @@
import {
IInstantiationService,
registerSingleton,
SyncDescriptor,
} from "#/_base/di";
import type { ContextMessage, PromptOrigin } from '../../../agent/context';
import { USER_PROMPT_ORIGIN } from '../../../agent/context';
import { toKimiErrorPayload, type KimiErrorPayload } from "#/_base/errors";
import { isUserCancellation, userCancellationReason } from "#/_base/utils/abort";
import { IContextMemory } from '../contextMemory/contextMemory';
import { IEventBus } from '../eventBus/eventBus';
import { IExternalHooksService } from '../externalHooks/externalHooks';
import { OrderedHookSlot } from '../hooks';
import { ILoopService } from '../loop/loop';
import { IPlanService } from '../plan/planMode';
import { ITelemetryService } from '../telemetry/telemetry';
import type {
Turn,
TurnEndedContext,
TurnResult,
TurnStepContext,
} from '../types';
import { IUsageService } from '../usage/usage';
import { IWireRecord } from '../wireRecord/wireRecord';
import { ITurnRunner } from './turnRunner';
declare module '../types' {
interface WireRecordMap {
'turn.launch': {
turnId: number;
origin: PromptOrigin;
};
}
}
export class TurnRunnerService implements ITurnRunner {
private nextTurnId = 0;
private activeTurn: Turn | undefined;
private readonly readyControllers = new WeakMap<Turn, ControlledPromise<void>>();
private readonly readySettled = new WeakSet<Turn>();
private readonly currentStepByTurn = new Map<number, number>();
private readonly interruptedTelemetryTurnIds = new Set<number>();
private readonly telemetryModeByTurn = new Map<number, 'agent' | 'plan'>();
readonly hooks = {
onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
onEnded: new OrderedHookSlot<TurnEndedContext>(),
beforeStep: new OrderedHookSlot<TurnStepContext>(),
afterStep: new OrderedHookSlot<TurnStepContext>(),
};
constructor(
@ILoopService private readonly loop: ILoopService,
@IUsageService private readonly usage: IUsageService,
@IEventBus private readonly events: IEventBus,
@IWireRecord private readonly wireRecord: IWireRecord,
@IContextMemory private readonly context: IContextMemory,
@IExternalHooksService private readonly externalHooks: IExternalHooksService,
@IInstantiationService private readonly instantiation: IInstantiationService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {
wireRecord.register('turn.launch', (record) => {
this.restoreLaunch(record.turnId);
});
this.hooks.beforeStep.register('turn-before-step-event', async (ctx, next) => {
await next();
this.resolveReady(ctx.turn);
});
this.events.on((event) => {
if (event.type === 'turn.step.started') {
this.currentStepByTurn.set(event.turnId, event.step);
return;
}
if (event.type === 'turn.step.interrupted') {
this.trackTurnInterrupted(event.turnId, event.step);
}
});
}
launch(origin: PromptOrigin): Turn {
if (this.activeTurn !== undefined) {
throw new Error(`Cannot launch a new turn while turn ${this.activeTurn.id} is active`);
}
const turnId = this.nextTurnId;
this.wireRecord.append({ type: 'turn.launch', turnId, origin });
this.restoreLaunch(turnId);
const abortController = new AbortController();
const ready = createControlledPromise<void>();
const turn: MutableTurn = {
id: turnId,
abortController,
ready: ready.promise,
result: Promise.resolve({ reason: 'failed' }),
};
this.readyControllers.set(turn, ready);
void ready.promise.catch(() => undefined);
this.activeTurn = turn;
turn.result = this.runTurn(turn, origin);
void this.hooks.onLaunched.run({ turn });
return turn;
}
getActiveTurn(): Turn | undefined {
return this.activeTurn;
}
cancel(turnId?: number, reason?: unknown): void {
const turn = this.activeTurn;
if (turn === undefined) return;
if (turnId !== undefined && turn.id !== turnId) return;
turn.abortController.abort(reason ?? userCancellationReason());
}
private async runTurn(turn: Turn, origin: PromptOrigin): Promise<TurnResult> {
const startedAt = Date.now();
const telemetryMode = this.telemetryMode();
this.telemetryModeByTurn.set(turn.id, telemetryMode);
let result: TurnResult | undefined;
try {
this.usage.beginTurn();
this.telemetry.track('turn_started', { mode: telemetryMode });
this.events.emit({ type: 'turn.started', turnId: turn.id, origin });
const promptHookResult = await this.applyUserPromptHook(turn, origin);
if (promptHookResult !== undefined) {
result = promptHookResult;
return result;
}
result = await this.loop.runTurn(turn, {
beforeStep: this.hooks.beforeStep,
afterStep: this.hooks.afterStep,
});
return result;
} catch (error) {
if (turn.abortController.signal.aborted) {
result = { reason: 'cancelled', error: turn.abortController.signal.reason };
this.rejectReady(turn, turn.abortController.signal.reason);
return result;
}
this.externalHooks.triggerStopFailure(error, turn.abortController.signal);
this.rejectReady(turn, error);
result = { reason: 'failed', error };
return result;
} finally {
if (result !== undefined) {
this.rejectReady(turn, result);
}
this.usage.endTurn();
if (this.activeTurn === turn) {
this.activeTurn = undefined;
}
if (result !== undefined) {
const ended = toTurnEndedEvent(turn, result, Date.now() - startedAt);
if (
ended.reason === 'cancelled' &&
isUserCancellation(turn.abortController.signal.reason)
) {
this.externalHooks.triggerInterrupt({ turnId: turn.id, reason: 'cancelled' });
}
this.events.emit(ended);
if (ended.error !== undefined) {
this.events.emit({ type: 'error', ...ended.error });
}
if (ended.reason !== 'completed') {
this.trackTurnInterrupted(turn.id, this.currentStepByTurn.get(turn.id) ?? 0);
}
}
if (result !== undefined) {
await this.hooks.onEnded.run({ turn, result });
}
this.currentStepByTurn.delete(turn.id);
this.interruptedTelemetryTurnIds.delete(turn.id);
this.telemetryModeByTurn.delete(turn.id);
}
}
private resolveReady(turn: Turn): void {
if (this.readySettled.has(turn)) return;
this.readySettled.add(turn);
this.readyControllers.get(turn)?.resolve();
}
private restoreLaunch(turnId: number): void {
if (Number.isInteger(turnId) && turnId >= this.nextTurnId) {
this.nextTurnId = turnId + 1;
}
}
private async applyUserPromptHook(
turn: Turn,
origin: PromptOrigin,
): Promise<TurnResult | undefined> {
if (origin.kind !== 'user') return undefined;
const promptMessage = this.context.getHistory().at(-1);
if (!shouldRunUserPromptHook(promptMessage)) return undefined;
const hookResult = await this.externalHooks.triggerUserPromptSubmit(
promptMessage.content,
turn.abortController.signal,
);
if (hookResult?.action === 'block') {
this.append({
role: 'assistant',
content: [{ type: 'text', text: hookResult.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: hookResult.event, blocked: true },
});
this.events.emit({
type: 'hook.result',
turnId: turn.id,
hookEvent: hookResult.event,
content: hookResult.message,
blocked: true,
});
return { reason: 'completed' };
}
if (hookResult?.action === 'append') {
this.append({
role: 'user',
content: [{ type: 'text', text: hookResult.text }],
toolCalls: [],
origin: { kind: 'hook_result', event: hookResult.event },
});
this.events.emit({
type: 'hook.result',
turnId: turn.id,
hookEvent: hookResult.event,
content: hookResult.message,
});
}
return undefined;
}
private append(...messages: ContextMessage[]): void {
if (messages.length === 0) return;
this.context.spliceHistory(this.context.getHistory().length, 0, messages);
}
private rejectReady(turn: Turn, reason: unknown): void {
if (this.readySettled.has(turn)) return;
this.readySettled.add(turn);
this.readyControllers.get(turn)?.reject(reason);
}
private trackTurnInterrupted(turnId: number, atStep: number): void {
if (this.interruptedTelemetryTurnIds.has(turnId)) return;
this.interruptedTelemetryTurnIds.add(turnId);
this.telemetry.track('turn_interrupted', {
mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(),
at_step: atStep,
});
}
private telemetryMode(): 'agent' | 'plan' {
const planMode = this.instantiation.invokeFunction((accessor) =>
accessor.get(IPlanService),
);
return planMode.isActive ? 'plan' : 'agent';
}
}
function shouldRunUserPromptHook(message: ContextMessage | undefined): message is ContextMessage {
if (message === undefined || message.role !== 'user') return false;
return (message.origin ?? USER_PROMPT_ORIGIN).kind === 'user';
}
function toTurnEndedEvent(
turn: Turn,
result: TurnResult,
durationMs: number,
): {
type: 'turn.ended';
turnId: number;
reason: TurnResult['reason'];
error?: KimiErrorPayload;
durationMs: number;
} {
if (result.reason !== 'failed' || result.error === undefined) {
return { type: 'turn.ended', turnId: turn.id, reason: result.reason, durationMs };
}
return {
type: 'turn.ended',
turnId: turn.id,
reason: result.reason,
error: summarizeTurnError(result.error, turn.id),
durationMs,
};
}
const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload {
const payload = toKimiErrorPayload(error);
const details = { ...payload.details, turnId };
// Substitute a friendlier, login-aware message for model-not-configured. The
// raw "Model not set" / "Provider not set" text is not actionable.
if (payload.code === 'model.not_configured') {
return { ...payload, message: LLM_NOT_SET_MESSAGE, details };
}
return { ...payload, details };
}
interface ControlledPromise<T> {
readonly promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
reject(reason?: unknown): void;
}
type MutableTurn = {
-readonly [K in keyof Turn]: Turn[K];
};
function createControlledPromise<T>(): ControlledPromise<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
registerSingleton(ITurnRunner, new SyncDescriptor(TurnRunnerService, [], true));