mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat: simplify turn service
This commit is contained in:
parent
95901922e4
commit
2471ae088f
36 changed files with 345 additions and 266 deletions
|
|
@ -3,10 +3,11 @@
|
|||
* hook commands.
|
||||
*
|
||||
* Listens to hook slots owned by the agent behavior/lifecycle domains
|
||||
* (`toolExecutor`, `permissionGate`, `turn`, `loop`, `fullCompaction`, and
|
||||
* (`toolExecutor`, `permissionGate`, `prompt`, `turn`, `loop`, `fullCompaction`, and
|
||||
* `task`) and translates those minimal contexts into the configured external
|
||||
* HookEngine events. Appends Stop hook continuation prompts through
|
||||
* `contextMemory`. The `SubagentStart` / `SubagentStop` pair is the one
|
||||
* HookEngine events. Appends UserPromptSubmit hook results and 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.
|
||||
|
|
@ -18,7 +19,7 @@ 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 { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
|
||||
import {
|
||||
IAgentFullCompactionService,
|
||||
type FullCompactionDidCompactContext,
|
||||
|
|
@ -29,6 +30,11 @@ import {
|
|||
IAgentPermissionGate,
|
||||
type PermissionApprovalResultContext,
|
||||
} from '#/agent/permissionGate';
|
||||
import {
|
||||
IAgentPromptService,
|
||||
type PromptSubmitContext,
|
||||
} from '#/agent/prompt';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import type {
|
||||
ExecutableToolResult,
|
||||
ToolDidExecuteContext,
|
||||
|
|
@ -38,8 +44,6 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor';
|
|||
import {
|
||||
IAgentTurnService,
|
||||
type TurnEndedContext,
|
||||
type TurnUserPromptDecision,
|
||||
type TurnUserPromptSubmitContext,
|
||||
} from '#/agent/turn';
|
||||
import { IBootstrapService } from '#/app/bootstrap';
|
||||
import { IConfigService } from '#/app/config';
|
||||
|
|
@ -84,6 +88,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
|
|||
constructor(
|
||||
private readonly options: ExternalHooksServiceOptions = {},
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentRecordService private readonly record: IAgentRecordService,
|
||||
@IInstantiationService private readonly instantiation: IInstantiationService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
|
|
@ -115,6 +120,10 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
|
|||
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentPermissionGate)),
|
||||
);
|
||||
|
||||
this.registerPromptHooks(
|
||||
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentPromptService)),
|
||||
);
|
||||
|
||||
this.registerTurnHooks(
|
||||
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentTurnService)),
|
||||
);
|
||||
|
|
@ -180,17 +189,19 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
|
|||
);
|
||||
}
|
||||
|
||||
private registerTurnHooks(turn: IAgentTurnService): void {
|
||||
private registerPromptHooks(prompt: IAgentPromptService): void {
|
||||
this._register(
|
||||
turn.hooks.onWillSubmitUserPrompt.register('externalHooks', async (ctx, next) => {
|
||||
const decision = await this.runUserPromptSubmit(ctx);
|
||||
if (decision !== undefined) {
|
||||
ctx.decision = decision;
|
||||
if (decision.action === 'block') return;
|
||||
prompt.hooks.onWillSubmitPrompt.register('externalHooks', async (ctx, next) => {
|
||||
if (await this.runPromptSubmitHook(ctx)) {
|
||||
ctx.block = true;
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private registerTurnHooks(turn: IAgentTurnService): void {
|
||||
this._register(
|
||||
turn.hooks.onEnded.register('externalHooks', async (ctx, next) => {
|
||||
this.notifyTurnEnded(ctx);
|
||||
|
|
@ -293,24 +304,53 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
|
|||
);
|
||||
}
|
||||
|
||||
private async runUserPromptSubmit(
|
||||
ctx: TurnUserPromptSubmitContext,
|
||||
): Promise<TurnUserPromptDecision | undefined> {
|
||||
const signal = ctx.turn.abortController.signal;
|
||||
private async runPromptSubmitHook(
|
||||
ctx: PromptSubmitContext,
|
||||
): Promise<boolean> {
|
||||
if ((ctx.promptMessage.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return false;
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
const input = ctx.promptMessage.content;
|
||||
signal.throwIfAborted();
|
||||
const results = await this.engine()?.trigger('UserPromptSubmit', {
|
||||
matcherValue: input,
|
||||
signal,
|
||||
inputData: { prompt: input },
|
||||
inputData: { prompt: input, isSteer: ctx.isSteer },
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
|
||||
const block = renderUserPromptHookBlockResult(results);
|
||||
if (block !== undefined) return { action: 'block', ...block };
|
||||
if (block !== undefined) {
|
||||
this.context.splice(this.context.get().length, 0, [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: block.text }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'hook_result', event: block.event, blocked: true },
|
||||
}]);
|
||||
this.record.signal({
|
||||
type: 'hook.result',
|
||||
hookEvent: block.event,
|
||||
content: block.message,
|
||||
blocked: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const append = renderUserPromptHookResult(results);
|
||||
return append === undefined ? undefined : { action: 'append', ...append };
|
||||
if (append !== undefined) {
|
||||
this.context.splice(this.context.get().length, 0, [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: append.text }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'hook_result', event: append.event },
|
||||
}]);
|
||||
this.record.signal({
|
||||
type: 'hook.result',
|
||||
hookEvent: append.event,
|
||||
content: append.message,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private notifyTurnEnded(ctx: TurnEndedContext): void {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import { createDecorator } from "#/_base/di";
|
||||
import type { ContextMessage } from "#/agent/contextMemory";
|
||||
import type { Turn } from "#/agent/turn";
|
||||
import type { Hooks } from '#/hooks';
|
||||
|
||||
export interface PromptSubmitContext {
|
||||
readonly promptMessage: ContextMessage;
|
||||
readonly isSteer: boolean;
|
||||
block: boolean;
|
||||
}
|
||||
|
||||
export interface IAgentPromptService {
|
||||
readonly _serviceBrand: undefined;
|
||||
prompt(message: ContextMessage): Turn | undefined;
|
||||
steer(message: ContextMessage): Turn | undefined;
|
||||
prompt(message: ContextMessage): Promise<Turn | undefined>;
|
||||
steer(message: ContextMessage): Promise<Turn | undefined>;
|
||||
retry(trigger?: string): Turn | undefined;
|
||||
undo(count: number): number;
|
||||
clear(): void;
|
||||
|
||||
readonly hooks: Hooks<{
|
||||
onWillSubmitPrompt: PromptSubmitContext;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const IAgentPromptService = createDecorator<IAgentPromptService>('agentPromptService');
|
||||
|
|
|
|||
|
|
@ -11,13 +11,21 @@ import {
|
|||
import { IAgentLoopService } from '#/agent/loop';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { IAgentTurnService, type Turn } from '#/agent/turn';
|
||||
import { IAgentPromptService } from './prompt';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
import {
|
||||
IAgentPromptService,
|
||||
type PromptSubmitContext,
|
||||
} from './prompt';
|
||||
|
||||
export class AgentPromptService implements IAgentPromptService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly steerQueue: ContextMessage[] = [];
|
||||
private observedTurn: Turn | undefined;
|
||||
|
||||
readonly hooks = {
|
||||
onWillSubmitPrompt: new OrderedHookSlot<PromptSubmitContext>(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentTurnService private readonly turnService: IAgentTurnService,
|
||||
|
|
@ -36,25 +44,27 @@ export class AgentPromptService implements IAgentPromptService {
|
|||
});
|
||||
}
|
||||
|
||||
prompt(message: ContextMessage): Turn | undefined {
|
||||
async prompt(message: ContextMessage): Promise<Turn | undefined> {
|
||||
if (this.emitBusyIfActive()) return undefined;
|
||||
const stamped = ensureMessageId(message);
|
||||
this.append(stamped);
|
||||
if (await this.applyPromptHook(stamped, false)) return undefined;
|
||||
const turn = this.turnService.launch(stamped.origin ?? USER_PROMPT_ORIGIN, stamped.id);
|
||||
this.observe(turn);
|
||||
return turn;
|
||||
}
|
||||
|
||||
steer(message: ContextMessage): Turn | undefined {
|
||||
async steer(message: ContextMessage): Promise<Turn | undefined> {
|
||||
const stamped = ensureMessageId(message);
|
||||
const activeTurn = this.turnService.getActiveTurn();
|
||||
if (activeTurn !== undefined) {
|
||||
this.steerQueue.push(ensureMessageId(message));
|
||||
this.steerQueue.push(stamped);
|
||||
this.observe(activeTurn);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stamped = ensureMessageId(message);
|
||||
this.append(stamped);
|
||||
if (await this.applyPromptHook(stamped, true)) return undefined;
|
||||
const turn = this.turnService.launch(stamped.origin ?? USER_PROMPT_ORIGIN, stamped.id);
|
||||
this.observe(turn);
|
||||
return turn;
|
||||
|
|
@ -118,6 +128,16 @@ export class AgentPromptService implements IAgentPromptService {
|
|||
this.context.splice(this.context.get().length, 0, messages);
|
||||
}
|
||||
|
||||
private async applyPromptHook(promptMessage: ContextMessage, isSteer: boolean): Promise<boolean> {
|
||||
const hookContext: PromptSubmitContext = {
|
||||
promptMessage,
|
||||
isSteer,
|
||||
block: false,
|
||||
};
|
||||
await this.hooks.onWillSubmitPrompt.run(hookContext);
|
||||
return hookContext.block;
|
||||
}
|
||||
|
||||
private observe(turn: Turn): void {
|
||||
if (this.observedTurn === turn) return;
|
||||
this.observedTurn = turn;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
PromptAbortResponse,
|
||||
PromptItem,
|
||||
PromptListResponse,
|
||||
PromptStatus,
|
||||
PromptSteerResult,
|
||||
PromptSubmission,
|
||||
PromptSubmitResult,
|
||||
|
|
@ -69,8 +70,8 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
|
|||
this.queued.push(record);
|
||||
return toItem(record, 'queued');
|
||||
}
|
||||
const turn = this.launch(record);
|
||||
return toItem(record, turn === undefined ? 'queued' : 'running');
|
||||
const status = await this.launch(record);
|
||||
return toItem(record, status);
|
||||
}
|
||||
|
||||
async steer(promptIds: readonly string[]): Promise<PromptSteerResult> {
|
||||
|
|
@ -96,7 +97,7 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
|
|||
selected.reverse();
|
||||
|
||||
const content = selected.flatMap((record) => contentToCoreParts(record.body.content));
|
||||
this.prompt.steer({
|
||||
await this.prompt.steer({
|
||||
role: 'user',
|
||||
content,
|
||||
toolCalls: [],
|
||||
|
|
@ -138,12 +139,12 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
|
|||
};
|
||||
}
|
||||
|
||||
private launch(record: PromptRecord): Turn | undefined {
|
||||
private async launch(record: PromptRecord): Promise<PromptStatus> {
|
||||
const parts = contentToCoreParts(record.body.content);
|
||||
if (parts.length === 0) {
|
||||
throw new KimiError(ErrorCodes.REQUEST_INVALID, 'prompt content has no supported parts');
|
||||
}
|
||||
const turn = this.prompt.prompt({
|
||||
const turn = await this.prompt.prompt({
|
||||
id: record.promptId,
|
||||
role: 'user',
|
||||
content: parts,
|
||||
|
|
@ -151,14 +152,17 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
|
|||
origin: { kind: 'user' },
|
||||
});
|
||||
if (turn === undefined) {
|
||||
// Busy with a turn started outside the legacy service (e.g. via /api/v2);
|
||||
// keep the record queued so it runs once the agent is idle.
|
||||
this.queued.unshift(record);
|
||||
return undefined;
|
||||
if (this.turnService.getActiveTurn() !== undefined) {
|
||||
// Busy with a turn started outside the legacy service (e.g. via /api/v2);
|
||||
// keep the record queued so it runs once the agent is idle.
|
||||
this.queued.unshift(record);
|
||||
return 'queued';
|
||||
}
|
||||
return 'blocked';
|
||||
}
|
||||
this.active = { ...record, turn };
|
||||
void turn.result.then((result) => this.onTurnSettled(record.promptId, result));
|
||||
return turn;
|
||||
return 'running';
|
||||
}
|
||||
|
||||
private onTurnSettled(promptId: string, result: TurnResult): void {
|
||||
|
|
@ -173,7 +177,7 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
|
|||
if (this.active !== undefined) return;
|
||||
const next = this.queued.shift();
|
||||
if (next === undefined) return;
|
||||
this.launch(next);
|
||||
void this.launch(next);
|
||||
}
|
||||
|
||||
private async applyOverrides(body: PromptSubmission): Promise<void> {
|
||||
|
|
@ -189,7 +193,7 @@ export class AgentPromptLegacyService implements IAgentPromptLegacyService {
|
|||
}
|
||||
}
|
||||
|
||||
function toItem(record: PromptRecord, status: 'running' | 'queued'): PromptItem {
|
||||
function toItem(record: PromptRecord, status: PromptStatus): PromptItem {
|
||||
return {
|
||||
prompt_id: record.promptId,
|
||||
user_message_id: record.userMessageId,
|
||||
|
|
|
|||
|
|
@ -369,7 +369,8 @@ export interface RemoveKimiProviderPayload {
|
|||
* Result returned when a prompt/steer submission is accepted. The turn is the
|
||||
* submission's identity and lifecycle (`turn.started` / `turn.ended` carry the
|
||||
* rest over the event stream), so the handle is just the turn id. `undefined`
|
||||
* means the submission was not accepted (e.g. the agent was busy).
|
||||
* means no turn was launched (e.g. the agent was busy, or a prompt hook blocked
|
||||
* before launch).
|
||||
*/
|
||||
export interface PromptLaunchResult {
|
||||
readonly turn_id: number;
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ export class AgentRPCService implements IAgentRPCService {
|
|||
@ISessionMetadata private readonly metadata: ISessionMetadata,
|
||||
) { }
|
||||
|
||||
prompt(payload: PromptPayload): PromptLaunchResult | undefined {
|
||||
const turn = this.promptService.prompt({
|
||||
async prompt(payload: PromptPayload): Promise<PromptLaunchResult | undefined> {
|
||||
const turn = await this.promptService.prompt({
|
||||
role: 'user',
|
||||
content: [...payload.input],
|
||||
toolCalls: [],
|
||||
|
|
@ -169,9 +169,9 @@ export class AgentRPCService implements IAgentRPCService {
|
|||
this.shellCommandControllers.get(payload.commandId)?.abort(userCancellationReason());
|
||||
}
|
||||
|
||||
steer(payload: SteerPayload): PromptLaunchResult | undefined {
|
||||
async steer(payload: SteerPayload): Promise<PromptLaunchResult | undefined> {
|
||||
this.telemetry.track('input_steer', { parts: payload.input.length });
|
||||
const turn = this.promptService.steer({
|
||||
const turn = await this.promptService.steer({
|
||||
role: 'user',
|
||||
content: [...payload.input],
|
||||
toolCalls: [],
|
||||
|
|
@ -312,7 +312,7 @@ export class AgentRPCService implements IAgentRPCService {
|
|||
commandArgs: origin.commandArgs,
|
||||
trigger: origin.trigger,
|
||||
});
|
||||
this.promptService.prompt({
|
||||
await this.promptService.prompt({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: expanded }],
|
||||
toolCalls: [],
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
|
|||
},
|
||||
];
|
||||
|
||||
return this.recordActivation(
|
||||
const turn = await this.recordActivation(
|
||||
{
|
||||
kind: 'skill_activation',
|
||||
activationId: randomUUID(),
|
||||
|
|
@ -83,17 +83,24 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
|
|||
skillArgs: input.args,
|
||||
},
|
||||
content,
|
||||
)!;
|
||||
);
|
||||
if (turn === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.TURN_AGENT_BUSY,
|
||||
'Cannot activate skill while another turn is active',
|
||||
);
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
recordModelToolActivation(origin: SkillActivationOrigin): void {
|
||||
this.recordActivation(origin);
|
||||
void this.recordActivation(origin);
|
||||
}
|
||||
|
||||
private recordActivation(
|
||||
private async recordActivation(
|
||||
origin: SkillActivationOrigin,
|
||||
input?: readonly ContentPart[],
|
||||
): Turn | undefined {
|
||||
): Promise<Turn | undefined> {
|
||||
this.records.append({ type: 'skill.activate', origin });
|
||||
this.publishActivation(origin);
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ export async function executeModelSkill(
|
|||
origin,
|
||||
};
|
||||
skillService.recordModelToolActivation(origin);
|
||||
prompt.steer(message);
|
||||
await prompt.steer(message);
|
||||
return {
|
||||
output: `Skill "${skill.name}" loaded inline. Follow its instructions.`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -813,7 +813,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
private async notifyAgentTask(info: AgentTaskInfo): Promise<void> {
|
||||
const context = await this.buildAgentTaskNotificationContext(info);
|
||||
if (context === undefined) return;
|
||||
this.prompt.steer({
|
||||
await this.prompt.steer({
|
||||
role: 'user',
|
||||
content: [...context.content],
|
||||
toolCalls: [],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createDecorator } from "#/_base/di";
|
||||
import type { TurnResult } from '#/agent/loop';
|
||||
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
|
||||
import type { PromptOrigin } from '#/agent/contextMemory';
|
||||
import type { Hooks } from '#/hooks';
|
||||
|
||||
export type { TurnResult } from '#/agent/loop';
|
||||
|
|
@ -18,33 +18,6 @@ export interface Turn {
|
|||
readonly result: Promise<TurnResult>;
|
||||
}
|
||||
|
||||
export interface TurnRunContext {
|
||||
readonly turn: Turn;
|
||||
readonly origin: PromptOrigin;
|
||||
readonly promptMessage?: ContextMessage;
|
||||
result?: TurnResult;
|
||||
}
|
||||
|
||||
export type TurnUserPromptDecision =
|
||||
| {
|
||||
readonly action: 'append';
|
||||
readonly event: string;
|
||||
readonly message: string;
|
||||
readonly text: string;
|
||||
}
|
||||
| {
|
||||
readonly action: 'block';
|
||||
readonly event: string;
|
||||
readonly message: string;
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
export interface TurnUserPromptSubmitContext {
|
||||
readonly turn: Turn;
|
||||
readonly promptMessage: ContextMessage;
|
||||
decision?: TurnUserPromptDecision;
|
||||
}
|
||||
|
||||
export interface TurnEndedContext {
|
||||
readonly turn: Turn;
|
||||
readonly result: TurnResult;
|
||||
|
|
@ -63,7 +36,6 @@ export interface IAgentTurnService {
|
|||
|
||||
readonly hooks: Hooks<{
|
||||
onLaunched: { turn: Turn };
|
||||
onWillSubmitUserPrompt: TurnUserPromptSubmitContext;
|
||||
onEnded: TurnEndedContext;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import { createControlledPromise } from '@antfu/utils';
|
|||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
|
||||
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
|
||||
import { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
|
||||
import { toKimiErrorPayload } from '#/errors';
|
||||
import type { PromptOrigin } from '#/agent/contextMemory';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
import { IAgentLoopService } from '#/agent/loop';
|
||||
import { IAgentTelemetryContextService, ITelemetryService } from '#/app/telemetry';
|
||||
|
|
@ -12,7 +11,6 @@ import { IAgentRecordService } from '#/agent/record';
|
|||
import type {
|
||||
Turn,
|
||||
TurnEndedContext,
|
||||
TurnUserPromptSubmitContext,
|
||||
TurnResult,
|
||||
} from './turn';
|
||||
import { IAgentTurnService } from './turn';
|
||||
|
|
@ -35,14 +33,12 @@ export class AgentTurnService implements IAgentTurnService {
|
|||
|
||||
readonly hooks = {
|
||||
onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
|
||||
onWillSubmitUserPrompt: new OrderedHookSlot<TurnUserPromptSubmitContext>(),
|
||||
onEnded: new OrderedHookSlot<TurnEndedContext>(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
@IAgentLoopService private readonly loop: IAgentLoopService,
|
||||
@IAgentRecordService private readonly record: IAgentRecordService,
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
|
||||
) {
|
||||
|
|
@ -105,11 +101,6 @@ export class AgentTurnService implements IAgentTurnService {
|
|||
origin,
|
||||
promptMessageId: turn.promptMessageId,
|
||||
});
|
||||
const promptHookResult = await this.applyUserPromptHook(turn, origin);
|
||||
if (promptHookResult !== undefined) {
|
||||
result = promptHookResult;
|
||||
return result;
|
||||
}
|
||||
result = await this.loop.runTurn(turn.id, {
|
||||
signal: turn.abortController.signal,
|
||||
onStepStarted: () => ready.resolve(),
|
||||
|
|
@ -123,19 +114,25 @@ export class AgentTurnService implements IAgentTurnService {
|
|||
result = { reason: 'failed', error };
|
||||
return result;
|
||||
} finally {
|
||||
ready.reject(createTurnReadyError(result));
|
||||
ready.reject(new Error('Turn ended before first step', { cause: result?.error }));
|
||||
if (this.activeTurn === turn) {
|
||||
this.activeTurn = undefined;
|
||||
}
|
||||
if (result !== undefined) {
|
||||
this.lastEndedReasonValue = result.reason;
|
||||
const ended = toTurnEndedEvent(turn, result, Date.now() - startedAt);
|
||||
this.record.signal(ended);
|
||||
if (ended.error !== undefined) {
|
||||
this.record.signal({ type: 'error', ...ended.error });
|
||||
const error = result.error !== undefined ? toKimiErrorPayload(result.error) : undefined;
|
||||
this.record.signal({
|
||||
type: 'turn.ended',
|
||||
turnId: turn.id,
|
||||
reason: result.reason,
|
||||
error,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
if (error !== undefined) {
|
||||
this.record.signal({ type: 'error', ...error });
|
||||
}
|
||||
if (ended.reason !== 'completed') {
|
||||
turnTelemetry.track('turn_interrupted', { at_step: result.steps ?? 0 });
|
||||
if (result.reason !== 'completed') {
|
||||
turnTelemetry.track('turn_interrupted', { at_step: result.steps ?? null });
|
||||
}
|
||||
}
|
||||
if (result !== undefined) {
|
||||
|
|
@ -149,117 +146,12 @@ export class AgentTurnService implements IAgentTurnService {
|
|||
this.nextTurnId = turnId + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private async applyUserPromptHook(
|
||||
turn: Turn,
|
||||
origin: PromptOrigin,
|
||||
): Promise<TurnResult | undefined> {
|
||||
if (origin.kind !== 'user') return undefined;
|
||||
const promptMessage = this.context.get().at(-1);
|
||||
if (!shouldRunUserPromptHook(promptMessage)) return undefined;
|
||||
|
||||
const hookContext: TurnUserPromptSubmitContext = { turn, promptMessage };
|
||||
await this.hooks.onWillSubmitUserPrompt.run(hookContext);
|
||||
const hookResult = hookContext.decision;
|
||||
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.record.signal({
|
||||
type: 'hook.result',
|
||||
turnId: turn.id,
|
||||
hookEvent: hookResult.event,
|
||||
content: hookResult.message,
|
||||
blocked: true,
|
||||
});
|
||||
return { reason: 'blocked' };
|
||||
}
|
||||
|
||||
if (hookResult?.action === 'append') {
|
||||
this.append({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: hookResult.text }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'hook_result', event: hookResult.event },
|
||||
});
|
||||
this.record.signal({
|
||||
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.splice(this.context.get().length, 0, messages);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
type MutableTurn = {
|
||||
-readonly [K in keyof Turn]: Turn[K];
|
||||
};
|
||||
|
||||
function createTurnReadyError(result: TurnResult | undefined): Error {
|
||||
const cause = turnReadyFailureCause(result);
|
||||
return cause === undefined
|
||||
? new Error('Turn ended before first step')
|
||||
: new Error('Turn ended before first step', { cause });
|
||||
}
|
||||
|
||||
function turnReadyFailureCause(result: TurnResult | undefined): unknown {
|
||||
if (result === undefined) return undefined;
|
||||
if ('error' in result && result.error !== undefined) return result.error;
|
||||
return result.reason;
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentTurnService,
|
||||
|
|
|
|||
|
|
@ -38,33 +38,33 @@ export class RestGateway implements IRestGateway {
|
|||
return agent;
|
||||
}
|
||||
|
||||
prompt(
|
||||
async prompt(
|
||||
sessionId: string,
|
||||
agentId: string,
|
||||
input: string,
|
||||
): Promise<{ readonly turn_id: number } | undefined> {
|
||||
const turn = this.agent(sessionId, agentId).accessor.get(IAgentPromptService).prompt({
|
||||
const turn = await this.agent(sessionId, agentId).accessor.get(IAgentPromptService).prompt({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: input }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
});
|
||||
return Promise.resolve(turn === undefined ? undefined : { turn_id: turn.id });
|
||||
return turn === undefined ? undefined : { turn_id: turn.id };
|
||||
}
|
||||
steer(
|
||||
async steer(
|
||||
sessionId: string,
|
||||
agentId: string,
|
||||
content: string,
|
||||
): Promise<{ readonly turn_id: number } | undefined> {
|
||||
const agent = this.agent(sessionId, agentId);
|
||||
const turn = agent.accessor.get(IAgentPromptService).steer({
|
||||
const turn = await agent.accessor.get(IAgentPromptService).steer({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: content }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
});
|
||||
const id = turn?.id ?? agent.accessor.get(IAgentTurnService).getActiveTurn()?.id;
|
||||
return Promise.resolve(id === undefined ? undefined : { turn_id: id });
|
||||
return id === undefined ? undefined : { turn_id: id };
|
||||
}
|
||||
cancel(sessionId: string, agentId: string, reason?: string): Promise<void> {
|
||||
const activeTurn = this.agent(sessionId, agentId).accessor.get(IAgentTurnService).getActiveTurn();
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export interface IAgentLifecycleService {
|
|||
* surface this run (the `Agent` tool, the swarm) mirrors it itself. Throws
|
||||
* when the agent does not exist or a turn cannot be started (busy / no head).
|
||||
*/
|
||||
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): AgentRunHandle;
|
||||
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise<AgentRunHandle>;
|
||||
getHandle(agentId: string): IAgentScopeHandle | undefined;
|
||||
list(filter?: AgentListFilter): readonly IAgentScopeHandle[];
|
||||
remove(agentId: string): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
return child;
|
||||
}
|
||||
|
||||
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): AgentRunHandle {
|
||||
run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise<AgentRunHandle> {
|
||||
const handle = this.handles.get(agentId);
|
||||
if (handle === undefined) throw new Error(`Agent "${agentId}" does not exist`);
|
||||
return runAgentTurn(handle, request, {
|
||||
|
|
|
|||
|
|
@ -58,20 +58,20 @@ export interface RunAgentTurnOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Submit a prompt (or a retry) to `target` and return the running `Turn` plus
|
||||
* a promise of the distilled summary/usage. Throws when the underlying
|
||||
* Submit a prompt (or a retry) to `target` and resolve to the running `Turn`
|
||||
* plus a promise of the distilled summary/usage. Throws when the underlying
|
||||
* `IAgentPromptService.prompt/retry` refuses to launch a turn (busy / no head).
|
||||
*/
|
||||
export function runAgentTurn(
|
||||
export async function runAgentTurn(
|
||||
target: IAgentScopeHandle,
|
||||
request: AgentRunRequest,
|
||||
options: RunAgentTurnOptions,
|
||||
): AgentRunHandle {
|
||||
): Promise<AgentRunHandle> {
|
||||
options.signal.throwIfAborted();
|
||||
const promptService = target.accessor.get(IAgentPromptService);
|
||||
const turn =
|
||||
request.kind === 'prompt'
|
||||
? promptService.prompt({
|
||||
? await promptService.prompt({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: request.prompt }],
|
||||
toolCalls: [],
|
||||
|
|
@ -140,7 +140,7 @@ async function distillSummary(
|
|||
|
||||
const promptService = target.accessor.get(IAgentPromptService);
|
||||
for (let attempt = 0; attempt < policy.retries; attempt++) {
|
||||
const turn = promptService.prompt({
|
||||
const turn = await promptService.prompt({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: policy.continuationPrompt }],
|
||||
toolCalls: [],
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
|
|||
runInBackground,
|
||||
});
|
||||
|
||||
const run = this.lifecycle.run(
|
||||
const run = await this.lifecycle.run(
|
||||
agentId,
|
||||
{ kind: 'prompt', prompt: promptText },
|
||||
{ signal: controller.signal },
|
||||
|
|
|
|||
|
|
@ -392,9 +392,9 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
|
|||
toolCalls: [],
|
||||
origin,
|
||||
};
|
||||
const turn = promptService.steer(message);
|
||||
void promptService.steer(message);
|
||||
this.telemetry.track(CRON_MISSED, { count: tasks.length });
|
||||
return turn;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
emitScheduled(task: CronTask): void {
|
||||
|
|
@ -448,14 +448,15 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
|
|||
origin,
|
||||
};
|
||||
this.signalRecord({ type: 'cron.fired', origin, prompt: task.prompt });
|
||||
const turn = promptService.steer(message);
|
||||
const buffered = mainHandle.accessor.get(IAgentTurnService).getActiveTurn() !== undefined;
|
||||
void promptService.steer(message);
|
||||
this.telemetry.track(CRON_FIRED, {
|
||||
recurring: task.recurring !== false,
|
||||
coalesced_count: ctx.coalescedCount,
|
||||
stale: origin.stale,
|
||||
buffered: turn === undefined,
|
||||
buffered,
|
||||
});
|
||||
return turn;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private advanceCursor(id: string, lastFiredAt: number): void {
|
||||
|
|
|
|||
|
|
@ -172,14 +172,14 @@ export class SessionSwarmService implements ISessionSwarmService {
|
|||
return this.observe(caller, child.id, profileName, request, options);
|
||||
}
|
||||
|
||||
private observe(
|
||||
private async observe(
|
||||
caller: IAgentScopeHandle,
|
||||
agentId: string,
|
||||
profileName: string,
|
||||
request: { kind: 'prompt'; prompt: string } | { kind: 'retry' },
|
||||
options: AgentRunAttemptOptions,
|
||||
): AgentRunAttemptHandle {
|
||||
const run = this.lifecycle.run(agentId, request, {
|
||||
): Promise<AgentRunAttemptHandle> {
|
||||
const run = await this.lifecycle.run(agentId, request, {
|
||||
signal: options.signal,
|
||||
onReady: options.onReady,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -82,12 +82,12 @@ describe('Cron — session E2E (P1.9)', () => {
|
|||
}> = [];
|
||||
vi.spyOn(prompt, 'steer').mockImplementation((message: ContextMessage) => {
|
||||
steerCalls.push({ content: message.content, origin: message.origin });
|
||||
return {
|
||||
return Promise.resolve({
|
||||
id: 1,
|
||||
abortController: new AbortController(),
|
||||
ready: Promise.resolve(),
|
||||
result: Promise.resolve({ reason: 'completed' as const }),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Schedule via the full tool surface — the scheduling path goes
|
||||
|
|
|
|||
|
|
@ -75,10 +75,10 @@ describe('SessionCronService', () => {
|
|||
};
|
||||
|
||||
ix.stub(IAgentPromptService, {
|
||||
prompt: () => undefined,
|
||||
prompt: () => Promise.resolve(undefined),
|
||||
steer: (message) => {
|
||||
steered.push(message);
|
||||
return fakeTurn();
|
||||
return Promise.resolve(fakeTurn());
|
||||
},
|
||||
retry: () => undefined,
|
||||
undo: () => 0,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ function createSteerSpy(
|
|||
const calls: Array<{ content: readonly ContentPart[]; origin: PromptOrigin }> = [];
|
||||
vi.spyOn(prompt, 'steer').mockImplementation((message: ContextMessage) => {
|
||||
calls.push({ content: message.content, origin: message.origin as PromptOrigin });
|
||||
return returnValue;
|
||||
return Promise.resolve(returnValue);
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
|
@ -610,7 +610,7 @@ describe('SessionCronService', () => {
|
|||
vi.spyOn(prompt, 'steer').mockImplementation((message: ContextMessage) => {
|
||||
if (shouldThrow) throw new Error('steer boom');
|
||||
delivered.push(message);
|
||||
return undefined;
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
cron.addTask({ cron: '*/5 * * * *', prompt: 'retry me' });
|
||||
harness.advance(6 * 60_000);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function createClocks(initial: number = WALL_ANCHOR): ClockHarness {
|
|||
}
|
||||
|
||||
function spySteer(prompt: IAgentPromptService) {
|
||||
return vi.spyOn(prompt, 'steer').mockImplementation((_message: ContextMessage) => ({
|
||||
return vi.spyOn(prompt, 'steer').mockImplementation((_message: ContextMessage) => Promise.resolve({
|
||||
id: 1,
|
||||
abortController: new AbortController(),
|
||||
ready: Promise.resolve(),
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ function captureSteer(prompt: IAgentPromptService): SteerCall[] {
|
|||
const calls: SteerCall[] = [];
|
||||
prompt.steer = (message: ContextMessage) => {
|
||||
calls.push({ content: message.content, origin: message.origin as PromptOrigin });
|
||||
return undefined;
|
||||
return Promise.resolve(undefined);
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
import { IAgentFullCompactionService } from '#/agent/fullCompaction';
|
||||
import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop';
|
||||
import { IAgentPermissionGate } from '#/agent/permissionGate';
|
||||
import { IAgentPromptService } from '#/agent/prompt';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
|
||||
import { IAgentTurnService, type Turn } from '#/agent/turn';
|
||||
import { IBootstrapService } from '#/app/bootstrap';
|
||||
|
|
@ -31,6 +33,7 @@ import { IPluginService } from '#/app/plugin';
|
|||
import { createHooks } from '#/hooks';
|
||||
|
||||
import { stubBootstrap } from '../bootstrap/stubs';
|
||||
import { stubRecord } from '../contextMemory/stubs';
|
||||
import { stubLoopWithHooks, stubToolExecutor, stubTurnWithHooks } from '../turn/stubs';
|
||||
|
||||
function nodeCommand(source: string): string {
|
||||
|
|
@ -156,7 +159,11 @@ describe('HookEngine integration', () => {
|
|||
reg.definePartialInstance(IConfigService, {});
|
||||
reg.definePartialInstance(IPluginService, {});
|
||||
reg.defineInstance(IAgentContextMemoryService, context);
|
||||
reg.defineInstance(IAgentRecordService, stubRecord());
|
||||
reg.defineInstance(IAgentLoopService, loop);
|
||||
reg.definePartialInstance(IAgentPromptService, {
|
||||
hooks: createHooks(['onWillSubmitPrompt']),
|
||||
});
|
||||
reg.defineInstance(IAgentTurnService, turnService);
|
||||
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
|
||||
reg.definePartialInstance(IAgentPermissionGate, {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ILogService } from '#/app/log';
|
|||
import { IAgentPromptService } from '#/agent/prompt';
|
||||
import { ISessionLifecycleService } from '#/app/sessionLifecycle';
|
||||
import { IAgentTurnService } from '#/agent/turn';
|
||||
import { createHooks } from '#/hooks';
|
||||
import { stubLog } from '../log/stubs';
|
||||
import { stubTurn } from '../turn/stubs';
|
||||
|
||||
|
|
@ -51,12 +52,13 @@ describe('RestGateway', () => {
|
|||
_serviceBrand: undefined,
|
||||
prompt: (message) => {
|
||||
promptCalls.push(message);
|
||||
return undefined;
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
steer: () => undefined,
|
||||
steer: () => Promise.resolve(undefined),
|
||||
retry: () => undefined,
|
||||
undo: () => 0,
|
||||
clear: () => {},
|
||||
hooks: createHooks(['onWillSubmitPrompt']) as IAgentPromptService['hooks'],
|
||||
};
|
||||
|
||||
const agentHandle: IAgentScopeHandle = {
|
||||
|
|
|
|||
85
packages/agent-core-v2/test/prompt/promptService.test.ts
Normal file
85
packages/agent-core-v2/test/prompt/promptService.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it, onTestFinished } from 'vitest';
|
||||
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices } from '#/_base/di/test';
|
||||
import { IAgentLoopService } from '#/agent/loop';
|
||||
import { AgentPromptService, IAgentPromptService } from '#/agent/prompt';
|
||||
import type { PromptSubmitContext } from '#/agent/prompt';
|
||||
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { IAgentTurnService } from '#/agent/turn';
|
||||
|
||||
import { stubContextMemory, stubRecord } from '../contextMemory/stubs';
|
||||
import { stubLoopWithHooks, stubTurn } from '../turn/stubs';
|
||||
|
||||
function userMessage(text: string, origin: ContextMessage['origin']): ContextMessage {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
toolCalls: [],
|
||||
origin,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const disposables = new DisposableStore();
|
||||
onTestFinished(() => disposables.dispose());
|
||||
|
||||
const context = stubContextMemory();
|
||||
const turn = stubTurn();
|
||||
const ix = createServices(disposables, {
|
||||
strict: true,
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(IAgentContextMemoryService, context);
|
||||
reg.defineInstance(IAgentTurnService, turn);
|
||||
reg.defineInstance(IAgentRecordService, stubRecord());
|
||||
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
|
||||
reg.define(IAgentPromptService, AgentPromptService);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
context,
|
||||
prompt: ix.get(IAgentPromptService),
|
||||
turn,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentPromptService', () => {
|
||||
it('runs submit hooks for any prompt and steer origin', async () => {
|
||||
const { prompt, turn } = createHarness();
|
||||
const seen: Array<Pick<PromptSubmitContext, 'isSteer'> & {
|
||||
readonly originKind: string | undefined;
|
||||
}> = [];
|
||||
|
||||
prompt.hooks.onWillSubmitPrompt.register('capture', async (ctx, next) => {
|
||||
seen.push({ isSteer: ctx.isSteer, originKind: ctx.promptMessage.origin?.kind });
|
||||
await next();
|
||||
});
|
||||
|
||||
await prompt.prompt(userMessage('from prompt', { kind: 'system_trigger', name: 'test_prompt' }));
|
||||
await prompt.steer(userMessage('from steer', { kind: 'system_trigger', name: 'test_steer' }));
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ isSteer: false, originKind: 'system_trigger' },
|
||||
{ isSteer: true, originKind: 'system_trigger' },
|
||||
]);
|
||||
expect(turn.launches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('blocks launch when the hook sets block', async () => {
|
||||
const { context, prompt, turn } = createHarness();
|
||||
|
||||
prompt.hooks.onWillSubmitPrompt.register('block', async (ctx) => {
|
||||
ctx.block = true;
|
||||
});
|
||||
|
||||
const result = await prompt.prompt(
|
||||
userMessage('blocked', { kind: 'system_trigger', name: 'test_block' }),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(turn.launches).toEqual([]);
|
||||
expect(context.messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo
|
|||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentPromptService } from '#/agent/prompt/prompt';
|
||||
import { IAgentTurnService, type Turn, type TurnResult } from '#/agent/turn/turn';
|
||||
import { createHooks } from '#/hooks';
|
||||
import type { PromptSubmission } from '@moonshot-ai/protocol';
|
||||
|
||||
import { IAgentPromptLegacyService, AgentPromptLegacyService } from '#/agent/promptLegacy';
|
||||
|
|
@ -40,7 +41,7 @@ interface Harness {
|
|||
readonly steered: string[];
|
||||
}
|
||||
|
||||
function createHarness(): Harness {
|
||||
function createHarness(options: { readonly blockPrompt?: boolean } = {}): Harness {
|
||||
const disposables = new DisposableStore();
|
||||
onTestFinished(() => disposables.dispose());
|
||||
|
||||
|
|
@ -53,7 +54,8 @@ function createHarness(): Harness {
|
|||
const prompt: IAgentPromptService = {
|
||||
_serviceBrand: undefined,
|
||||
prompt: () => {
|
||||
if (activeTurn !== undefined) return undefined;
|
||||
if (options.blockPrompt === true) return Promise.resolve(undefined);
|
||||
if (activeTurn !== undefined) return Promise.resolve(undefined);
|
||||
const { turn, settle } = controlledTurn(nextTurnId++);
|
||||
activeTurn = turn;
|
||||
activeSettle = settle;
|
||||
|
|
@ -64,17 +66,18 @@ function createHarness(): Harness {
|
|||
activeSettle = undefined;
|
||||
}
|
||||
});
|
||||
return turn;
|
||||
return Promise.resolve(turn);
|
||||
},
|
||||
steer: (message) => {
|
||||
for (const part of message.content) {
|
||||
if (part.type === 'text') steered.push(part.text);
|
||||
}
|
||||
return undefined;
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
retry: () => undefined,
|
||||
undo: () => 0,
|
||||
clear: () => {},
|
||||
hooks: createHooks(['onWillSubmitPrompt']) as IAgentPromptService['hooks'],
|
||||
};
|
||||
|
||||
const turnService: IAgentTurnService = {
|
||||
|
|
@ -136,12 +139,21 @@ describe('AgentPromptLegacyService', () => {
|
|||
expect(list.queued.map((q) => q.prompt_id)).toEqual([second.prompt_id]);
|
||||
});
|
||||
|
||||
it('reports blocked without queueing when no turn is launched', async () => {
|
||||
const { service, turns } = createHarness({ blockPrompt: true });
|
||||
const result = await service.submit(textBody('blocked'));
|
||||
expect(result.status).toBe('blocked');
|
||||
expect(turns).toHaveLength(0);
|
||||
expect(service.list()).toEqual({ active: null, queued: [] });
|
||||
});
|
||||
|
||||
it('auto-launches the next queued prompt when the active turn settles', async () => {
|
||||
const { service, turns, settleActive } = createHarness();
|
||||
await service.submit(textBody('first'));
|
||||
const second = await service.submit(textBody('second'));
|
||||
settleActive({ reason: 'completed' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(turns).toHaveLength(2);
|
||||
expect(service.list().active?.prompt_id).toBe(second.prompt_id);
|
||||
});
|
||||
|
|
@ -154,6 +166,7 @@ describe('AgentPromptLegacyService', () => {
|
|||
expect(aborted.aborted).toBe(true);
|
||||
settleActive({ reason: 'cancelled' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(turns).toHaveLength(2);
|
||||
expect(service.list().active?.prompt_id).toBe(second.prompt_id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ describe('AgentSkillService', () => {
|
|||
reg.definePartialInstance(IAgentPromptService, {
|
||||
prompt: (message) => {
|
||||
prompted.push(message);
|
||||
return fakeTurn();
|
||||
return Promise.resolve(fakeTurn());
|
||||
},
|
||||
steer: (message) => {
|
||||
prompted.push(message);
|
||||
return undefined;
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
retry: () => undefined,
|
||||
undo: () => 0,
|
||||
|
|
@ -152,11 +152,11 @@ describe('SkillTool', () => {
|
|||
reg.definePartialInstance(IAgentPromptService, {
|
||||
prompt: (message: ContextMessage) => {
|
||||
prompted.push(message);
|
||||
return fakeTurn();
|
||||
return Promise.resolve(fakeTurn());
|
||||
},
|
||||
steer: (message: ContextMessage) => {
|
||||
prompted.push(message);
|
||||
return undefined;
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
retry: () => undefined,
|
||||
undo: () => 0,
|
||||
|
|
|
|||
|
|
@ -205,7 +205,9 @@ function createAgentTaskService(options: {
|
|||
emittedEvents.push(event as { type: string; info?: unknown });
|
||||
});
|
||||
|
||||
const steerSpy = vi.spyOn(ctx.get(IAgentPromptService), 'steer').mockReturnValue(undefined);
|
||||
const steerSpy = vi
|
||||
.spyOn(ctx.get(IAgentPromptService), 'steer')
|
||||
.mockResolvedValue(undefined);
|
||||
const context = ctx.get(IAgentContextMemoryService);
|
||||
const spliceHistorySpy = vi.spyOn(context, 'splice');
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ describe('AgentTaskService', () => {
|
|||
ix.stub(IAgentToolRegistryService, {
|
||||
register: () => toDisposable(() => {}),
|
||||
});
|
||||
ix.stub(IAgentPromptService, { steer: () => undefined });
|
||||
ix.stub(IAgentPromptService, { steer: () => Promise.resolve(undefined) });
|
||||
ix.stub(IConfigRegistry, { registerSection: () => {} });
|
||||
ix.stub(IConfigService, {
|
||||
get: (() => undefined) as IConfigService['get'],
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ function makeTurn(id: number): Turn {
|
|||
function makeHooks(): IAgentTurnService['hooks'] {
|
||||
return createHooks([
|
||||
'onLaunched',
|
||||
'onWillSubmitUserPrompt',
|
||||
'onEnded',
|
||||
]) as IAgentTurnService['hooks'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -619,7 +619,7 @@ describe('Agent turn flow', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('emits a friendly model.not_configured error when no model is configured', async () => {
|
||||
it('emits a model.not_configured error when no model is configured', async () => {
|
||||
const ctx = testAgent(configServices(() => ({ providers: {} })), { autoConfigure: false });
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello without login' }] });
|
||||
|
|
@ -628,10 +628,10 @@ describe('Agent turn flow', () => {
|
|||
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello without login" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
|
||||
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
|
||||
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false } }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "Model not set", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false } }
|
||||
`);
|
||||
expect(ctx.newEvents()).toMatchInlineSnapshot(
|
||||
`[emit] error { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false }`,
|
||||
`[emit] error { "code": "model.not_configured", "message": "Model not set", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false }`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -741,7 +741,7 @@ describe('Agent turn flow', () => {
|
|||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({ reason: 'blocked' }),
|
||||
args: expect.objectContaining({ reason: 'completed' }),
|
||||
}),
|
||||
);
|
||||
expect(ctx.contextData().history).toMatchObject([
|
||||
|
|
@ -780,11 +780,17 @@ describe('Agent turn flow', () => {
|
|||
const ctx = testAgent({ hookEngine });
|
||||
ctx.configure();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'bad words here' }] });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
const result = await ctx.rpc.prompt({ input: [{ type: 'text', text: 'bad words here' }] });
|
||||
const events = ctx.newEvents();
|
||||
|
||||
const hookResult = '<hook_result hook_event="UserPromptSubmit">\nno profanity\n</hook_result>';
|
||||
expect(result).toBeUndefined();
|
||||
expect(ctx.llmCalls).toHaveLength(0);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.started',
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'hook.result',
|
||||
|
|
@ -824,31 +830,30 @@ describe('Agent turn flow', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
it('cancels while waiting for a UserPromptSubmit hook without appending stale output', async () => {
|
||||
it('ignores timed out UserPromptSubmit hook output before launching the turn', async () => {
|
||||
const hookEngine = new HookEngine([
|
||||
{
|
||||
event: 'UserPromptSubmit',
|
||||
command: 'node -e "setTimeout(() => process.stdout.write(\\"late hook\\"), 250)"',
|
||||
timeout: 5,
|
||||
timeout: 0.01,
|
||||
},
|
||||
]);
|
||||
const ctx = testAgent({ hookEngine });
|
||||
ctx.configure();
|
||||
ctx.mockNextResponse({ type: 'text', text: 'model after timeout' });
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hook will sleep' }] });
|
||||
await ctx.rpc.cancel({ turnId: 0 });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({ reason: 'cancelled' }),
|
||||
args: expect.objectContaining({ reason: 'completed' }),
|
||||
}),
|
||||
);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'assistant.delta',
|
||||
args: expect.objectContaining({ delta: expect.stringContaining('late hook') }),
|
||||
event: 'hook.result',
|
||||
}),
|
||||
);
|
||||
expect(ctx.contextData().history).toMatchObject([
|
||||
|
|
@ -857,6 +862,11 @@ describe('Agent turn flow', () => {
|
|||
content: [{ type: 'text', text: 'hook will sleep' }],
|
||||
toolCalls: [],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'model after timeout' }],
|
||||
toolCalls: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -110,13 +110,14 @@ describe('events / display re-exports', () => {
|
|||
sessionId: 'sess_1',
|
||||
promptId: 'prompt_1',
|
||||
userMessageId: 'msg_1',
|
||||
status: 'running',
|
||||
status: 'blocked',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
createdAt: '2026-06-11T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe('prompt.submitted');
|
||||
expect((parsed as { promptId: string }).promptId).toBe('prompt_1');
|
||||
expect((parsed as { status: string }).status).toBe('blocked');
|
||||
});
|
||||
|
||||
it('preserves detached on task events', () => {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,18 @@ describe('promptSubmitResultSchema', () => {
|
|||
expect(parsed.status).toBe('running');
|
||||
});
|
||||
|
||||
it('parses a blocked prompt result shape', () => {
|
||||
const parsed = promptSubmitResultSchema.parse({
|
||||
prompt_id: 'prompt_blocked',
|
||||
user_message_id: 'msg_blocked',
|
||||
status: 'blocked',
|
||||
content: [{ type: 'text', text: 'blocked' }],
|
||||
created_at: '2026-06-09T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(parsed.status).toBe('blocked');
|
||||
});
|
||||
|
||||
it('rejects empty prompt_id', () => {
|
||||
expect(
|
||||
promptSubmitResultSchema.safeParse({ prompt_id: '', user_message_id: 'msg' })
|
||||
|
|
|
|||
|
|
@ -547,7 +547,7 @@ export interface AssistantDeltaEvent {
|
|||
|
||||
export interface HookResultEvent {
|
||||
readonly type: 'hook.result';
|
||||
readonly turnId: number;
|
||||
readonly turnId?: number;
|
||||
readonly hookEvent: string;
|
||||
readonly content: string;
|
||||
readonly blocked?: boolean;
|
||||
|
|
@ -693,7 +693,7 @@ export interface PromptSubmittedEvent {
|
|||
readonly type: 'prompt.submitted';
|
||||
readonly promptId: string;
|
||||
readonly userMessageId: string;
|
||||
readonly status: 'running' | 'queued';
|
||||
readonly status: 'running' | 'queued' | 'blocked';
|
||||
readonly content: readonly MessageContent[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
|
@ -1253,7 +1253,7 @@ export const assistantDeltaEventSchema = z.object({
|
|||
|
||||
export const hookResultEventSchema = z.object({
|
||||
type: z.literal('hook.result'),
|
||||
turnId: z.number(),
|
||||
turnId: z.number().optional(),
|
||||
hookEvent: z.string(),
|
||||
content: z.string(),
|
||||
blocked: z.boolean().optional(),
|
||||
|
|
@ -1389,7 +1389,7 @@ export const promptSubmittedEventSchema = z.object({
|
|||
type: z.literal('prompt.submitted'),
|
||||
promptId: z.string(),
|
||||
userMessageId: z.string(),
|
||||
status: z.enum(['running', 'queued']),
|
||||
status: z.enum(['running', 'queued', 'blocked']),
|
||||
content: z.array(messageContentSchema),
|
||||
createdAt: isoDateTimeSchema,
|
||||
}) satisfies z.ZodType<PromptSubmittedEvent>;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
* }
|
||||
* Reply: PromptSubmitResult { prompt_id, user_message_id, status, content, created_at }
|
||||
* status='running' when sent immediately, status='queued' when
|
||||
* another prompt is already active.
|
||||
* another prompt is already active, status='blocked' when rejected
|
||||
* before a turn is launched.
|
||||
*
|
||||
* GET /v1/sessions/{sid}/prompts
|
||||
* Reply: { active: PromptItem | null, queued: PromptItem[] }
|
||||
|
|
@ -54,7 +55,7 @@ export const promptSubmissionSchema = z.object({
|
|||
});
|
||||
export type PromptSubmission = z.infer<typeof promptSubmissionSchema>;
|
||||
|
||||
export const promptStatusSchema = z.enum(['running', 'queued']);
|
||||
export const promptStatusSchema = z.enum(['running', 'queued', 'blocked']);
|
||||
export type PromptStatus = z.infer<typeof promptStatusSchema>;
|
||||
|
||||
export const promptItemSchema = z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue