From 2d874fbd73eb511e4ef4c8d4c88bd47e429580b2 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 13 Jul 2026 23:52:22 +0800 Subject: [PATCH] fix(agent-core-v2): surface provider auth errors and align agent copy with v1 (#1631) * fix(agent-core-v2): surface provider rejection on post-refresh 401 - map a 401 that survives forced token refresh to PROVIDER_AUTH_ERROR carrying the provider's sanitized message, instead of a misleading AUTH_LOGIN_REQUIRED re-login prompt - propagate provider auth errors through full compaction instead of wrapping them as compaction failure - export sanitizeStatusErrorMessage for reuse * fix(agent-core-v2): align repeat-reminder and AskUserQuestion copy with v1 - rewrite repeated tool call reminders to redirect instead of prohibit (port of v1 #1518) - simplify makeReminderText2 to (repeatCount), no longer echoing tool name and args - treat dismissed AskUserQuestion as no answer, not the recommended pick (port of v1 #1550) - update toolDedupe test assertions and regenerate affected wire snapshots * chore: add changeset for v2 reminder and question copy alignment * fix(kap-server): drain in-flight heartbeat writes before unlinking the instance file - count writes that passed the released check and await them in release() so their atomic rename cannot recreate the file after unlink - harden the heartbeat test: 1ms cadence plus a post-release re-check catches the recreate-after-unlink race (verified failing on old code) * docs(agent-core-v2): move toProviderAuthError rationale to the file header Address Codex review: agent-core-v2 keeps comments in the top-of-file header only, never beside functions --- .../fix-kap-server-heartbeat-release-race.md | 5 +++ .../fix-v2-oauth-provider-auth-error.md | 5 +++ .../fix-v2-reminder-and-question-copy.md | 5 +++ .../fullCompaction/fullCompactionService.ts | 8 +++- .../src/agent/questionTools/tools/ask-user.md | 2 +- .../src/agent/toolDedupe/toolDedupeService.ts | 30 ++++++------- .../agent-core-v2/src/app/model/modelImpl.ts | 17 +++++--- .../agent-core-v2/src/app/protocol/errors.ts | 2 +- .../fullCompaction/fullCompaction.test.ts | 4 +- .../test/agent/loop/loop.test.ts | 4 +- .../test/agent/toolDedupe/toolDedupe.test.ts | 34 +++++++-------- .../test/app/model/modelResolver.test.ts | 6 ++- packages/agent-core-v2/test/tool/tool.test.ts | 10 ++--- packages/kap-server/src/instanceRegistry.ts | 42 +++++++++++++------ .../kap-server/test/instanceRegistry.test.ts | 9 +++- 15 files changed, 116 insertions(+), 67 deletions(-) create mode 100644 .changeset/fix-kap-server-heartbeat-release-race.md create mode 100644 .changeset/fix-v2-oauth-provider-auth-error.md create mode 100644 .changeset/fix-v2-reminder-and-question-copy.md diff --git a/.changeset/fix-kap-server-heartbeat-release-race.md b/.changeset/fix-kap-server-heartbeat-release-race.md new file mode 100644 index 000000000..95ed2b837 --- /dev/null +++ b/.changeset/fix-kap-server-heartbeat-release-race.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix a race where a heartbeat write in flight during server shutdown could recreate the instance file right after it was removed. diff --git a/.changeset/fix-v2-oauth-provider-auth-error.md b/.changeset/fix-v2-oauth-provider-auth-error.md new file mode 100644 index 000000000..59a033e05 --- /dev/null +++ b/.changeset/fix-v2-oauth-provider-auth-error.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Surface the provider's actual rejection message instead of a misleading re-login prompt when an OAuth-managed model keeps returning 401 after a token refresh. diff --git a/.changeset/fix-v2-reminder-and-question-copy.md b/.changeset/fix-v2-reminder-and-question-copy.md new file mode 100644 index 000000000..bb67cdd24 --- /dev/null +++ b/.changeset/fix-v2-reminder-and-question-copy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Rewrite repeated-tool-call reminders to redirect the agent toward a different action instead of prohibiting the call, and treat a dismissed question prompt as no answer rather than the recommended option. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index d1b55d83a..5614eaea5 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -670,7 +670,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull thinking_effort: this.profile.data().thinkingLevel, error_type: error instanceof Error ? error.name : 'Unknown', }); - if (isError2(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error; + if ( + isError2(error) && + (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || + error.code === ErrorCodes.PROVIDER_AUTH_ERROR) + ) { + throw error; + } throw new Error2(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error }); } } diff --git a/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.md b/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.md index 5386cd79a..0b4d966f5 100644 --- a/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.md +++ b/packages/agent-core-v2/src/agent/questionTools/tools/ask-user.md @@ -18,4 +18,4 @@ Overusing this tool interrupts the user's flow. Only use it when the user's inpu - Question texts must be unique across the call, and option labels must be unique within each question - You can ask 1-4 questions at a time; group related questions to minimize interruptions - If you recommend a specific option, list it first and append "(Recommended)" to its label -- The result is JSON with an `answers` object keyed by question text; each value is the chosen option's label (comma-separated labels for multi_select, or the user's own words if they picked "Other"); if `answers` is empty and a `note` says the user dismissed it, they declined to answer — proceed with your best judgment and do not re-ask the same question +- The result is JSON with an `answers` object keyed by question text; each value is the chosen option's label (comma-separated labels for multi_select, or the user's own words if they picked "Other"); if `answers` is empty and a `note` says the user dismissed it, they chose not to answer — do not treat this as selecting the recommended option; decide based on context and do not re-ask the same question diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 89fafac51..2a5b07bd9 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -22,32 +22,28 @@ import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; const REMINDER_TEXT_1 = '\n\n\n' + - 'You are repeating the exact same tool call with identical parameters.' + - ' Please carefully analyze the previous result. If the task is not yet complete,' + - ' try a different method or parameters instead of repeating the same call.' + + 'The same tool call has been repeated several times in a row. ' + + 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + + 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' + '\n'; -function makeReminderText2(toolName: string, repeatCount: number, args: unknown): string { - const argsStr = canonicalTelemetryArgs(args); +function makeReminderText2(repeatCount: number): string { return ( '\n\n\n' + - 'You have repeatedly called the same tool with identical parameters many times.\n' + - 'Repeated tool call detected:\n' + - `- tool: ${toolName}\n` + - `- repeated_times: ${String(repeatCount)}\n` + - `- arguments: ${argsStr}\n` + - 'The previous repeated calls did not make progress. Do not call this exact same tool with the exact same arguments again.\n' + - 'Carefully inspect the latest tool result and choose a different next action, different parameters, or finish the task if enough evidence has been gathered.' + + `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + + 'Choose exactly one of the following and state your choice before acting:\n' + + '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + + '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + + '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' + '\n' ); } const REMINDER_TEXT_3 = '\n\n\n' + - 'You are stuck in a dead end and have repeatedly made the same function call without progress.\n' + - 'Stop all function calls immediately. Do not call any tool in your next response.\n' + - 'In analysis, review the current execution state and identify why progress is blocked.\n' + - 'Then return a text-only summary to the user that reports the current problem, what has already been tried, and what information or decision is needed next.' + + 'Write your final response now, without any further tool calls. ' + + 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + + 'Text only.' + '\n'; const REPEAT_REMINDER_1_START = 3; @@ -269,7 +265,7 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu finalResult = appendReminder(result, REMINDER_TEXT_3); action = 'r3'; } else if (streak >= REPEAT_REMINDER_2_START) { - finalResult = appendReminder(result, makeReminderText2(toolName, streak, args)); + finalResult = appendReminder(result, makeReminderText2(streak)); action = 'r2'; } else if (streak >= REPEAT_REMINDER_1_START) { finalResult = appendReminder(result, REMINDER_TEXT_1); diff --git a/packages/agent-core-v2/src/app/model/modelImpl.ts b/packages/agent-core-v2/src/app/model/modelImpl.ts index a667abb49..e9c883b07 100644 --- a/packages/agent-core-v2/src/app/model/modelImpl.ts +++ b/packages/agent-core-v2/src/app/model/modelImpl.ts @@ -15,6 +15,11 @@ * wire I/O to `IProtocolAdapterRegistry.createChatProvider(...)` + kosong's * `generate(...)`. Phase 8 replaces the wire with native adapters; only this * file changes. + * + * Provider error translation also lives here: a 401 that survives a forced + * token refresh means the provider rejected the account itself, so it is + * surfaced as `PROVIDER_AUTH_ERROR` carrying the provider's message instead + * of a misleading re-login prompt. */ import { AsyncEventQueue } from '#/_base/asyncEventQueue'; @@ -28,7 +33,7 @@ import { type ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; import type { ChatProvider } from '#/app/llmProtocol/provider'; import type { Protocol, ProtocolProviderOptions } from '#/app/protocol/protocol'; import { generate, type GenerateResult } from '#/app/llmProtocol/generate'; -import { translateProviderError } from '#/app/protocol/errors'; +import { sanitizeStatusErrorMessage, translateProviderError } from '#/app/protocol/errors'; import { type ProtocolAdapterRegistry } from '#/app/protocol/protocolAdapterRegistry'; import { ErrorCodes, Error2 } from '#/errors'; @@ -333,7 +338,7 @@ export class ModelImpl implements Model { try { return await run(refreshedAuth); } catch (error) { - if (isUnauthorizedStatusError(error)) throw toLoginRequiredError(error); + if (isUnauthorizedStatusError(error)) throw toProviderAuthError(error); throw error; } } @@ -347,11 +352,13 @@ function isUnauthorizedStatusError(error: unknown): error is APIStatusError { return error instanceof APIStatusError && error.statusCode === 401; } -function toLoginRequiredError(error: APIStatusError): Error2 { +function toProviderAuthError(error: APIStatusError): Error2 { + const reason = sanitizeStatusErrorMessage(error.message); return new Error2( - ErrorCodes.AUTH_LOGIN_REQUIRED, - 'OAuth provider credentials were rejected. Send /login to login.', + ErrorCodes.PROVIDER_AUTH_ERROR, + reason.length > 0 ? reason : 'OAuth provider credentials were rejected.', { + name: error.name, cause: error, details: { statusCode: error.statusCode, diff --git a/packages/agent-core-v2/src/app/protocol/errors.ts b/packages/agent-core-v2/src/app/protocol/errors.ts index ab74dad02..11385fe36 100644 --- a/packages/agent-core-v2/src/app/protocol/errors.ts +++ b/packages/agent-core-v2/src/app/protocol/errors.ts @@ -139,7 +139,7 @@ export function translateProviderError(error: unknown): Error2 { return new Error2(CoreErrors.codes.INTERNAL, String(error), { cause: error }); } -function sanitizeStatusErrorMessage(message: string): string { +export function sanitizeStatusErrorMessage(message: string): string { const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(message); const extracted = titleMatch?.[1]?.trim(); const normalized = extracted !== undefined && extracted.length > 0 ? extracted : message; diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index bd5b22163..7d802958d 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -428,7 +428,7 @@ describe('FullCompaction', () => { ).toBe(false); }); - it('force-refreshes OAuth credentials on compaction 401 and falls back to login_required when replay 401', async () => { + it('force-refreshes OAuth credentials on compaction 401 and treats replay 401 as provider auth error', async () => { const tokenCalls: Array = []; const authKeys: string[] = []; const oauthOptions = oauthTestAgentOptions(async (options) => { @@ -467,7 +467,7 @@ describe('FullCompaction', () => { expect.objectContaining({ event: 'error', args: expect.objectContaining({ - code: 'auth.login_required', + code: 'provider.auth_error', details: expect.objectContaining({ statusCode: 401, requestId: 'req-compact-401', diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 60b2e3d2f..bd68cb380 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -96,8 +96,8 @@ describe('Agent loop', () => { [emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "" } ] } [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "