diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 79a301ba1..17ffe6e8f 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -805,9 +805,12 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } { } function formatTurnEndedFailure(event: Extract): 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}`; } diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index d984f7082..aa99e05c5 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -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}`; } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 33cec0ff2..075e027e7 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -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([]); diff --git a/apps/kimi-code/src/tui/utils/event-payload.ts b/apps/kimi-code/src/tui/utils/event-payload.ts index 2508996ea..088409ffd 100644 --- a/apps/kimi-code/src/tui/utils/event-payload.ts +++ b/apps/kimi-code/src/tui/utils/event-payload.ts @@ -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, -): string { +interface ErrorPayloadLike { + readonly code: string; + readonly message: string; + readonly details?: Record; +} + +export function formatErrorPayload(error: ErrorPayloadLike): string { const filteredMessage = formatProviderFilteredMessage(error.details); if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`; return `[${error.code}] ${error.message}`; diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 0d1fc3ea1..285bc52ba 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -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, + }, }), ); } diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 5a55282b4..f0f4bc3f8 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -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, diff --git a/packages/acp-adapter/src/events-map.ts b/packages/acp-adapter/src/events-map.ts index ac160051c..0448f2eb9 100644 --- a/packages/acp-adapter/src/events-map.ts +++ b/packages/acp-adapter/src/events-map.ts @@ -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'; } } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index a0f1e3d7c..f92a74319 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -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(); diff --git a/packages/acp-adapter/test/error-mapping.test.ts b/packages/acp-adapter/test/error-mapping.test.ts index 8f5f1ee5c..f05bfef12 100644 --- a/packages/acp-adapter/test/error-mapping.test.ts +++ b/packages/acp-adapter/test/error-mapping.test.ts @@ -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, ], }); diff --git a/packages/agent-core-v2/src/_base/errors/serialize.ts b/packages/agent-core-v2/src/_base/errors/serialize.ts index cca302a7b..d054183c1 100644 --- a/packages/agent-core-v2/src/_base/errors/serialize.ts +++ b/packages/agent-core-v2/src/_base/errors/serialize.ts @@ -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, diff --git a/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts b/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts index 2a3a5af2b..9e7003c51 100644 --- a/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts +++ b/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts @@ -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); } diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index d0b36ef01..ce9b3cfab 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -144,7 +144,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private readonly goalDrivenTurns = new Set(); private readonly countedGoalTurns = new Set(); private readonly goalOutcomeContinuationTurns = new Set(); - private readonly promptHookBlockedTurns = new Set(); 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 { @@ -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: diff --git a/packages/agent-core-v2/src/agent/turn/turn.ts b/packages/agent-core-v2/src/agent/turn/turn.ts index 266004978..9caa1597a 100644 --- a/packages/agent-core-v2/src/agent/turn/turn.ts +++ b/packages/agent-core-v2/src/agent/turn/turn.ts @@ -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; } diff --git a/packages/agent-core-v2/src/agent/turn/turnService.ts b/packages/agent-core-v2/src/agent/turn/turnService.ts index 77ad65de6..ce4752957 100644 --- a/packages/agent-core-v2/src/agent/turn/turnService.ts +++ b/packages/agent-core-v2/src/agent/turn/turnService.ts @@ -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' }; } diff --git a/packages/agent-core-v2/src/app/protocol/errors.ts b/packages/agent-core-v2/src/app/protocol/errors.ts index da3054e0c..ae1e28f99 100644 --- a/packages/agent-core-v2/src/app/protocol/errors.ts +++ b/packages/agent-core-v2/src/app/protocol/errors.ts @@ -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, diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts index 8a25bf8a7..43a85ad57 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts @@ -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; } } diff --git a/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts b/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts index affb1fa42..fdee48561 100644 --- a/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts +++ b/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts @@ -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({ diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts index 81315bd1c..d29e77bdd 100644 --- a/packages/agent-core-v2/test/goal/goal.test.ts +++ b/packages/agent-core-v2/test/goal/goal.test.ts @@ -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', diff --git a/packages/agent-core-v2/test/loop/basic.test.ts b/packages/agent-core-v2/test/loop/basic.test.ts index a550f9ed0..b02937115 100644 --- a/packages/agent-core-v2/test/loop/basic.test.ts +++ b/packages/agent-core-v2/test/loop/basic.test.ts @@ -87,7 +87,8 @@ describe('Agent loop', () => { [wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "", "role": "assistant", "content": [ { "type": "text", "text": "blocked" } ], "toolCalls": [], "providerMessageId": "mock-1" } ], "time": "