From 4292ae9f9bc49e9edaaaeae50dbddabbd4b9bb25 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 22 Jun 2026 17:32:05 +0800 Subject: [PATCH] fix: surface provider content filter and preserve context tokens (#963) * fix: surface provider content filter and preserve context tokens * fix: complete filtered turn handling across surfaces - context: accumulate token estimate for zero-usage steps to preserve the tokenCount / tokenCountCoveredMessageCount invariant - turn/goal: pause the goal when a turn is blocked by safety policy - subagent: surface a filtered child turn as a distinct error - acp: map filtered to the native ACP refusal stop reason - tui: show a filtered-specific message in the btw panel - cli: drop the redundant content_filter suffix from the error message - tests: cover filtered across cli, web, acp, and goal flows --- .../fix-provider-filtered-silent-ignore.md | 5 +++ apps/kimi-code/src/cli/run-prompt.ts | 3 ++ .../src/tui/controllers/btw-panel.ts | 3 ++ .../tui/controllers/session-event-handler.ts | 12 +++++ apps/kimi-code/test/cli/run-prompt.test.ts | 25 +++++++++++ .../src/api/daemon/agentEventProjector.ts | 5 ++- packages/acp-adapter/src/events-map.ts | 7 +++ packages/acp-adapter/src/session.ts | 7 +++ .../acp-adapter/test/error-mapping.test.ts | 26 +++++++++++ .../agent-core/src/agent/context/index.ts | 19 ++++++-- packages/agent-core/src/agent/turn/index.ts | 14 ++++-- .../src/services/prompt/promptService.ts | 6 +-- .../src/services/session/sessionService.ts | 2 +- .../agent-core/src/session/subagent-host.ts | 3 ++ .../agent-core/test/agent/context.test.ts | 35 +++++++++++++++ packages/agent-core/test/agent/turn.test.ts | 44 +++++++++++++++++++ .../test/harness/goal-session.test.ts | 25 +++++++++++ packages/protocol/src/events.ts | 4 +- 18 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-provider-filtered-silent-ignore.md diff --git a/.changeset/fix-provider-filtered-silent-ignore.md b/.changeset/fix-provider-filtered-silent-ignore.md new file mode 100644 index 000000000..7d23a9ff0 --- /dev/null +++ b/.changeset/fix-provider-filtered-silent-ignore.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Surface provider safety-policy blocks instead of silently treating them as completed turns, and prevent the context token count from dropping to zero after a filtered response. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index ecac8d3db..3a6791bbf 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -795,5 +795,8 @@ 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') { + return 'Provider safety policy blocked the response.'; + } 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 21406f329..d984f7082 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -202,5 +202,8 @@ function formatBtwTurnEnd(event: TurnEndedEvent): string { if (event.reason === 'cancelled') { return 'Interrupted by user'; } + if (event.reason === 'filtered') { + return 'Provider safety policy blocked the response.'; + } 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 919568191..c6c8036f0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -325,6 +325,9 @@ export class SessionEventHandler { if (event.reason === 'cancelled') { this.markActiveAgentSwarmsCancelled(); } + if (event.reason === 'filtered') { + this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error'); + } const todos = this.host.state.todoPanel.getTodos(); if (todos.length > 0 && todos.every((t) => t.status === 'done')) { this.host.streamingUI.setTodoList([]); @@ -356,6 +359,15 @@ export class SessionEventHandler { private handleStepCompleted(event: TurnStepCompletedEvent): void { this.host.streamingUI.flushNow(); this.maybeShowDebugTiming(event); + + if (event.providerFinishReason === 'filtered') { + this.host.showNotice( + 'Provider safety policy blocked the response.', + `The model output was filtered (${event.rawFinishReason ?? 'content_filter'}).`, + ); + return; + } + if (event.finishReason !== 'max_tokens') return; const truncatedCount = this.host.streamingUI.markStepTruncated( diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index a3620aa35..101361e5a 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -807,6 +807,31 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('rejects with a friendly message when the provider filters the response', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'turn.ended', + turnId: 2, + reason: 'filtered', + }), + ); + } + }); + + await expect( + runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }), + ).rejects.toThrow('Provider safety policy blocked the response.'); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + it('approval fallback approves if an unexpected approval request reaches SDK', async () => { await runPrompt(opts(), '1.2.3-test', { stdout: { write: vi.fn(() => true) }, diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 62b36a52a..86d9e7224 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -916,7 +916,7 @@ export function createAgentProjector(): AgentProjector { sessionId, messageId: msgId, content: msg.content.map((c) => ({ ...c })), - status: reason === 'failed' ? 'error' : 'completed', + status: reason === 'failed' || reason === 'filtered' ? 'error' : 'completed', durationMs, }); } @@ -926,7 +926,8 @@ export function createAgentProjector(): AgentProjector { const usageSnapshot = buildUsageSnapshot(s); out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); - const newStatus = reason === 'cancelled' ? 'aborted' : reason === 'failed' ? 'aborted' : 'idle'; + const newStatus = + reason === 'cancelled' || reason === 'failed' || reason === 'filtered' ? '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 37b4cc6ca..ac160051c 100644 --- a/packages/acp-adapter/src/events-map.ts +++ b/packages/acp-adapter/src/events-map.ts @@ -56,6 +56,11 @@ 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. */ export function turnEndReasonToStopReason(reason: TurnEndReason): AcpStopReason { switch (reason) { @@ -65,6 +70,8 @@ export function turnEndReasonToStopReason(reason: TurnEndReason): AcpStopReason return 'cancelled'; case 'failed': return 'end_turn'; + case 'filtered': + return 'refusal'; } } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 53c50beb5..2a8aa5e0a 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -1151,6 +1151,13 @@ 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 }); + } argsByToolCall.clear(); startedToolCalls.clear(); // Drop the turnId so a late-arriving approval (e.g. an SDK diff --git a/packages/acp-adapter/test/error-mapping.test.ts b/packages/acp-adapter/test/error-mapping.test.ts index ab08d04be..8f5f1ee5c 100644 --- a/packages/acp-adapter/test/error-mapping.test.ts +++ b/packages/acp-adapter/test/error-mapping.test.ts @@ -23,6 +23,7 @@ import { type Session, } from '@moonshot-ai/kimi-code-sdk'; +import { turnEndReasonToStopReason } from '../src/events-map'; import { AcpServer } from '../src/server'; import { AUTHED_STATUS } from './_helpers/harness-stubs'; @@ -258,4 +259,29 @@ describe('AcpServer error mapping', () => { const response = await client.prompt({ sessionId, prompt: [textBlock('hi')] }); expect(response.stopReason).toBe('cancelled'); }); + + it('maps the filtered turn-end reason 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'); + }); + + it('resolves with refusal when turn.ended reason is filtered', async () => { + const sessionId = 'sess-filtered'; + const { session, unsubscribeCount } = makeScriptedSession(sessionId, { + script: [ + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'filtered' } as Event, + ], + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + expect(response.stopReason).toBe('refusal'); + expect(unsubscribeCount()).toBe(1); + }); }); diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 9bd25f209..7701dc0cb 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -255,13 +255,26 @@ export class ContextMemory { this.openSteps.delete(event.uuid); if (event.usage !== undefined) { const openStepIndex = openStep === undefined ? -1 : this._history.indexOf(openStep); - this._tokenCount = + const coveredCount = + openStepIndex === -1 ? this._history.length : openStepIndex + 1; + const totalUsage = event.usage.inputCacheRead + event.usage.inputCacheCreation + event.usage.inputOther + event.usage.output; - this.tokenCountCoveredMessageCount = - openStepIndex === -1 ? this._history.length : openStepIndex + 1; + if (totalUsage > 0) { + this._tokenCount = totalUsage; + } else { + // The provider reported zero usage (e.g. content filter). Do not + // overwrite the accumulated context token count with 0; add an + // estimate for the newly covered messages so the invariant between + // _tokenCount and tokenCountCoveredMessageCount stays intact. + const previousCoveredCount = this.tokenCountCoveredMessageCount; + this._tokenCount += estimateTokensForMessages( + this._history.slice(previousCoveredCount, coveredCount), + ); + } + this.tokenCountCoveredMessageCount = coveredCount; } this.flushDeferredMessagesIfToolExchangeClosed(); return; diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 7fff37876..b7052b396 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -33,7 +33,7 @@ import { type LoopTurnInterruptedEvent, type LoopTurnStopReason, } from '../../loop/index'; -import type { AgentEvent, TurnEndedEvent } from '../../rpc'; +import type { AgentEvent, TurnEndedEvent, TurnEndReason } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; import { abortable, isUserCancellation, userCancellationReason } from '../../utils/abort'; import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; @@ -76,6 +76,7 @@ const GOAL_PROVIDER_AUTH_PAUSE_PREFIX = 'Paused after provider authentication er const GOAL_PROVIDER_API_PAUSE_PREFIX = 'Paused after provider API error'; const GOAL_MODEL_CONFIG_PAUSE_PREFIX = 'Paused after model configuration error'; const GOAL_RUNTIME_PAUSE_PREFIX = 'Paused after runtime error'; +const GOAL_PROVIDER_FILTERED_PAUSE_REASON = 'Paused after provider safety policy block'; /** * The prompt the goal driver appends to start each continuation turn — the @@ -325,7 +326,8 @@ export class TurnFlow { if ( goalBecameActive && end.event.reason !== 'cancelled' && - end.event.reason !== 'failed' + end.event.reason !== 'failed' && + end.event.reason !== 'filtered' ) { return await this.driveGoal( this.allocateTurnId(), @@ -384,6 +386,10 @@ 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) { await this.agent.goal.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' }); return end; @@ -469,10 +475,12 @@ 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: stopReason === 'aborted' ? 'cancelled' : 'completed', + reason, durationMs: Date.now() - startedAt, }; } diff --git a/packages/agent-core/src/services/prompt/promptService.ts b/packages/agent-core/src/services/prompt/promptService.ts index 4476d54d8..d96f3a110 100644 --- a/packages/agent-core/src/services/prompt/promptService.ts +++ b/packages/agent-core/src/services/prompt/promptService.ts @@ -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'). */ + /** Set on `turn.ended` for the top-level turn (reason='completed'|'failed'|'filtered'). */ 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'; + reason: 'completed' | 'cancelled' | 'failed' | 'filtered'; } { 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' ? 'failed' : 'completed', + reason: reason === 'failed' || reason === 'filtered' ? 'failed' : 'completed', }; this._active.delete(key); // Fire typed listeners BEFORE publishing the synth event. diff --git a/packages/agent-core/src/services/session/sessionService.ts b/packages/agent-core/src/services/session/sessionService.ts index 99530c8d9..a57908bca 100644 --- a/packages/agent-core/src/services/session/sessionService.ts +++ b/packages/agent-core/src/services/session/sessionService.ts @@ -202,7 +202,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') { + if (reason === 'cancelled' || reason === 'failed' || reason === 'filtered') { this._abortedTurns.add(sessionId); } else { this._abortedTurns.delete(sessionId); diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 0241bdb6b..a39f25d64 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -468,6 +468,9 @@ 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') { + throw new Error('Subagent turn blocked by provider safety policy'); + } if (turnEnded.error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) { throw providerRateLimitErrorFromPayload(turnEnded.error); } diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 3ba820c6a..ddc853184 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -588,6 +588,41 @@ describe('Agent context', () => { ); }); + it('does not zero tokenCount when a filtered step reports zero usage', () => { + const ctx = testAgent(); + ctx.configure(); + ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000); + expect(ctx.agent.context.tokenCount).toBe(1_000); + + const stepUuid = 'context-filtered-step'; + ctx.agent.context.appendUserMessage([{ type: 'text', text: 'next prompt' }]); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 2 }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'step.end', + uuid: stepUuid, + turnId: '0', + step: 2, + usage: { + inputOther: 0, + output: 0, + inputCacheRead: 0, + inputCacheCreation: 0, + }, + finishReason: 'filtered', + }, + }); + + expect(ctx.agent.context.tokenCount).toBeGreaterThan(1_000); + expect(ctx.agent.context.tokenCountWithPending).toBeGreaterThanOrEqual( + ctx.agent.context.tokenCount, + ); + }); + it('undo only counts real user prompts, skipping background notifications', () => { const ctx = testAgent(); ctx.configure(); diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index e39094fb4..e4dba7fee 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -425,6 +425,50 @@ describe('Agent turn flow', () => { ); }); + it('ends the turn with reason filtered when the provider filters a non-empty response', async () => { + const generate: GenerateFn = async () => ({ + id: null, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'some filtered text' }], + toolCalls: [], + }, + usage: { + inputOther: 10, + output: 5, + inputCacheRead: 0, + inputCacheCreation: 0, + }, + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }); + const ctx = testAgent({ + generate, + ...singleAttemptAgentOptions(), + }); + ctx.configure(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger filtered response' }] }); + const events = await ctx.untilTurnEnd(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'turn.ended', + args: expect.objectContaining({ + reason: 'filtered', + }), + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'turn.ended', + args: expect.objectContaining({ reason: 'completed' }), + }), + ); + }); + it('emits a friendly model.not_configured error when no model is configured', async () => { const ctx = testAgent(); diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 8cfeddc6d..c5080a870 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -490,6 +490,31 @@ describe('goal session end-to-end', () => { expect(goal?.terminalReason).toBe('Paused after runtime error: unexpected failure'); }); + it('pauses the goal on provider safety policy blocks', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const generate: NonNullable = async () => ({ + id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'filtered' }], toolCalls: [] }, + usage: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }); + const { session, agent } = await setupSession(sessionDir, events, ['GetGoal'], generate); + const api = new SessionAPIImpl(session); + await api.createGoal({ agentId: 'main', objective: 'work' }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + const goal = (await api.getGoal({ agentId: 'main' })).goal; + 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' }), + ); + }); + it('blocks the goal when the initial prompt hook blocks the objective', async () => { const sessionDir = await makeTempDir(); const events: Array> = []; diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index d5e28dd70..52e9f5afb 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -292,7 +292,7 @@ export interface McpOAuthAuthorizationUrlUpdateData { readonly authorizationUrl: string; } -export type TurnEndReason = 'completed' | 'cancelled' | 'failed'; +export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'filtered'; export interface AgentStatusUpdatedEvent { readonly type: 'agent.status.updated'; @@ -919,7 +919,7 @@ export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({ authorizationUrl: z.string(), }) satisfies z.ZodType; -export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed']) satisfies z.ZodType; +export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'filtered']) satisfies z.ZodType; export const agentStatusUpdatedEventSchema = z.object({ type: z.literal('agent.status.updated'),