Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-provider-filtered-silent-ignore.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,5 +795,8 @@ 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') {
return 'Provider safety policy blocked the response.';
}
return `Prompt turn ended with reason: ${event.reason}`;
}
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/controllers/btw-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
12 changes: 12 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions apps/kimi-code/test/cli/run-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
5 changes: 3 additions & 2 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/acp-adapter/src/events-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -65,6 +70,8 @@ export function turnEndReasonToStopReason(reason: TurnEndReason): AcpStopReason
return 'cancelled';
case 'failed':
return 'end_turn';
case 'filtered':
return 'refusal';
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/acp-adapter/test/error-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
});
});
19 changes: 16 additions & 3 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 11 additions & 3 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-core/src/services/prompt/promptService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/services/session/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/session/subagent-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
35 changes: 35 additions & 0 deletions packages/agent-core/test/agent/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
44 changes: 44 additions & 0 deletions packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading
Loading