refactor(agent-core-v2): turn end reason

This commit is contained in:
_Kerman 2026-07-03 10:50:12 +08:00
parent 38c2c0f527
commit c76fba4942
34 changed files with 278 additions and 113 deletions

View file

@ -805,9 +805,12 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
}
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
if (event.reason === 'filtered') {
if (event.error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
if (event.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `Prompt turn ended with reason: ${event.reason}`;
}

View file

@ -196,14 +196,17 @@ export class BtwPanelController {
}
function formatBtwTurnEnd(event: TurnEndedEvent): string {
if (event.error !== undefined) {
return `[${event.error.code}] ${event.error.message}`;
}
if (event.reason === 'cancelled') {
return 'Interrupted by user';
}
if (event.reason === 'filtered') {
if (event.error?.code === 'provider.filtered') {
return 'Provider safety policy blocked the response.';
}
if (event.error !== undefined) {
return `[${event.error.code}] ${event.error.message}`;
}
if (event.reason === 'blocked') {
return 'Prompt hook blocked the request.';
}
return `BTW turn ended with reason: ${event.reason}`;
}

View file

@ -333,9 +333,12 @@ export class SessionEventHandler {
if (event.reason === 'cancelled') {
this.markActiveAgentSwarmsCancelled();
}
if (event.reason === 'filtered') {
if (event.reason === 'failed' && event.error?.code === 'provider.filtered') {
this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error');
}
if (event.reason === 'blocked') {
this.host.showStatus('Turn stopped: prompt hook blocked the request.', 'error');
}
const todos = this.host.state.todoPanel.getTodos();
if (todos.length > 0 && todos.every((t) => t.status === 'done')) {
this.host.streamingUI.setTodoList([]);

View file

@ -1,7 +1,4 @@
import {
isKimiError,
type KimiErrorPayload,
} from '@moonshot-ai/kimi-code-sdk';
import { isKimiError } from '@moonshot-ai/kimi-code-sdk';
import {
STREAMING_ARGS_FIELD_RE,
@ -103,9 +100,13 @@ export function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function formatErrorPayload(
error: Pick<KimiErrorPayload, 'code' | 'message' | 'details'>,
): string {
interface ErrorPayloadLike {
readonly code: string;
readonly message: string;
readonly details?: Record<string, unknown>;
}
export function formatErrorPayload(error: ErrorPayloadLike): string {
const filteredMessage = formatProviderFilteredMessage(error.details);
if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`;
return `[${error.code}] ${error.message}`;

View file

@ -862,7 +862,13 @@ describe('runPrompt', () => {
mocks.mainEvent({
type: 'turn.ended',
turnId: 2,
reason: 'filtered',
reason: 'failed',
error: {
code: 'provider.filtered',
message: 'Provider safety policy blocked the response.',
name: 'ProviderFilteredError',
retryable: false,
},
}),
);
}

View file

@ -930,7 +930,7 @@ export function createAgentProjector(): AgentProjector {
sessionId,
messageId: msgId,
content: msg.content.map((c) => ({ ...c })),
status: reason === 'failed' || reason === 'filtered' ? 'error' : 'completed',
status: reason === 'failed' || reason === 'blocked' ? 'error' : 'completed',
durationMs,
});
}
@ -941,7 +941,9 @@ export function createAgentProjector(): AgentProjector {
out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot });
const newStatus =
reason === 'cancelled' || reason === 'failed' || reason === 'filtered' ? 'aborted' : 'idle';
reason === 'cancelled' || reason === 'failed' || reason === 'blocked'
? 'aborted'
: 'idle';
out.push({
type: 'sessionStatusChanged',
sessionId,

View file

@ -56,21 +56,27 @@ export function assistantDeltaToSessionUpdate(
* belong on the JSON-RPC error channel). Returning `end_turn` keeps the
* client unblocked; the caller is expected to log the `error` payload
* separately so the failure is observable in the agent logs.
* `filtered` `refusal`: the provider's safety policy blocked the
* response. ACP's `refusal` stop reason is the native signal for a
* model/provider decline, so the client can render the block instead of
* mistaking it for a clean `end_turn`. The caller additionally logs the
* block so it stays observable in the agent logs.
* `failed` + `provider.filtered` `refusal`: the provider's safety policy
* blocked the response. ACP's `refusal` stop reason is the native signal
* for a model/provider decline, so the client can render the block instead
* of mistaking it for a clean `end_turn`.
* `blocked` `refusal`: a prompt hook blocked the turn before the model
* ran. ACP has no separate hook-blocked terminal state, so reuse the
* refusal channel instead of reporting a clean `end_turn`.
*/
export function turnEndReasonToStopReason(reason: TurnEndReason): AcpStopReason {
export function turnEndReasonToStopReason(
reason: TurnEndReason,
error?: { readonly code: string },
): AcpStopReason {
switch (reason) {
case 'completed':
return 'end_turn';
case 'cancelled':
return 'cancelled';
case 'failed':
if (error?.code === 'provider.filtered') return 'refusal';
return 'end_turn';
case 'filtered':
case 'blocked':
return 'refusal';
}
}

View file

@ -16,7 +16,6 @@ import {
type BackgroundTaskInfo,
type ContextMessage,
type Event,
type KimiErrorPayload,
type KimiHarness,
type McpServerInfo,
type QuestionAnswers,
@ -1151,12 +1150,15 @@ export class AcpSession {
return;
}
} else {
if (event.reason === 'filtered') {
// The provider's safety policy blocked the response. It is
// mapped to ACP `refusal` (see turnEndReasonToStopReason); log
// it here too so the block stays observable in the agent logs,
// mirroring the `failed` branch above.
log.warn('acp: turn ended with filtered reason', { sessionId });
if (event.reason === 'blocked') {
// Provider safety and prompt hooks both map to ACP `refusal`
// (see turnEndReasonToStopReason); log them here too so the
// block stays observable in the agent logs, mirroring the
// `failed` branch above.
log.warn('acp: turn ended with blocked reason', {
reason: event.reason,
sessionId,
});
}
argsByToolCall.clear();
startedToolCalls.clear();
@ -1166,7 +1168,7 @@ export class AcpSession {
this.currentTurnId = undefined;
unsub();
}
resolve({ stopReason: turnEndReasonToStopReason(event.reason) });
resolve({ stopReason: turnEndReasonToStopReason(event.reason, event.error) });
}
});
@ -1514,7 +1516,9 @@ function mapPromptError(err: unknown, sessionId: string): RequestError {
* `turn.ended` event hands us a serialized payload (no class identity
* to branch on) we only need the `code` discriminator here.
*/
function authRequiredFromPayload(payload: KimiErrorPayload | undefined): RequestError | undefined {
function authRequiredFromPayload(
payload: { readonly code: unknown } | undefined,
): RequestError | undefined {
if (!payload) return undefined;
if (isAuthErrorCode(payload.code)) {
return RequestError.authRequired();

View file

@ -260,18 +260,31 @@ describe('AcpServer error mapping', () => {
expect(response.stopReason).toBe('cancelled');
});
it('maps the filtered turn-end reason to ACP stopReason refusal', () => {
it('maps blocked turn-end reasons to ACP stopReason refusal', () => {
// ACP has a native `refusal` stop reason that matches a provider safety
// block; mapping filtered to anything else (e.g. end_turn) would let the
// client mistake the block for a clean turn.
expect(turnEndReasonToStopReason('filtered')).toBe('refusal');
// block or prompt-hook block; mapping either to anything else (e.g.
// end_turn) would let the client mistake the block for a clean turn.
expect(turnEndReasonToStopReason('failed', { code: 'provider.filtered' })).toBe('refusal');
expect(turnEndReasonToStopReason('blocked')).toBe('refusal');
});
it('resolves with refusal when turn.ended reason is filtered', async () => {
it('resolves with refusal when turn.ended fails with provider.filtered', async () => {
const sessionId = 'sess-filtered';
const { session, unsubscribeCount } = makeScriptedSession(sessionId, {
script: [
{ type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'filtered' } as Event,
{
type: 'turn.ended',
sessionId,
agentId: 'main',
turnId: 1,
reason: 'failed',
error: {
code: 'provider.filtered',
message: 'Provider safety policy blocked the response.',
name: 'ProviderFilteredError',
retryable: false,
},
} as Event,
],
});

View file

@ -33,6 +33,7 @@ export interface CodedErrorShape {
}
const PROVIDER_API_ERROR: ErrorCode = 'provider.api_error';
const PROVIDER_FILTERED: ErrorCode = 'provider.filtered';
const PROVIDER_RATE_LIMIT: ErrorCode = 'provider.rate_limit';
const PROVIDER_AUTH_ERROR: ErrorCode = 'provider.auth_error';
const PROVIDER_CONNECTION_ERROR: ErrorCode = 'provider.connection_error';
@ -101,7 +102,8 @@ export function toErrorPayload(error: unknown): ErrorPayload {
return makeErrorPayload(PROVIDER_CONNECTION_ERROR, error.message, { name: error.name });
}
if (error instanceof APIEmptyResponseError) {
return makeErrorPayload(PROVIDER_API_ERROR, error.message, {
const code = error.finishReason === 'filtered' ? PROVIDER_FILTERED : PROVIDER_API_ERROR;
return makeErrorPayload(code, error.message, {
name: error.name,
details: {
finishReason: error.finishReason,

View file

@ -375,13 +375,16 @@ async function completeSummary(
}
function classifyTurnResult(result: { reason: string; error?: unknown }): void {
if (result.reason === 'filtered') {
throw new Error('Subagent turn blocked by provider safety policy');
if (result.reason === 'blocked') {
throw new Error('Subagent turn blocked by prompt hook');
}
if (result.reason === 'failed') {
const error = result.error;
if (isProviderRateLimitError(error)) throw error;
const payload = toKimiErrorPayload(error);
if (payload.code === ErrorCodes.PROVIDER_FILTERED) {
throw new Error('Subagent turn blocked by provider safety policy');
}
if (payload.code === ErrorCodes.PROVIDER_RATE_LIMIT) {
throw providerRateLimitErrorFromPayload(payload);
}

View file

@ -144,7 +144,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
private readonly goalDrivenTurns = new Set<number>();
private readonly countedGoalTurns = new Set<number>();
private readonly goalOutcomeContinuationTurns = new Set<number>();
private readonly promptHookBlockedTurns = new Set<number>();
constructor(
private readonly options: GoalServiceOptions = {},
@ -225,13 +224,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
await this.handleTurnEnded(ctx);
}),
);
this._register(
this.record.on((event) => {
if (event.type === 'hook.result' && event.blocked === true) {
this.promptHookBlockedTurns.add(event.turnId);
}
}),
);
}
get enabled(): boolean {
@ -454,7 +446,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
const turnId = turn.id;
if (this.state?.status === 'active') this.goalDrivenTurns.add(turnId);
this.goalOutcomeContinuationTurns.delete(turnId);
this.promptHookBlockedTurns.delete(turnId);
}
private async handleBeforeStep(ctx: TurnBeforeStepContext): Promise<void> {
@ -487,8 +478,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
this.countedGoalTurns.delete(turnId);
this.goalOutcomeContinuationTurns.delete(turnId);
const blockedByPromptHook = this.promptHookBlockedTurns.delete(turnId);
if (blockedByPromptHook) {
if (ctx.result.reason === 'blocked') {
await this.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' });
return;
}
@ -501,10 +491,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
await this.pauseActiveGoal({ reason: goalFailurePauseReason(ctx.result.error) });
return;
}
if (ctx.result.reason === 'filtered') {
await this.pauseActiveGoal({ reason: GOAL_PROVIDER_FILTERED_PAUSE_REASON });
return;
}
if (this.state?.status !== 'active') return;
if (this.blockIfBudgetReached(this.state) !== null) return;
@ -797,6 +783,8 @@ function goalFailurePauseReason(error: unknown): string {
return pauseReasonWithMessage(GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, payload.message);
case ErrorCodes.PROVIDER_AUTH_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_AUTH_PAUSE_PREFIX, payload.message);
case ErrorCodes.PROVIDER_FILTERED:
return GOAL_PROVIDER_FILTERED_PAUSE_REASON;
case ErrorCodes.PROVIDER_API_ERROR:
return pauseReasonWithMessage(GOAL_PROVIDER_API_PAUSE_PREFIX, payload.message);
case ErrorCodes.MODEL_NOT_CONFIGURED:

View file

@ -1,9 +1,10 @@
import { createDecorator } from "#/_base/di";
import type { TurnEndReason } from '@moonshot-ai/protocol';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
import type { Hooks } from '#/hooks';
export interface TurnResult {
readonly reason: 'completed' | 'cancelled' | 'failed' | 'filtered';
readonly reason: TurnEndReason;
readonly error?: unknown;
}

View file

@ -1,6 +1,6 @@
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { toKimiErrorPayload, type KimiErrorPayload } from "#/errors";
import { ErrorCodes, KimiError, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
import { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
import { OrderedHookSlot } from '#/hooks';
@ -208,7 +208,7 @@ export class AgentTurnService implements IAgentTurnService {
content: hookResult.message,
blocked: true,
});
return { reason: 'completed' };
return { reason: 'blocked' };
}
if (hookResult?.action === 'append') {
@ -286,7 +286,17 @@ function toAgentTurnResult(result: LoopTurnResult, signal: AbortSignal): TurnRes
return { reason: 'cancelled', error: signal.reason };
}
if (result.stopReason === 'filtered') {
return { reason: 'filtered' };
return {
reason: 'failed',
error: new KimiError(
ErrorCodes.PROVIDER_FILTERED,
'Provider safety policy blocked the response.',
{
name: 'ProviderFilteredError',
details: { finishReason: 'filtered' },
},
),
};
}
return { reason: 'completed' };
}

View file

@ -11,6 +11,7 @@ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors';
export const ProtocolErrors = {
codes: {
PROVIDER_API_ERROR: 'provider.api_error',
PROVIDER_FILTERED: 'provider.filtered',
PROVIDER_RATE_LIMIT: 'provider.rate_limit',
PROVIDER_AUTH_ERROR: 'provider.auth_error',
PROVIDER_CONNECTION_ERROR: 'provider.connection_error',
@ -23,6 +24,12 @@ export const ProtocolErrors = {
public: true,
action: 'Retry after the provider rate limit resets.',
},
'provider.filtered': {
title: 'Provider filtered response',
retryable: false,
public: true,
action: 'Revise the prompt or model configuration to avoid provider safety filtering.',
},
'provider.auth_error': {
title: 'Provider authentication failed',
retryable: false,

View file

@ -46,7 +46,11 @@ export class SessionActivity implements ISessionActivity {
private hasAbortedTurn(): boolean {
for (const handle of this.agents.list()) {
const reason = handle.accessor.get(IAgentTurnService).lastEndedReason();
if (reason === 'cancelled' || reason === 'failed' || reason === 'filtered') {
if (
reason === 'cancelled' ||
reason === 'failed' ||
reason === 'blocked'
) {
return true;
}
}

View file

@ -487,7 +487,17 @@ describe('runChildAgent', () => {
it('classifies a filtered turn as a provider safety policy block', async () => {
const parent = fakeScope(CALLER_AGENT_ID);
const child = fakeScope('child', { result: Promise.resolve({ reason: 'filtered' }) });
const child = fakeScope('child', {
result: Promise.resolve({
reason: 'failed',
error: {
code: 'provider.filtered',
message: 'Provider safety policy blocked the response.',
name: 'ProviderFilteredError',
retryable: false,
},
}),
});
const agents = makeAgents(parent, { child });
const handle = await spawnChildAgent({

View file

@ -700,14 +700,7 @@ describe('AgentGoalService core workflow hooks', () => {
const turn = makeTurn(5);
await turnService.hooks.onLaunched.run({ turn });
eventSink.emit({
type: 'hook.result',
turnId: turn.id,
hookEvent: 'UserPromptSubmit',
content: 'blocked',
blocked: true,
});
await endTurn(turnService, turn);
await endTurn(turnService, turn, { reason: 'blocked' });
expect(goals.getGoal().goal).toMatchObject({
status: 'blocked',

View file

@ -87,7 +87,8 @@ describe('Agent loop', () => {
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "blocked" } ], "toolCalls": [], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "filtered" }
[emit] turn.ended { "turnId": 0, "reason": "filtered" }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "provider.filtered", "message": "Provider safety policy blocked the response.", "name": "ProviderFilteredError", "details": { "finishReason": "filtered", "turnId": 0 }, "retryable": false } }
[emit] error { "code": "provider.filtered", "message": "Provider safety policy blocked the response.", "name": "ProviderFilteredError", "details": { "finishReason": "filtered", "turnId": 0 }, "retryable": false }
`);
const rpcStepEnd = ctx.allEvents.find(

View file

@ -516,7 +516,7 @@ describe('Agent turn flow', () => {
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: 'provider.api_error',
code: 'provider.filtered',
name: 'APIEmptyResponseError',
details: expect.objectContaining({
finishReason: 'filtered',
@ -532,7 +532,7 @@ describe('Agent turn flow', () => {
type: '[rpc]',
event: 'error',
args: expect.objectContaining({
code: 'provider.api_error',
code: 'provider.filtered',
name: 'APIEmptyResponseError',
details: expect.objectContaining({
finishReason: 'filtered',
@ -544,7 +544,7 @@ describe('Agent turn flow', () => {
);
});
it('ends the turn with reason filtered when the provider filters a non-empty response', async () => {
it('ends the turn with a provider.filtered error when the provider filters a non-empty response', async () => {
const generate: GenerateFn = async () => ({
id: null,
message: {
@ -575,7 +575,14 @@ describe('Agent turn flow', () => {
type: '[rpc]',
event: 'turn.ended',
args: expect.objectContaining({
reason: 'filtered',
reason: 'failed',
error: expect.objectContaining({
code: 'provider.filtered',
details: expect.objectContaining({
finishReason: 'filtered',
turnId: 0,
}),
}),
}),
}),
);
@ -707,6 +714,12 @@ describe('Agent turn flow', () => {
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'blocked' }),
}),
);
expect(ctx.contextData().history).toMatchObject([
{
role: 'user',

View file

@ -328,7 +328,7 @@ export class TurnFlow {
goalBecameActive &&
end.event.reason !== 'cancelled' &&
end.event.reason !== 'failed' &&
end.event.reason !== 'filtered'
end.event.reason !== 'blocked'
) {
return await this.driveGoal(
this.allocateTurnId(),
@ -387,11 +387,7 @@ export class TurnFlow {
await this.agent.goal.pauseActiveGoal({ reason: goalFailurePauseReason(end.event.error) });
return end;
}
if (end.event.reason === 'filtered') {
await this.agent.goal.pauseActiveGoal({ reason: GOAL_PROVIDER_FILTERED_PAUSE_REASON });
return end;
}
if (end.blockedByUserPromptHook === true) {
if (end.event.reason === 'blocked' || end.blockedByUserPromptHook === true) {
await this.agent.goal.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' });
return end;
}
@ -476,14 +472,25 @@ export class TurnFlow {
} else {
const stopReason = await this.runStepLoop(turnId, signal);
completedStopReason = stopReason;
const reason: TurnEndReason =
stopReason === 'aborted' ? 'cancelled' : stopReason === 'filtered' ? 'filtered' : 'completed';
ended = {
type: 'turn.ended',
turnId,
reason,
durationMs: Date.now() - startedAt,
};
if (stopReason === 'filtered') {
const summary = providerFilteredPayload(turnId);
ended = {
type: 'turn.ended',
turnId,
reason: 'failed',
error: summary,
durationMs: Date.now() - startedAt,
};
errorEvent = { type: 'error', ...summary };
} else {
const reason: TurnEndReason = stopReason === 'aborted' ? 'cancelled' : 'completed';
ended = {
type: 'turn.ended',
turnId,
reason,
durationMs: Date.now() - startedAt,
};
}
}
} catch (error) {
if (isAbortError(error)) {
@ -606,7 +613,7 @@ export class TurnFlow {
// The terminal turn.ended is emitted by runOneTurn (synchronously with the
// activeTurn clear), not here, so the session is idle the moment it fires.
return {
event: { type: 'turn.ended', turnId, reason: 'completed', durationMs: Date.now() - startedAt },
event: { type: 'turn.ended', turnId, reason: 'blocked', durationMs: Date.now() - startedAt },
blocked: true,
};
}
@ -1091,7 +1098,17 @@ function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload {
return { ...payload, details };
}
function goalFailurePauseReason(error: KimiErrorPayload | undefined): string {
function providerFilteredPayload(turnId: number): KimiErrorPayload {
return {
code: ErrorCodes.PROVIDER_FILTERED,
message: 'Provider safety policy blocked the response.',
name: 'ProviderFilteredError',
details: { finishReason: 'filtered', turnId },
retryable: false,
};
}
function goalFailurePauseReason(error: TurnEndedEvent['error']): string {
if (error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) return GOAL_RATE_LIMIT_PAUSE_REASON;
if (error?.code === ErrorCodes.PROVIDER_CONNECTION_ERROR) {
return pauseReasonWithMessage(GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, error.message);
@ -1099,6 +1116,9 @@ function goalFailurePauseReason(error: KimiErrorPayload | undefined): string {
if (error?.code === ErrorCodes.PROVIDER_AUTH_ERROR) {
return pauseReasonWithMessage(GOAL_PROVIDER_AUTH_PAUSE_PREFIX, error.message);
}
if (error?.code === ErrorCodes.PROVIDER_FILTERED) {
return GOAL_PROVIDER_FILTERED_PAUSE_REASON;
}
if (error?.code === ErrorCodes.PROVIDER_API_ERROR) {
return pauseReasonWithMessage(GOAL_PROVIDER_API_PAUSE_PREFIX, error.message);
}

View file

@ -49,6 +49,7 @@ export const ErrorCodes = {
CONTEXT_OVERFLOW: 'context.overflow',
LOOP_MAX_STEPS_EXCEEDED: 'loop.max_steps_exceeded',
PROVIDER_API_ERROR: 'provider.api_error',
PROVIDER_FILTERED: 'provider.filtered',
PROVIDER_RATE_LIMIT: 'provider.rate_limit',
PROVIDER_AUTH_ERROR: 'provider.auth_error',
PROVIDER_CONNECTION_ERROR: 'provider.connection_error',
@ -309,6 +310,12 @@ export const KIMI_ERROR_INFO = {
public: true,
action: 'Inspect details.statusCode / details.requestId; check provider status.',
},
'provider.filtered': {
title: 'Provider filtered response',
retryable: false,
public: true,
action: 'Revise the prompt or model configuration to avoid provider safety filtering.',
},
'provider.rate_limit': {
title: 'Provider rate limit',
retryable: true,

View file

@ -104,15 +104,19 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload {
}
if (error instanceof APIEmptyResponseError) {
const code =
error.finishReason === 'filtered'
? ErrorCodes.PROVIDER_FILTERED
: ErrorCodes.PROVIDER_API_ERROR;
return {
code: ErrorCodes.PROVIDER_API_ERROR,
code,
message: error.message,
name: error.name,
details: {
finishReason: error.finishReason,
rawFinishReason: error.rawFinishReason,
},
retryable: KIMI_ERROR_INFO[ErrorCodes.PROVIDER_API_ERROR].retryable,
retryable: KIMI_ERROR_INFO[code].retryable,
};
}

View file

@ -18,7 +18,7 @@
* later abort can pass the correct numeric `turnId` to
* `core.rpc.cancel({turnId})`).
* - capture `turn.ended` for the prompt's top-level turn SYNTHESIZE a
* `prompt.completed` (reason='completed' or 'failed') or
* `prompt.completed` (reason='completed', 'failed', or 'blocked') or
* `prompt.aborted` (reason='cancelled') event. The event service then
* broadcasts these. agent-core's event union has no prompt-level
* types.
@ -215,7 +215,7 @@ export interface IPromptService {
/**
* VSCode-style accessor for `prompt.completed` synthetic events. The
* listener fires synchronously when a top-level `turn.ended`
* (reason='completed'|'failed') is synthesised into a prompt-lifecycle
* (reason='completed'|'failed'|'blocked') is synthesised into a prompt-lifecycle
* event, BEFORE `bus.publish(synth)`.
*
* Returns an `IDisposable`. Owners stash it via
@ -309,7 +309,7 @@ export interface SyntheticPromptCompletedEvent {
readonly sessionId: string;
readonly promptId: string;
readonly finishedAt: string;
readonly reason: 'completed' | 'failed';
readonly reason: 'completed' | 'failed' | 'blocked';
}
/**

View file

@ -97,7 +97,7 @@ interface PromptState {
body: PromptSubmission;
createdAt: string;
turnId: number | null;
/** Set on `turn.ended` for the top-level turn (reason='completed'|'failed'|'filtered'). */
/** Set on `turn.ended` for the top-level turn (reason='completed'|'failed'|'blocked'). */
completed: boolean;
/** Set on `turn.ended` with reason='cancelled' or after a successful abort RPC. */
aborted: boolean;
@ -205,7 +205,7 @@ function isTurnStarted(e: Event): e is Event & { type: 'turn.started'; turnId: n
function isTurnEnded(e: Event): e is Event & {
type: 'turn.ended';
turnId: number;
reason: 'completed' | 'cancelled' | 'failed' | 'filtered';
reason: 'completed' | 'cancelled' | 'failed' | 'blocked';
} {
return (e as { type?: string }).type === 'turn.ended';
}
@ -853,7 +853,7 @@ export class PromptService
sessionId: sid,
promptId: state.promptId,
finishedAt: new Date().toISOString(),
reason: reason === 'failed' || reason === 'filtered' ? 'failed' : 'completed',
reason: reason === 'failed' || reason === 'blocked' ? reason : 'completed',
};
this._active.delete(key);
// Fire typed listeners BEFORE publishing the synth event.

View file

@ -209,7 +209,7 @@ export class SessionService extends Disposable implements ISessionService {
case 'turn.ended': {
this._activeTurns.delete(sessionId);
const reason = (event as { reason?: string }).reason;
if (reason === 'cancelled' || reason === 'failed' || reason === 'filtered') {
if (reason === 'cancelled' || reason === 'failed' || reason === 'blocked') {
this._abortedTurns.add(sessionId);
} else {
this._abortedTurns.delete(sessionId);

View file

@ -6,7 +6,7 @@ import {
import type { Agent } from '../agent';
import type { PromptOrigin } from '../agent/context';
import { ErrorCodes, type KimiErrorPayload } from '../errors';
import { ErrorCodes } from '../errors';
import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all';
import { InMemoryAgentRecordPersistence } from '../agent/records';
import { isAbortError } from '../loop/errors';
@ -469,7 +469,7 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom
const completion = await child.turn.waitForCurrentTurn(signal);
const turnEnded = completion.event;
if (turnEnded.reason !== 'completed') {
if (turnEnded.reason === 'filtered') {
if (turnEnded.error?.code === ErrorCodes.PROVIDER_FILTERED) {
throw new Error('Subagent turn blocked by provider safety policy');
}
if (turnEnded.error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) {
@ -486,7 +486,10 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom
}
}
function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError {
function providerRateLimitErrorFromPayload(error: {
readonly message: string;
readonly details?: Record<string, unknown>;
}): APIProviderRateLimitError {
const requestId =
typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null;
return new APIProviderRateLimitError(error.message, requestId);

View file

@ -425,7 +425,7 @@ describe('Agent turn flow', () => {
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: 'provider.api_error',
code: 'provider.filtered',
name: 'APIEmptyResponseError',
details: expect.objectContaining({
finishReason: 'filtered',
@ -441,7 +441,7 @@ describe('Agent turn flow', () => {
type: '[rpc]',
event: 'error',
args: expect.objectContaining({
code: 'provider.api_error',
code: 'provider.filtered',
name: 'APIEmptyResponseError',
details: expect.objectContaining({
finishReason: 'filtered',
@ -453,7 +453,7 @@ describe('Agent turn flow', () => {
);
});
it('ends the turn with reason filtered when the provider filters a non-empty response', async () => {
it('ends the turn with a provider.filtered error when the provider filters a non-empty response', async () => {
const generate: GenerateFn = async () => ({
id: null,
message: {
@ -484,7 +484,14 @@ describe('Agent turn flow', () => {
type: '[rpc]',
event: 'turn.ended',
args: expect.objectContaining({
reason: 'filtered',
reason: 'failed',
error: expect.objectContaining({
code: 'provider.filtered',
details: expect.objectContaining({
finishReason: 'filtered',
turnId: 0,
}),
}),
}),
}),
);
@ -670,6 +677,12 @@ describe('Agent turn flow', () => {
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'blocked' }),
}),
);
expect(ctx.agent.context.data().history).toEqual([
{
role: 'user',

View file

@ -511,7 +511,11 @@ describe('goal session end-to-end', () => {
expect(goal?.status).toBe('paused');
expect(goal?.terminalReason).toBe('Paused after provider safety policy block');
expect(events).toContainEqual(
expect.objectContaining({ type: 'turn.ended', reason: 'filtered' }),
expect.objectContaining({
type: 'turn.ended',
reason: 'failed',
error: expect.objectContaining({ code: 'provider.filtered' }),
}),
);
});

View file

@ -701,6 +701,38 @@ describe('PromptService lifecycle synthesis (via IEventService.onDidPublish)', (
expect(impl._activeForTest(SID)).toBeUndefined();
});
it('preserves blocked reason when synthesizing prompt.completed', async () => {
const { bridge } = makeBridge();
const { bus, events, triggerSubscribers } = makeBus();
const impl = newSvc(bridge, bus);
const submit = await impl.submit(SID, mkBody());
triggerSubscribers({
type: 'turn.started',
turnId: 7,
origin: { kind: 'user' },
sessionId: SID,
agentId: 'main',
} as unknown as Event);
events.length = 0;
triggerSubscribers({
type: 'turn.ended',
turnId: 7,
reason: 'blocked',
sessionId: SID,
agentId: 'main',
} as unknown as Event);
expect(events).toHaveLength(1);
const synth = events[0] as unknown as {
type: string;
promptId: string;
reason: string;
};
expect(synth.type).toBe('prompt.completed');
expect(synth.promptId).toBe(submit.prompt_id);
expect(synth.reason).toBe('blocked');
expect(impl._activeForTest(SID)).toBeUndefined();
});
it('fires onDidComplete listener before bus.publish', async () => {
const { bridge } = makeBridge();
const { bus, events, triggerSubscribers } = makeBus();

View file

@ -1074,6 +1074,13 @@ describe('SessionService status lifecycle', () => {
expect((await svc.get(session.id)).status).toBe('aborted');
});
it('turn.ended with blocked moves status to aborted', async () => {
const session = await svc.create({ metadata: { cwd: '/tmp/blocked' } });
eventBus.eventService.publish({ type: 'turn.started', sessionId: session.id } as unknown as Event);
eventBus.eventService.publish({ type: 'turn.ended', sessionId: session.id, reason: 'blocked' } as unknown as Event);
expect((await svc.get(session.id)).status).toBe('aborted');
});
it('prompt.submitted moves status to running when a current prompt exists', async () => {
const session = await svc.create({ metadata: { cwd: '/tmp/prompt' } });
promptStub.activePromptIds.set(session.id, 'p1');

View file

@ -230,6 +230,7 @@ export type KimiErrorCode =
| 'context.overflow'
| 'loop.max_steps_exceeded'
| 'provider.api_error'
| 'provider.filtered'
| 'provider.rate_limit'
| 'provider.auth_error'
| 'provider.connection_error'
@ -339,7 +340,7 @@ export interface McpOAuthAuthorizationUrlUpdateData {
readonly authorizationUrl: string;
}
export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'filtered';
export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked';
export interface AgentStatusUpdatedEvent {
readonly type: 'agent.status.updated';
@ -960,6 +961,7 @@ export const kimiErrorCodeSchema = z.enum([
'context.overflow',
'loop.max_steps_exceeded',
'provider.api_error',
'provider.filtered',
'provider.rate_limit',
'provider.auth_error',
'provider.connection_error',
@ -1058,7 +1060,7 @@ export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({
authorizationUrl: z.string(),
}) satisfies z.ZodType<McpOAuthAuthorizationUrlUpdateData>;
export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'filtered']) satisfies z.ZodType<TurnEndReason>;
export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType<TurnEndReason>;
export const agentStatusUpdatedEventSchema = z.object({
type: z.literal('agent.status.updated'),

View file

@ -83,7 +83,7 @@ export class ApprovalService extends Disposable implements IApprovalService {
this.eventService.onDidPublish((event) => {
if ((event as { type?: string }).type !== 'turn.ended') return;
const reason = (event as { reason?: string }).reason;
if (reason !== 'cancelled' && reason !== 'failed' && reason !== 'filtered') return;
if (reason !== 'cancelled' && reason !== 'failed' && reason !== 'blocked') return;
const sessionId = (event as { sessionId?: string }).sessionId;
if (sessionId === undefined || sessionId === '') return;
this.dismissForSession(sessionId);

View file

@ -304,7 +304,7 @@ export class SnapshotService extends Disposable implements ISnapshotService {
case 'turn.ended': {
this._activeTurns.delete(sessionId);
const reason = (event as { reason?: string }).reason;
if (reason === 'cancelled' || reason === 'failed') {
if (reason === 'cancelled' || reason === 'failed' || reason === 'blocked') {
this._abortedTurns.add(sessionId);
} else {
this._abortedTurns.delete(sessionId);