From 3cad2d789b266744266e41d72c4eb598242bd3ba Mon Sep 17 00:00:00 2001 From: SobertLi Date: Wed, 19 Aug 2026 17:44:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(maintainer):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B1=80=E9=83=A8=E5=88=A4=E5=88=AB=E8=BE=85=E5=8A=A9=E6=8A=BD?= =?UTF-8?q?=E8=B1=A1=E4=B8=8E=E4=BF=9D=E5=AE=88=E6=A1=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立 MaintainerLocalJudge 抽象接口及保守桩实现, 用于后续在少数高杠杆语义判断点接入轻量判别辅助。 保持当前 Maintainer 行为不变:桩始终标记判定不可靠, runner 预留 localJudge 字段但未改变判定流。 Co-Authored-By: Kimi K3 (Moonshot) --- .../fix/maintainer-local-judge-stub.ts | 48 ++++++++++ .../classic/fix/maintainer-local-judge.ts | 96 +++++++++++++++++++ .../classic/runners/maintainer-runner.ts | 5 + 3 files changed, 149 insertions(+) create mode 100644 src/advance/classic/fix/maintainer-local-judge-stub.ts create mode 100644 src/advance/classic/fix/maintainer-local-judge.ts diff --git a/src/advance/classic/fix/maintainer-local-judge-stub.ts b/src/advance/classic/fix/maintainer-local-judge-stub.ts new file mode 100644 index 0000000..cfc4409 --- /dev/null +++ b/src/advance/classic/fix/maintainer-local-judge-stub.ts @@ -0,0 +1,48 @@ +/** + * Maintainer 判别辅助的保守桩实现 + * + * 当前行为: + * - 所有辅助请求均回退为“不可靠”,不改变现有 Maintainer 逻辑 + * - 后续可替换为本地轻量模型实现,且无需修改调用方 + */ + +import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult } from './maintainer-local-judge.js'; + +export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { + /** 当前桩始终可用(避免调用方因“不可用”而改变流程),但判定均不可靠 */ + isAvailable(): boolean { + return true; + } + + reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,语义重识别由现有机制处理', + }); + } + + adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,卡点校正由现有熔断逻辑处理', + }); + } + + assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,already-fixed 判定由现有机制处理', + }); + } +} diff --git a/src/advance/classic/fix/maintainer-local-judge.ts b/src/advance/classic/fix/maintainer-local-judge.ts new file mode 100644 index 0000000..1f0e488 --- /dev/null +++ b/src/advance/classic/fix/maintainer-local-judge.ts @@ -0,0 +1,96 @@ +/** + * Maintainer 判别辅助抽象 + * + * 目标(见本分支方案): + * - 在少数高杠杆语义判断点引入本地轻量判别辅助 + * - 不代替 Maintainer 大脑/Actor 的既有决策,仅作为辅助信号 + * - 不可信/不可用时必须无缝回退到现有机制 + */ + +export type LocalJudgeVerdict = + | { kind: 'reliable'; value: boolean; reason: string } + | { kind: 'unreliable'; reason: string }; + +/** + * 语义重识别辅助的结果 + */ +export interface SemanticReidentificationResult { + /** 当前 finding 与之前记录的决策是否可能是同一语义问题 */ + likelySame: boolean; + /** 判定依据 */ + reason: string; + /** 置信程度,用于决定是否覆盖/复用历史决策 */ + confidence: 'high' | 'medium' | 'low'; +} + +/** + * 工具循环卡点校正辅助的结果 + */ +export interface StuckCorrectionResult { + /** 建议动作 */ + suggestion: 'continue' | 'refocus' | 'broaden' | 'stop'; + /** 建议理由 */ + reason: string; + /** 是否建议停止当前方向的探索 */ + suggestStop: boolean; +} + +/** + * 已修复辅助的结果(可选的增强点) + */ +export interface AlreadyFixedAssistanceResult { + /** 问题是否可能已经在当前代码中不存在 */ + likelyAlreadyFixed: boolean; + /** 判定依据 */ + reason: string; + /** 可选的最小证据片段(若提供,须经调用方 grounded 校验) */ + evidence?: string; +} + +/** + * 本地判别辅助的抽象接口 + * + * 实现方可根据本部署能力选择本地 LLM 服务、MCP 桥或后续多模型配置; + * 本接口不绑死具体后端。 + */ +export interface MaintainerLocalJudge { + /** + * 服务当前是否可用 + */ + isAvailable(): boolean; + + /** + * 语义重识别辅助 + * + * 用在 MR HEAD 变化、文件结构变化后,判断当前 finding 是否与历史决策 + * 属于同一语义问题,从而避免已处理问题被重复回复/重复检查。 + */ + reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string, + ): Promise; + + /** + * 工具循环卡点校正辅助 + * + * 在 FixToolLoop 探测到低效徘徊或长时间无实质进展时,可选调用, + * 用于提前转向或收拢探索方向。 + */ + adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string, + ): Promise; + + /** + * 已修复辅助(可选增强点) + * + * 在 already-fixed 判定阶段提供辅助信号。 + * 输出的证据必须经调用方 grounded 校验,不得直接覆盖现有结论。 + */ + assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string, + ): Promise; +} diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index 6716497..52a3083 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -7,6 +7,7 @@ */ import { LlmClient } from '../../llm/client.js'; +import { ConservativeLocalJudgeStub, type MaintainerLocalJudge } from '../fix/maintainer-local-judge.js'; import { GitLabProvider } from '../provider/gitlab-provider.js'; import { WorktreeManager } from '../worktree/worktree-manager.js'; import { MaintainerBrain } from '../fix/maintainer-brain.js'; @@ -505,8 +506,12 @@ const CONTINUE_AFTER_CI: CiHandlingResult = { }; export class MaintainerRunner extends BaseRoleRunner { + /** 本地判别辅助(目前为保守桩,未来可替换为本地轻量模型实现) */ + private localJudge: MaintainerLocalJudge; + constructor(options: MaintainerRunnerOptions) { super({ llmClient: options.llmClient }); + this.localJudge = new ConservativeLocalJudgeStub(); } protected getRole(): 'maintainer' { From 46b6ca5e701bca4d37f25fa6caeee7899318b05e Mon Sep 17 00:00:00 2001 From: SobertLi Date: Wed, 19 Aug 2026 23:04:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(maintainer):=20=E6=8E=A5=E5=85=A5=20Ll?= =?UTF-8?q?mMaintainerLocalJudge=20=E5=B9=B6=E5=A2=9E=E5=BC=BA=20already-f?= =?UTF-8?q?ixed=20=E5=A4=8D=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaintainerRunner 构造器替换保守桩为 LlmMaintainerLocalJudge - recheckStaleFindingIfNeeded 中调用 assistAlreadyFixedCheck 辅助判别 - 辅助不可靠时回退到 brain.recheckAlreadyFixed 原逻辑 - 辅助可靠且建议已修复时仅在可靠命中时覆盖结论 本提交属于 feat/upgrade_maintainer 分支的阶段3最小接通。 Co-Authored-By: Kimi K3 (Moonshot) --- .../classic/fix/maintainer-llm-judge.ts | 290 ++++++++++++++++++ .../classic/runners/maintainer-runner.ts | 19 +- 2 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/advance/classic/fix/maintainer-llm-judge.ts diff --git a/src/advance/classic/fix/maintainer-llm-judge.ts b/src/advance/classic/fix/maintainer-llm-judge.ts new file mode 100644 index 0000000..85cb369 --- /dev/null +++ b/src/advance/classic/fix/maintainer-llm-judge.ts @@ -0,0 +1,290 @@ +/** + * 基于 LlmClient 的轻量判别辅助实现 + * + * 目标: + * - 在少数高杠杆语义判断点提供结构化辅助判别 + * - 使用现有的 LlmClient,调用小 prompt、小输出 + * - 不可靠/失败时回退为不可靠,不改变 Maintainer 既有行为 + */ + +import { LlmClient } from '../../llm/client.js'; +import type { + MaintainerLocalJudge, + LocalJudgeVerdict, + SemanticReidentificationResult, + StuckCorrectionResult, + AlreadyFixedAssistanceResult, +} from './maintainer-local-judge.js'; + +interface ReidentifyPromptPayload { + currentDescription: string; + previousDecisionSummary: string; + fileContextHint?: string; +} + +interface StuckPromptPayload { + findingDescription: string; + recentProgressSummary: string; + attemptedDirectionsSummary?: string; +} + +interface AlreadyFixedPromptPayload { + findingDescription: string; + currentCodeContextHint?: string; +} + +/** + * 基于 LlmClient 的 Maintainer 判别辅助实现 + */ +export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { + constructor(private llmClient: LlmClient) {} + + isAvailable(): boolean { + return true; + } + + async reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string, + ): Promise { + const payload: ReidentifyPromptPayload = { + currentDescription: currentFindingDescription, + previousDecisionSummary, + fileContextHint, + }; + try { + const json = await this.llmClient.completeJson( + this.buildReidentifyPrompt(payload), + this.reidentifySystem(), + { + type: 'object', + properties: { + likelySame: { type: 'boolean' }, + confidence: { + type: 'string', + enum: ['high', 'medium', 'low'], + }, + reason: { type: 'string' }, + }, + required: ['likelySame', 'confidence', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelySame !== 'boolean') { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的语义重识别结果', + }; + } + const confidence = this.normalizeConfidence(body.confidence); + return { + likelySame: body.likelySame, + confidence, + reason: body.reason ?? '', + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + async adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string, + ): Promise { + const payload: StuckPromptPayload = { + findingDescription, + recentProgressSummary, + attemptedDirectionsSummary, + }; + try { + const json = await this.llmClient.completeJson( + this.buildStuckPrompt(payload), + this.stuckSystem(), + { + type: 'object', + properties: { + suggestion: { + type: 'string', + enum: ['continue', 'refocus', 'broaden', 'stop'], + }, + suggestStop: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['suggestion', 'suggestStop', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的卡点校正结果', + }; + } + return { + suggestion: body.suggestion as StuckCorrectionResult['suggestion'], + suggestStop: Boolean(body.suggestStop), + reason: body.reason ?? '', + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + async assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string, + ): Promise { + const payload: AlreadyFixedPromptPayload = { + findingDescription, + currentCodeContextHint, + }; + try { + const json = await this.llmClient.completeJson( + this.buildAlreadyFixedPrompt(payload), + this.alreadyFixedSystem(), + { + type: 'object', + properties: { + likelyAlreadyFixed: { type: 'boolean' }, + reason: { type: 'string' }, + evidence: { type: 'string' }, + }, + required: ['likelyAlreadyFixed', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的 already-fixed 辅助结果', + }; + } + return { + likelyAlreadyFixed: body.likelyAlreadyFixed, + reason: body.reason ?? '', + evidence: body.evidence, + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + // ---------- 提示构造 ---------- + + private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { + const ctx = payload.fileContextHint ? `\n\n当前文件上下文提示:\n${payload.fileContextHint}` : ''; + return [ + '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', + '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', + '', + '当前发现描述:', + payload.currentDescription, + '', + '之前决策摘要:', + payload.previousDecisionSummary, + ctx, + '', + '规则:', + '- 如果之前的决策是 ignore/ignore(alreadyFixed),且当前描述表达的是同样的意图/位置/问题,请倾向认为是同一语义问题。', + '- 如果当前描述明显涉及不同问题、不同意图、不同位置,则不应该认为是同一语义问题。', + '- 仅凭行号相同不足以断定同一语义问题;行号不同也不应直接否定同一语义问题。', + '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', + '', + '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', + ].join('\n'); + } + + private buildStuckPrompt(payload: StuckPromptPayload): string { + const attempted = payload.attemptedDirectionsSummary + ? `\n\n已尝试的方向总结:\n${payload.attemptedDirectionsSummary}` + : ''; + return [ + '你正在帮助维护者判断:当前修复循环是否应该继续当前方向、换范围、扩大范围,或收拢尝试。', + '', + '问题描述:', + payload.findingDescription, + '', + '最近进度总结:', + payload.recentProgressSummary, + attempted, + '', + '建议动作之一:', + '- continue:当前方向仍有意义,继续。', + '- refocus:当前方向不集中,建议缩小/重新聚焦到更明确的目标。', + '- broaden:当前范围太窄,建议扩大到更多相关文件/行。', + '- stop:当前信息下不再继续有意义,建议收拢或换方式。', + '', + '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', + ].join('\n'); + } + + private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { + const ctx = payload.currentCodeContextHint + ? `\n\n当前代码上下文提示:\n${payload.currentCodeContextHint}` + : ''; + return [ + '你正在帮助维护者判断:某个审查问题是否已经在当前代码中不再存在。', + '', + '问题描述:', + payload.findingDescription, + ctx, + '', + '请回答:该问题是否可能已经在当前代码中不再存在。', + '只返回 JSON,包含:', + '- likelyAlreadyFixed(boolean)', + '- reason(字符串)', + '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', + '', + '注意:如果信息不足,不要武断返回 already-fixed。', + ].join('\n'); + } + + // ---------- system / helper ---------- + + private reidentifySystem(): string { + return '你是一个保守的语义重识别辅助。目标是帮助判断同一语义问题是否已被处理,而不是直接代替人类决策。只有在理由充分时才标记 likelySame=true,并把置信度控制在 reasonable 范围。'; + } + + private stuckSystem(): string { + return '你是一个保守的卡点校正辅助。不要鼓励无限继续;如果信息不足或方向不明确,倾向于 suggestStop=true。'; + } + + private alreadyFixedSystem(): string { + return '你是一个保守的 already-fixed 辅助。只在有理由时标记 likelyAlreadyFixed=true,且不要超过你能从输入中看到的范围。'; + } + + private parseSimpleJson(text: string): Record | null { + try { + const parsed = JSON.parse(text) as Record; + if (parsed && typeof parsed === 'object') { + return parsed; + } + return null; + } catch { + return null; + } + } + + private normalizeConfidence(value: unknown): 'high' | 'medium' | 'low' { + if (value === 'high' || value === 'medium' || value === 'low') { + return value as 'high' | 'medium' | 'low'; + } + return 'low'; + } + + private wrapError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `LLM 判别辅助失败: ${message}`; + } +} diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index 52a3083..6962a60 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -7,6 +7,7 @@ */ import { LlmClient } from '../../llm/client.js'; +import { LlmMaintainerLocalJudge } from '../fix/maintainer-llm-judge.js'; import { ConservativeLocalJudgeStub, type MaintainerLocalJudge } from '../fix/maintainer-local-judge.js'; import { GitLabProvider } from '../provider/gitlab-provider.js'; import { WorktreeManager } from '../worktree/worktree-manager.js'; @@ -511,7 +512,7 @@ export class MaintainerRunner extends BaseRoleRunner { constructor(options: MaintainerRunnerOptions) { super({ llmClient: options.llmClient }); - this.localJudge = new ConservativeLocalJudgeStub(); + this.localJudge = new LlmMaintainerLocalJudge(this.llmClient); } protected getRole(): 'maintainer' { @@ -2860,7 +2861,21 @@ export class MaintainerRunner extends BaseRoleRunner { } try { - return await brain.recheckAlreadyFixed(finding); + const baseResult = await brain.recheckAlreadyFixed(finding); + + const assist = await this.localJudge.assistAlreadyFixedCheck( + finding.description, + focusedContent + ); + if (assist.kind === 'reliable' && assist.likelyAlreadyFixed) { + return { + alreadyFixed: true, + reason: assist.reason, + evidence: assist.evidence, + }; + } + + return baseResult; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( From 52e60f5ac9cf01a93b9aa0f0377a9b6ded44c9d7 Mon Sep 17 00:00:00 2001 From: SobertLi Date: Sat, 22 Aug 2026 11:36:32 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(maintainer):=20=E8=A1=A5=E9=BD=90=20St?= =?UTF-8?q?age=205=20=E4=BF=9D=E5=AE=88=E6=A1=A9=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E4=B8=8E=20LLM=20=E6=96=B9=E6=B3=95=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Kimi K3 (Moonshot) --- src/advance/classic/fix/issue-scope.ts | 22 +++ src/advance/classic/fix/maintainer-brain.ts | 33 ++++ .../classic/fix/maintainer-llm-judge.ts | 181 +++++++++++++++++- .../fix/maintainer-local-judge-stub.ts | 39 +++- .../classic/fix/maintainer-local-judge.ts | 33 ++++ 5 files changed, 291 insertions(+), 17 deletions(-) diff --git a/src/advance/classic/fix/issue-scope.ts b/src/advance/classic/fix/issue-scope.ts index 1c298d2..677a2cf 100755 --- a/src/advance/classic/fix/issue-scope.ts +++ b/src/advance/classic/fix/issue-scope.ts @@ -8,6 +8,7 @@ import type { LlmClient } from '../../llm/client.js'; import type { ReviewFinding } from '../provider/types.js'; import type { FocusedContext } from './focused-context-builder.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; import { extractJsonText } from '../utils/json-extraction.js'; import { defaultPromptLoader, type PromptLoader } from '../../llm/prompts/loader.js'; @@ -23,6 +24,8 @@ export interface ScopeClassifierOptions { llmClient?: LlmClient; /** 是否启用 LLM 二次确认;默认 false,避免每个 finding 都调用 LLM */ enableLlmConfirm?: boolean; + /** 可选的轻量判别辅助,用于启发式规则未命中时的 scope 初筛 */ + localJudge?: MaintainerLocalJudge; /** 可选的 prompt 加载器,默认使用全局 loader */ promptLoader?: PromptLoader; } @@ -42,6 +45,25 @@ export class IssueScopeClassifier { return heuristic; } + if ( + this.options.localJudge && + typeof this.options.localJudge.preFilterScope === 'function' + ) { + const verdict = await this.options.localJudge.preFilterScope( + finding.message, + finding.file, + finding.line, + ); + if ('kind' in verdict && verdict.kind === 'reliable') { + if (verdict.scope !== 'local') { + return { + scope: verdict.scope, + reason: verdict.reason, + }; + } + } + } + if (this.options.enableLlmConfirm && this.options.llmClient) { return await this.confirmWithLlm(finding, context); } diff --git a/src/advance/classic/fix/maintainer-brain.ts b/src/advance/classic/fix/maintainer-brain.ts index b68a260..5596c7e 100755 --- a/src/advance/classic/fix/maintainer-brain.ts +++ b/src/advance/classic/fix/maintainer-brain.ts @@ -2,6 +2,7 @@ import type { ReviewFinding } from '../provider/types.js'; import { LlmClient, LlmDecisionError } from '../../llm/client.js'; import type { ToolDefinition } from '../../llm/tool-types.js'; import type { IMemoryClient } from '../memory/types.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; import { IssueScopeClassifier, type IssueScope } from './issue-scope.js'; import { buildFocusedContext, type FocusedContext } from './focused-context-builder.js'; import { focusedContextToString } from './focused-context-streamer.js'; @@ -182,6 +183,8 @@ export interface MaintainerBrainOptions { worktreeManager?: WorktreeManager; /** 可选的 prompt 加载器,默认使用全局 loader */ promptLoader?: PromptLoader; + /** 可选的轻量判别辅助,用于非 finding 讨论的前置语义过滤 */ + localJudge?: MaintainerLocalJudge; } export interface ParseFindingsInput { @@ -425,6 +428,36 @@ export class MaintainerBrain { userId: string; mrContext?: MrContext; }): Promise { + if ( + this.options.localJudge && + typeof this.options.localJudge.preFilterNonFindingDiscussion === 'function' + ) { + const verdict = await this.options.localJudge.preFilterNonFindingDiscussion( + params.body, + undefined, + ); + if ('kind' in verdict && verdict.kind === 'reliable') { + if (verdict.isProbablyNonFinding) { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=reliable isProbablyNonFinding=true reason=${verdict.reason}` + ); + return { + action: 'ignore', + reason: `本地判别辅助认为该讨论很可能不是待逐条修复的代码问题: ${verdict.reason}`, + replyBody: '感谢 Review。', + }; + } else { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=reliable isProbablyNonFinding=false reason=${verdict.reason}` + ); + } + } else { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=unreliable reason=${verdict.reason}` + ); + } + } + const mrContextText = params.mrContext ? [ `标题:${params.mrContext.title}`, diff --git a/src/advance/classic/fix/maintainer-llm-judge.ts b/src/advance/classic/fix/maintainer-llm-judge.ts index 85cb369..81a27ca 100644 --- a/src/advance/classic/fix/maintainer-llm-judge.ts +++ b/src/advance/classic/fix/maintainer-llm-judge.ts @@ -14,6 +14,8 @@ import type { SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, + PreFilterScopeVerdict, + PreFilterNonFindingVerdict, } from './maintainer-local-judge.js'; interface ReidentifyPromptPayload { @@ -33,6 +35,17 @@ interface AlreadyFixedPromptPayload { currentCodeContextHint?: string; } +interface PreFilterScopePromptPayload { + findingDescription: string; + findingFile?: string; + findingLine?: number; +} + +interface PreFilterNonFindingPromptPayload { + discussionBody: string; + discussionNoteCount?: number; +} + /** * 基于 LlmClient 的 Maintainer 判别辅助实现 */ @@ -81,7 +94,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { return { likelySame: body.likelySame, confidence, - reason: body.reason ?? '', + reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { @@ -119,7 +132,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { }, ); const body = this.parseSimpleJson(json); - if (!body || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { + if (!body || typeof body.suggestion !== 'string' || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的卡点校正结果', @@ -128,7 +141,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { return { suggestion: body.suggestion as StuckCorrectionResult['suggestion'], suggestStop: Boolean(body.suggestStop), - reason: body.reason ?? '', + reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { @@ -169,8 +182,101 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { } return { likelyAlreadyFixed: body.likelyAlreadyFixed, - reason: body.reason ?? '', - evidence: body.evidence, + reason: typeof body.reason === 'string' ? body.reason : '', + evidence: typeof body.evidence === 'string' ? body.evidence : undefined, + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + async preFilterScope( + findingDescription: string, + findingFile?: string, + findingLine?: number, + ): Promise { + const payload: PreFilterScopePromptPayload = { + findingDescription, + findingFile, + findingLine, + }; + try { + const json = await this.llmClient.completeJson( + this.buildPreFilterScopePrompt(payload), + this.preFilterScopeSystem(), + { + type: 'object', + properties: { + scope: { + type: 'string', + enum: ['trivial', 'local', 'cross-file', 'needs-clarification'], + }, + reason: { type: 'string' }, + }, + required: ['scope', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.scope !== 'string') { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的 scope 初筛结果', + }; + } + if (!['trivial', 'local', 'cross-file', 'needs-clarification'].includes(body.scope)) { + return { + kind: 'unreliable', + reason: `LLM 返回了无效的 scope 值: ${String(body.scope)}`, + }; + } + return { + kind: 'reliable', + scope: body.scope as 'trivial' | 'local' | 'cross-file' | 'needs-clarification', + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { + kind: 'unreliable', + reason: this.wrapError(error), + }; + } + } + + async preFilterNonFindingDiscussion( + discussionBody: string, + discussionNoteCount?: number, + ): Promise { + const payload: PreFilterNonFindingPromptPayload = { + discussionBody, + discussionNoteCount, + }; + try { + const json = await this.llmClient.completeJson( + this.buildPreFilterNonFindingPrompt(payload), + this.preFilterNonFindingSystem(), + { + type: 'object', + properties: { + isProbablyNonFinding: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['isProbablyNonFinding', 'reason'], + }, + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.isProbablyNonFinding !== 'boolean') { + return { + kind: 'unreliable', + reason: 'LLM 返回了不可解析的非 finding 过滤结果', + }; + } + return { + kind: 'reliable', + isProbablyNonFinding: body.isProbablyNonFinding, + reason: typeof body.reason === 'string' ? body.reason : '', }; } catch (error) { return { @@ -182,6 +288,65 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { // ---------- 提示构造 ---------- + private buildPreFilterScopePrompt(payload: PreFilterScopePromptPayload): string { + const loc = payload.findingFile + ? `文件:${payload.findingFile}${payload.findingLine ? `, 行:${payload.findingLine}` : ''}` + : '(文件定位信息未提供)'; + return [ + '你正在帮助维护者判断一个审查问题的大致范围(前置初筛)。', + '目标是辅助判断该问题更可能属于:trivial / local / cross-file / needs-clarification。', + '', + `位置信息:${loc}`, + '', + '问题描述:', + payload.findingDescription, + '', + '范围定义:', + '- trivial:单行、小范围、注释/TODO/命名/常量补全之类,几乎不影响其他代码。', + '- local:集中在单个文件或一个小范围内,修复边界清楚。', + '- cross-file:涉及类型/接口/签名变更或明显影响多个调用点,可能需要多文件一起动。', + '- needs-clarification:关键信息(文件/行/意图)不足以给出范围结论。', + '', + '规则:', + '- 如果位置信息缺失或问题描述过短/模糊,倾向于 needs-clarification。', + '- 如果明显影响接口、签名、导出或多个调用点,倾向于 cross-file。', + '- 如果只能看到单行/字段/注释/常量层面的修改,且无控制流或类型扩散迹象,倾向于 trivial。', + '- 其余则判为 local。', + '', + '请返回 JSON,包含 scope(one of trivial/local/cross-file/needs-clarification)、reason(字符串)。', + ].join('\\n'); + } + + private buildPreFilterNonFindingPrompt( + payload: PreFilterNonFindingPromptPayload, + ): string { + const noteCount = payload.discussionNoteCount != null + ? `(讨论 note 数量:${payload.discussionNoteCount})` + : ''; + return [ + '你正在帮助维护者判断:一条 MR discussion 是否很可能不是待逐条修复的代码问题。', + '', + '讨论正文:', + payload.discussionBody, + noteCount, + '', + '判断规则:', + '- 如果正文只询问/提示而不给出具体文件行号的问题,就倾向于 isProbablyNonFinding=true。', + '- 如果正文指向具体文件和行号,且表达的是可操作的代码问题,就倾向于 isProbablyNonFinding=false。', + '- 如果信息不足以判断,保守返回 isProbablyNonFinding=false。', + '', + '请返回 JSON,包含 isProbablyNonFinding(boolean)、reason(字符串)。', + ].join('\\n'); + } + + private preFilterScopeSystem(): string { + return '你是一个保守的 scope 初筛辅助。只在理由充分时才给出 non-trivial/non-cross-file 的范围结论;信息不足时不要编造范围。'; + } + + private preFilterNonFindingSystem(): string { + return '你是一个保守的非 finding 过滤辅助。只有在有明确理由时才标记 isProbablyNonFinding=true;怀疑是具体代码问题时,就返回 false。'; + } + private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { const ctx = payload.fileContextHint ? `\n\n当前文件上下文提示:\n${payload.fileContextHint}` : ''; return [ @@ -202,7 +367,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', '', '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', - ].join('\n'); + ].join('\\n'); } private buildStuckPrompt(payload: StuckPromptPayload): string { @@ -226,7 +391,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { '- stop:当前信息下不再继续有意义,建议收拢或换方式。', '', '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', - ].join('\n'); + ].join('\\n'); } private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { @@ -247,7 +412,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', '', '注意:如果信息不足,不要武断返回 already-fixed。', - ].join('\n'); + ].join('\\n'); } // ---------- system / helper ---------- diff --git a/src/advance/classic/fix/maintainer-local-judge-stub.ts b/src/advance/classic/fix/maintainer-local-judge-stub.ts index cfc4409..0021de3 100644 --- a/src/advance/classic/fix/maintainer-local-judge-stub.ts +++ b/src/advance/classic/fix/maintainer-local-judge-stub.ts @@ -6,7 +6,7 @@ * - 后续可替换为本地轻量模型实现,且无需修改调用方 */ -import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult } from './maintainer-local-judge.js'; +import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, PreFilterScopeVerdict, PreFilterNonFindingVerdict } from './maintainer-local-judge.js'; export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { /** 当前桩始终可用(避免调用方因“不可用”而改变流程),但判定均不可靠 */ @@ -15,9 +15,9 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { } reassessSemanticIdentity( - currentFindingDescription: string, - previousDecisionSummary: string, - fileContextHint?: string, + _currentFindingDescription: string, + _previousDecisionSummary: string, + _fileContextHint?: string, ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -26,9 +26,9 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { } adviseOnStuckProgress( - findingDescription: string, - recentProgressSummary: string, - attemptedDirectionsSummary?: string, + _findingDescription: string, + _recentProgressSummary: string, + _attemptedDirectionsSummary?: string, ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -37,12 +37,33 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { } assistAlreadyFixedCheck( - findingDescription: string, - currentCodeContextHint?: string, + _findingDescription: string, + _currentCodeContextHint?: string, ): Promise { return Promise.resolve({ kind: 'unreliable', reason: '本地判别辅助尚未启用,already-fixed 判定由现有机制处理', }); } + + preFilterScope( + _findingDescription: string, + _findingFile?: string, + _findingLine?: number, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,scope 初筛由现有机制处理', + }); + } + + preFilterNonFindingDiscussion( + _discussionBody: string, + _discussionNoteCount?: number, + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,非 finding 过滤由现有机制处理', + }); + } } diff --git a/src/advance/classic/fix/maintainer-local-judge.ts b/src/advance/classic/fix/maintainer-local-judge.ts index 1f0e488..62fa6b0 100644 --- a/src/advance/classic/fix/maintainer-local-judge.ts +++ b/src/advance/classic/fix/maintainer-local-judge.ts @@ -11,6 +11,14 @@ export type LocalJudgeVerdict = | { kind: 'reliable'; value: boolean; reason: string } | { kind: 'unreliable'; reason: string }; +export type PreFilterScopeVerdict = + | { kind: 'reliable'; scope: 'trivial' | 'local' | 'cross-file' | 'needs-clarification'; reason: string } + | { kind: 'unreliable'; reason: string }; + +export type PreFilterNonFindingVerdict = + | { kind: 'reliable'; isProbablyNonFinding: boolean; reason: string } + | { kind: 'unreliable'; reason: string }; + /** * 语义重识别辅助的结果 */ @@ -93,4 +101,29 @@ export interface MaintainerLocalJudge { findingDescription: string, currentCodeContextHint?: string, ): Promise; + + /** + * Scope 初筛辅助(中优先级位置) + * + * 在启发式规则尚未给出明确范围结论时,辅助判断一个 finding + * 更可能属于 trivial / local / cross-file / needs-clarification。 + * 不可靠时返回不可靠,不改变现有范围判定。 + */ + preFilterScope( + findingDescription: string, + findingFile?: string, + findingLine?: number, + ): Promise; + + /** + * 非 finding 讨论的语义过滤辅助(中优先级位置) + * + * 在 discussion 无法解析出具体文件/行号时,辅助判断该讨论是否 + * 很可能不是待逐条修复的代码问题(例如纯统计/汇总/提问_hint/误报指示等)。 + * 不可靠时返回不可靠,调用方继续走原有非 finding 处理路径。 + */ + preFilterNonFindingDiscussion( + discussionBody: string, + discussionNoteCount?: number, + ): Promise; } From e46f770b34ee78f534cc8bb514b467f9af67a098 Mon Sep 17 00:00:00 2001 From: SobertLi Date: Fri, 4 Sep 2026 16:36:19 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(maintainer):=20=E5=BC=BA=E5=8C=96?= =?UTF-8?q?=E5=A4=A7=E6=A8=A1=E5=9E=8B=E9=A9=B1=E5=8A=A8=E7=9A=84=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=86=B3=E7=AD=96=E4=B8=8E=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接入 Maintainer 的大模型语义判断、方案评审和对抗性复核 - 增加 already-fixed 检查与修复后 verifyFix 最终验收门禁 - 缺少大模型验收、证据不完整或验收失败时禁止提交 - 补充认知引擎、修复执行器和 Runner 回归测试 Co-Authored-By: Kimi K3 (Moonshot) --- src/advance/classic/cognitive-engine.ts | 582 +++++++++- src/advance/classic/fix/cognitive-types.ts | 10 + src/advance/classic/fix/fix-tool-loop.ts | 5 +- src/advance/classic/fix/maintainer-actor.ts | 996 +++++++++++++++--- src/advance/classic/fix/maintainer-brain.ts | 163 ++- .../classic/fix/maintainer-llm-judge.ts | 171 ++- .../fix/maintainer-local-judge-stub.ts | 30 +- .../classic/fix/maintainer-local-judge.ts | 42 +- .../classic/runners/maintainer-runner.ts | 57 +- .../classic/runners/shared/state-utils.ts | 14 + .../prompts/cognitive-already-fixed-task.md | 1 + src/assets/prompts/cognitive-fast-task.md | 9 +- src/assets/prompts/cognitive-final-task.md | 17 +- src/assets/prompts/cognitive-inquiry-task.md | 3 + src/assets/prompts/cognitive-options-task.md | 6 +- .../prompts/maintainer-verify-fix-task.md | 59 ++ .../classic/fix/cognitive-engine.test.ts | 409 ++++++- .../advance/classic/fix/fix-tool-loop.test.ts | 1 + .../classic/fix/maintainer-actor.test.ts | 122 ++- .../classic/fix/maintainer-brain.test.ts | 103 +- .../classic/fix/maintainer-llm-judge.test.ts | 36 + .../classic/runners/maintainer-runner.test.ts | 16 + 22 files changed, 2602 insertions(+), 250 deletions(-) create mode 100644 src/assets/prompts/maintainer-verify-fix-task.md create mode 100644 tests/advance/classic/fix/maintainer-llm-judge.test.ts diff --git a/src/advance/classic/cognitive-engine.ts b/src/advance/classic/cognitive-engine.ts index 8dcf12c..9af1f7c 100755 --- a/src/advance/classic/cognitive-engine.ts +++ b/src/advance/classic/cognitive-engine.ts @@ -49,6 +49,8 @@ const OPTIONS_DECISION_TOOL: ToolDefinition = { pros: { type: 'array', items: { type: 'string' } }, cons: { type: 'array', items: { type: 'string' } }, risk: { type: 'string', enum: ['low', 'medium', 'high'] }, + affectedFiles: { type: 'array', items: { type: 'string' } }, + verificationSteps: { type: 'array', items: { type: 'string' } }, }, required: ['description', 'pros', 'cons', 'risk'], additionalProperties: false, @@ -78,7 +80,12 @@ const FINAL_DECISION_TOOL: ToolDefinition = { reasoning: { type: 'string' }, confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, alreadyFixed: { type: 'boolean' }, + notActionable: { type: 'boolean' }, replyBody: { type: 'string' }, + affectedFiles: { type: 'array', items: { type: 'string' } }, + verificationPlan: { type: 'array', items: { type: 'string' } }, + risks: { type: 'array', items: { type: 'string' } }, + adversarialResponses: { type: 'array', items: { type: 'string' } }, }, required: ['action', 'reason'], additionalProperties: false, @@ -95,6 +102,10 @@ const ALREADY_FIXED_CHECK_TOOL: ToolDefinition = { alreadyFixed: { type: 'boolean' }, reason: { type: 'string' }, evidence: { type: 'string' }, + notActionable: { + type: 'boolean', + description: '问题是误报、重复项或按项目约定无需代码修改时设为 true', + }, evidenceSnippet: { type: 'string', description: @@ -196,6 +207,8 @@ export interface CognitiveEngineOptions { recallPlanner?: RecallPlanner; memoryClient?: IMemoryClient; worktreeManager?: WorktreeManager; + /** 可选的轻量判别器,作为 already-fixed 与方案红队的辅助信号 */ + localJudge?: import('./fix/maintainer-local-judge.js').MaintainerLocalJudge; /** 可选的 prompt 加载器,默认使用全局 loader */ promptLoader?: PromptLoader; } @@ -204,6 +217,7 @@ interface InquiryResult { needsMoreContext: boolean; queries: Array<{ type: string; target: string }>; reason: string; + status?: 'actionable' | 'needs-context' | 'already-fixed' | 'not-actionable'; } interface OptionItem { @@ -211,13 +225,36 @@ interface OptionItem { pros: string[]; cons: string[]; risk: 'low' | 'medium' | 'high'; + affectedFiles?: string[]; + verificationSteps?: string[]; +} + +interface AdversarialReview { + approve: boolean; + concerns: string[]; + requiredChanges: string[]; + reason: string; +} + +interface AdversarialReviewAttempt { + status: 'skipped' | 'reviewed' | 'failed'; + review?: AdversarialReview; + reason?: string; +} + +interface AlreadyFixedCheckResult { + alreadyFixed: boolean; + notActionable?: boolean; + reason: string; + evidence?: string; + needsMoreContext?: boolean; } /** * 认知引擎 * * 把 Maintainer 的决策过程拆成可配置的多步认知循环: - * - fast:观察 → 决策(1 次 LLM 调用) + * - fast:观察 → already-fixed 前置复查 → 决策(至少 2 次 LLM 调用) * - standard:观察 → 追问 → 生成候选方案 → 决策(2~3 次调用) * - deep:standard 全部步骤 + 执行后反思并记录到记忆 */ @@ -235,8 +272,7 @@ export class CognitiveEngine { if (depth === 'fast') { return this.decideFast(context); } - // deep 模式在决策阶段与 standard 一致,反射由调用方在修复后触发 - return this.decideStandard(context); + return this.decideStandard(context, depth); } /** @@ -273,6 +309,11 @@ export class CognitiveEngine { } private async decideFast(context: CognitiveContext): Promise { + const alreadyFixed = await this.checkAlreadyFixed(context); + if (alreadyFixed.alreadyFixed || alreadyFixed.notActionable) { + return this.buildAlreadyFixedDecision(alreadyFixed); + } + const prompt = this.buildFastPrompt(context); console.log(`[CognitiveEngine] decideFast prompt 长度=${prompt.length}`); const toolCall = await this.options.llmClient.completeDecision( @@ -285,39 +326,148 @@ export class CognitiveEngine { return this.parseDecision(toolCall.input, context); } - private async decideStandard(context: CognitiveContext): Promise { + private async decideStandard( + context: CognitiveContext, + depth: CognitiveDepth + ): Promise { logMemorySnapshot('CognitiveEngine.decideStandard 开始'); - const inquiry = await this.runInquiry(context); + const initialAlreadyFixed = await this.checkAlreadyFixed(context); + if (initialAlreadyFixed.alreadyFixed || initialAlreadyFixed.notActionable) { + return this.buildAlreadyFixedDecision(initialAlreadyFixed); + } + + const inquiry = await this.runInquiry(context, initialAlreadyFixed); logMemorySnapshot('CognitiveEngine.decideStandard inquiry 后'); const enrichedContext = await this.enrichContext(context, inquiry); logMemorySnapshot('CognitiveEngine.decideStandard enrichContext 后'); - // 显式预检:issue 是否已经被修复,避免对已修复问题生成无效修复方案 - const alreadyFixed = await this.checkAlreadyFixed(enrichedContext); - if (alreadyFixed.alreadyFixed) { - console.log( - `[CognitiveEngine] 检测到问题已修复: ${enrichedContext.finding.file}:${enrichedContext.finding.line}` - ); - return { - action: 'ignore', - reason: alreadyFixed.reason, - alreadyFixed: true, - replyBody: alreadyFixed.evidence || alreadyFixed.reason, - analysis: alreadyFixed.reason, - consideredOptions: [], - reasoning: '当前代码已满足 Reviewer 的要求,无需修改', - confidence: 'high', - }; + const alreadyFixed = this.hasAdditionalContext(context, enrichedContext) + ? await this.checkAlreadyFixed(enrichedContext) + : initialAlreadyFixed; + if (alreadyFixed.alreadyFixed || alreadyFixed.notActionable) { + return this.buildAlreadyFixedDecision(alreadyFixed); } const options = await this.generateOptions(enrichedContext); logMemorySnapshot('CognitiveEngine.decideStandard generateOptions 后'); - const decision = await this.finalDecision(enrichedContext, options); + const adversarial = await this.reviewOptions( + enrichedContext, + options, + depth === 'deep' ? 2 : 1 + ); + let decision = await this.finalDecision(enrichedContext, options, adversarial); + const decisionReviews: AdversarialReview[] = []; + if (decision.action === 'fix') { + const firstDecisionReviewAttempt = await this.reviewFinalDecision( + enrichedContext, + options, + adversarial, + decision + ); + if (firstDecisionReviewAttempt.status === 'failed') { + return this.buildAdversarialAskDecision( + [ + adversarial, + this.buildAdversarialReviewFailure( + firstDecisionReviewAttempt.reason ?? '最终决策独立红队复核失败' + ), + ], + options, + decision, + '最终修复决策未能完成可靠的独立红队复核' + ); + } + const firstDecisionReview = firstDecisionReviewAttempt.review; + if (firstDecisionReview) { + decisionReviews.push(firstDecisionReview); + } + + if (firstDecisionReview && !firstDecisionReview.approve) { + try { + decision = await this.finalDecision( + enrichedContext, + options, + adversarial, + this.buildAdversarialDecisionFollowUp(firstDecisionReview, decision) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[CognitiveEngine] 红队修订轮调用失败,转为澄清: ${message}`); + return this.buildAdversarialAskDecision( + [adversarial, ...decisionReviews], + options, + decision, + '最终修复决策未能完成红队要求的修订' + ); + } + + if (decision.action === 'fix') { + const revisedDecisionReviewAttempt = await this.reviewFinalDecision( + enrichedContext, + options, + adversarial, + decision, + decisionReviews + ); + const revisedDecisionReview = revisedDecisionReviewAttempt.review; + if (revisedDecisionReview) { + decisionReviews.push(revisedDecisionReview); + } + if (!revisedDecisionReview || !revisedDecisionReview.approve) { + const reviewFailure = revisedDecisionReview + ? [] + : [ + this.buildAdversarialReviewFailure( + revisedDecisionReviewAttempt.reason ?? '最终决策修订后未能完成独立红队复核' + ), + ]; + return this.buildAdversarialAskDecision( + [adversarial, ...decisionReviews, ...reviewFailure], + options, + decision, + revisedDecisionReview + ? '最终修复决策经过一次修订后仍未通过独立红队复核' + : '最终修复决策修订后未能完成独立红队复核' + ); + } + } + } + } logMemorySnapshot('CognitiveEngine.decideStandard finalDecision 后'); - return decision; + + const verificationPlan = this.normalizeStringList( + decision.verificationPlan?.length + ? decision.verificationPlan + : options.flatMap(option => option.verificationSteps ?? []) + ).slice(0, 8); + const adversarialReviews = [adversarial, ...decisionReviews]; + const adversarialConcerns = this.collectAdversarialConcerns(adversarialReviews).slice(0, 30); + const risks = this.normalizeStringList([ + ...(decision.risks ?? []), + ...options.flatMap(option => + option.risk === 'high' ? [`高风险方案:${option.description}`] : [] + ), + ...adversarialConcerns, + ]).slice(0, 20); + return { + ...decision, + adversarialConcerns, + adversarialResponses: this.normalizeStringList(decision.adversarialResponses).slice(0, 30), + risks, + verificationPlan, + affectedFiles: this.normalizeStringList( + decision.affectedFiles?.length + ? decision.affectedFiles + : Array.from(new Set(options.flatMap(option => option.affectedFiles ?? []))) + ).slice(0, 20), + analysis: decision.analysis || '已完成问题状态、方案与风险审查', + }; } - private async runInquiry(context: CognitiveContext): Promise { + private async runInquiry( + context: CognitiveContext, + alreadyFixedAssessment?: AlreadyFixedCheckResult + ): Promise { const overviewText = context.fileOverview ? `文件总行数:${context.fileOverview.lineCount}\n主要符号:\n${context.fileOverview.symbols .slice(0, 20) @@ -343,6 +493,9 @@ export class CognitiveEngine { relatedFindings, recalledMemories, fileOverview: overviewText, + alreadyFixedAssessment: alreadyFixedAssessment + ? `前置复查结论:问题尚未被确认已修复。理由:${alreadyFixedAssessment.reason}` + : '未执行前置复查', }); console.log(`[CognitiveEngine] runInquiry prompt 长度=${prompt.length}`); @@ -490,14 +643,10 @@ export class CognitiveEngine { /** * 显式检查 finding 描述的问题是否已经在当前代码中被修复 */ - async checkAlreadyFixed(context: CognitiveContext): Promise<{ - alreadyFixed: boolean; - reason: string; - evidence?: string; - }> { + async checkAlreadyFixed(context: CognitiveContext): Promise { // 第一层:用聚焦上下文做轻量判断。prompt 短、噪音少,覆盖大多数情况。 const focusedResult = await this.runAlreadyFixedCheck(context, context.fileContent, '聚焦窗口'); - if (focusedResult.alreadyFixed) { + if (focusedResult.alreadyFixed || focusedResult.notActionable) { console.log( `[CognitiveEngine] 聚焦窗口判定问题已修复: ${context.finding.file}:${context.finding.line}` ); @@ -529,12 +678,7 @@ export class CognitiveEngine { context: CognitiveContext, fileContent: string, sourceLabel: string - ): Promise<{ - alreadyFixed: boolean; - reason: string; - evidence?: string; - needsMoreContext?: boolean; - }> { + ): Promise { const prompt = this.promptLoader.load('cognitive-already-fixed-task', { findingFile: context.finding.file, findingLine: String(context.finding.line), @@ -556,6 +700,7 @@ export class CognitiveEngine { ); const input = toolCall.input as { alreadyFixed?: boolean; + notActionable?: boolean; reason?: string; evidence?: string; evidenceSnippet?: string; @@ -577,12 +722,14 @@ export class CognitiveEngine { ); return { alreadyFixed: false, + notActionable: false, reason: 'already-fixed 证据无法绑定到当前 finding 的代码上下文,拒绝复用该结论', needsMoreContext: sourceLabel === '聚焦窗口', }; } return { alreadyFixed: input.alreadyFixed === true, + notActionable: input.notActionable === true, reason: input.reason ?? '未说明理由', evidence: input.evidence, needsMoreContext: input.needsMoreContext === true, @@ -652,7 +799,9 @@ export class CognitiveEngine { private async finalDecision( context: CognitiveContext, - options: OptionItem[] + options: OptionItem[], + adversarial: AdversarialReview, + followUpInstruction = '' ): Promise { const overviewText = this.formatFileOverview(context.fileOverview); const extraContextsText = this.formatExtraFileContexts(context.extraFileContexts); @@ -663,9 +812,15 @@ export class CognitiveEngine { const optionsText = options .map( (o, i) => - `${i + 1}. ${o.description}\n 优点:${o.pros.join(',')}\n 缺点:${o.cons.join(',')}\n 风险:${o.risk}` + `${i + 1}. ${o.description}\n 优点:${o.pros.join(',')}\n 缺点:${o.cons.join(',')}\n 风险:${o.risk}\n 可能受影响文件:${o.affectedFiles?.join(',') || '未说明'}\n 验证步骤:${o.verificationSteps?.join(';') || '未说明'}` ) .join('\n\n'); + const adversarialReview = [ + `是否通过:${adversarial.approve ? '是' : '否/需修订'}`, + `理由:${adversarial.reason || '未说明'}`, + `关键疑虑:${adversarial.concerns.join(';') || '无'}`, + `必须改变:${adversarial.requiredChanges.join(';') || '无'}`, + ].join('\n'); const prompt = this.promptLoader.load('cognitive-final-task', { findingFile: context.finding.file, @@ -677,6 +832,8 @@ export class CognitiveEngine { fileOverview: overviewText, extraFileContexts: extraContextsText, relatedMemories, + adversarialReview, + adversarialFollowUp: followUpInstruction, }); console.log(`[CognitiveEngine] finalDecision prompt 长度=${prompt.length}`); @@ -745,26 +902,47 @@ export class CognitiveEngine { reasoning?: string; confidence?: string; alreadyFixed?: boolean; + notActionable?: boolean; replyBody?: string; + affectedFiles?: unknown; + verificationPlan?: unknown; + risks?: unknown; + adversarialConcerns?: unknown; + adversarialResponses?: unknown; }; const base = this.normalizeBaseDecision(parsed, context); - // 如果模型明确标记问题已修复,强制按 ignore 处理,避免对已修复代码发起无效修复 - if (parsed.alreadyFixed === true && base.action === 'fix') { + // 如果模型明确标记问题已修复或无需处理,强制按 ignore 处理,避免无效修改 + if ( + (parsed.alreadyFixed === true || parsed.notActionable === true) && + base.action === 'fix' + ) { console.log( - `[CognitiveEngine] 模型返回 alreadyFixed=true 但 action=fix,已归一化为 ignore: ${context.finding.file}:${context.finding.line}` + `[CognitiveEngine] 模型返回无需修改标记但 action=fix,已归一化为 ignore: ${context.finding.file}:${context.finding.line}` ); return { action: 'ignore', reason: base.reason, - alreadyFixed: true, - replyBody: parsed.replyBody || base.replyBody || '当前代码已满足 Reviewer 的要求', - analysis: parsed.analysis ?? '问题已修复', + alreadyFixed: parsed.alreadyFixed === true, + notActionable: parsed.notActionable === true, + replyBody: + parsed.replyBody || + base.replyBody || + (parsed.alreadyFixed === true + ? '当前代码已满足 Reviewer 的要求' + : '当前 finding 无需修改'), + analysis: + parsed.analysis ?? (parsed.alreadyFixed === true ? '问题已修复' : '问题无需处理'), consideredOptions: Array.isArray(parsed.consideredOptions) ? parsed.consideredOptions : [], reasoning: parsed.reasoning ?? base.reason, confidence: this.normalizeConfidence(parsed.confidence), + affectedFiles: this.normalizeStringList(parsed.affectedFiles), + verificationPlan: this.normalizeStringList(parsed.verificationPlan), + risks: this.normalizeStringList(parsed.risks), + adversarialConcerns: this.normalizeStringList(parsed.adversarialConcerns), + adversarialResponses: this.normalizeStringList(parsed.adversarialResponses), }; } return { @@ -773,6 +951,11 @@ export class CognitiveEngine { consideredOptions: Array.isArray(parsed.consideredOptions) ? parsed.consideredOptions : [], reasoning: parsed.reasoning ?? base.reason, confidence: this.normalizeConfidence(parsed.confidence), + affectedFiles: this.normalizeStringList(parsed.affectedFiles), + verificationPlan: this.normalizeStringList(parsed.verificationPlan), + risks: this.normalizeStringList(parsed.risks), + adversarialConcerns: this.normalizeStringList(parsed.adversarialConcerns), + adversarialResponses: this.normalizeStringList(parsed.adversarialResponses), }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -798,7 +981,13 @@ export class CognitiveEngine { deleteFile?: boolean; scope?: string; alreadyFixed?: boolean; + notActionable?: boolean; replyBody?: string; + affectedFiles?: unknown; + verificationPlan?: unknown; + risks?: unknown; + adversarialConcerns?: unknown; + adversarialResponses?: unknown; }, _context: CognitiveContext ): { @@ -809,7 +998,13 @@ export class CognitiveEngine { deleteFile?: boolean; scope?: 'trivial' | 'local' | 'cross-file'; alreadyFixed?: boolean; + notActionable?: boolean; replyBody?: string; + affectedFiles?: string[]; + verificationPlan?: string[]; + risks?: string[]; + adversarialConcerns?: string[]; + adversarialResponses?: string[]; } { const reason = parsed.reason ?? '未说明理由'; switch (parsed.action) { @@ -820,6 +1015,13 @@ export class CognitiveEngine { fixDescription: parsed.fixDescription, deleteFile: parsed.deleteFile === true, scope: this.normalizeScope(parsed.scope), + alreadyFixed: parsed.alreadyFixed === true, + notActionable: parsed.notActionable === true, + affectedFiles: this.normalizeStringList(parsed.affectedFiles), + verificationPlan: this.normalizeStringList(parsed.verificationPlan), + risks: this.normalizeStringList(parsed.risks), + adversarialConcerns: this.normalizeStringList(parsed.adversarialConcerns), + adversarialResponses: this.normalizeStringList(parsed.adversarialResponses), }; case 'ask': return { @@ -832,7 +1034,13 @@ export class CognitiveEngine { action: 'ignore', reason, alreadyFixed: parsed.alreadyFixed === true, + notActionable: parsed.notActionable === true, replyBody: parsed.replyBody, + affectedFiles: this.normalizeStringList(parsed.affectedFiles), + verificationPlan: this.normalizeStringList(parsed.verificationPlan), + risks: this.normalizeStringList(parsed.risks), + adversarialConcerns: this.normalizeStringList(parsed.adversarialConcerns), + adversarialResponses: this.normalizeStringList(parsed.adversarialResponses), }; default: return { @@ -853,6 +1061,18 @@ export class CognitiveEngine { return 'medium'; } + private normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return Array.from( + new Set( + value + .filter((item): item is string => typeof item === 'string') + .map(item => item.trim()) + .filter(Boolean) + ) + ); + } + private parseInquiry(input: Record): InquiryResult { try { const parsed = input as { @@ -874,10 +1094,280 @@ export class CognitiveEngine { private parseOptions(input: Record): OptionItem[] { try { - const parsed = input as { options?: OptionItem[] }; - return (parsed.options ?? []).filter(o => typeof o.description === 'string'); + const parsed = input as { options?: unknown }; + if (!Array.isArray(parsed.options)) return []; + return parsed.options + .filter((option): option is Record => { + return Boolean(option) && typeof option === 'object' && !Array.isArray(option); + }) + .map(option => ({ + description: typeof option.description === 'string' ? option.description.trim() : '', + pros: this.normalizeStringList(option.pros), + cons: this.normalizeStringList(option.cons), + risk: ((): OptionItem['risk'] => { + const risk = option.risk; + if (risk === 'high' || risk === 'medium' || risk === 'low') return risk; + return 'medium'; + })(), + affectedFiles: this.normalizeStringList(option.affectedFiles), + verificationSteps: this.normalizeStringList(option.verificationSteps), + })) + .filter(option => option.description.length > 0); } catch { return []; } } + + private buildAlreadyFixedDecision(result: { + alreadyFixed: boolean; + notActionable?: boolean; + reason: string; + evidence?: string; + }): CognitiveDecision { + const alreadyFixed = result.alreadyFixed === true; + const notActionable = result.notActionable === true; + return { + action: 'ignore', + reason: result.reason, + alreadyFixed, + notActionable, + replyBody: result.evidence || result.reason, + analysis: alreadyFixed ? '问题已在当前代码中修复' : '该 finding 当前不需要代码修改', + consideredOptions: [], + reasoning: alreadyFixed + ? '当前代码已经满足 Reviewer 所指出的问题,无需重复修改' + : '当前 finding 不需要代码修改,避免为误报或无需处理的问题引入变更', + confidence: 'high', + risks: notActionable ? ['未执行代码修改;如 Reviewer 仍认为需要处理,应补充可执行证据'] : [], + }; + } + + private hasAdditionalContext(base: CognitiveContext, enriched: CognitiveContext): boolean { + return ( + enriched.recalledMemories.length > base.recalledMemories.length || + (enriched.extraFileContexts?.length ?? 0) > (base.extraFileContexts?.length ?? 0) + ); + } + + private getAdversarialItems(adversarial: AdversarialReview): string[] { + const concerns = this.normalizeStringList([ + ...adversarial.concerns, + ...adversarial.requiredChanges, + ]); + if (concerns.length > 0) return concerns; + return adversarial.approve ? [] : this.normalizeStringList([adversarial.reason]); + } + + private collectAdversarialConcerns(reviews: AdversarialReview[]): string[] { + return this.normalizeStringList( + reviews.flatMap(review => [ + ...review.concerns, + ...review.requiredChanges, + review.approve ? '' : review.reason, + ]) + ); + } + + private buildAdversarialReviewFailure(reason: string): AdversarialReview { + return { + approve: false, + concerns: [], + requiredChanges: ['在执行修复前重新完成独立红队复核'], + reason: `独立红队复核未能可靠完成:${reason}`, + }; + } + + private buildAdversarialDecisionFollowUp( + adversarial: AdversarialReview, + decision: CognitiveDecision + ): string { + const concerns = this.getAdversarialItems(adversarial); + return [ + '上一版最终决策未通过独立红队复核。请重新审视并形成新的最终决策,而不是沿用原结论。', + `上一版理由:${decision.reason}`, + decision.adversarialResponses?.length + ? `上一版主决策回应:\n- ${decision.adversarialResponses.join('\n- ')}` + : '上一版没有给出可审计的红队回应。', + concerns.length > 0 + ? `必须逐项回应以下意见:\n- ${concerns.join('\n- ')}` + : '红队未批准该方案,请明确说明阻断风险及其处理方式。', + '如果仍无法证明根因、影响范围和验证标准已经闭环,应选择 ask;如果坚持 fix,必须在 adversarialResponses 中逐项说明处理方式,并给出 verificationPlan。', + ].join('\n\n'); + } + + private buildAdversarialAskDecision( + reviews: AdversarialReview[], + options: OptionItem[], + decision?: CognitiveDecision, + reason = '最终修复决策仍有未闭环的关键风险' + ): CognitiveDecision { + const concerns = this.collectAdversarialConcerns(reviews); + return { + action: 'ask', + reason, + question: `红队评审指出以下风险,当前还不能安全自动修复:${concerns.join(';') || reason}。请补充约束、确认处理方向,或在独立复核恢复后重试。`, + analysis: + '方案与最终决策的独立复核未形成可批准结论,关键风险仍未被当前代码证据与验证计划闭环', + consideredOptions: options.map(option => option.description), + reasoning: '当前流程未能形成经独立复核确认的风险闭环,因此不能直接进入代码修改', + confidence: 'low', + risks: concerns, + adversarialConcerns: concerns, + adversarialResponses: this.normalizeStringList(decision?.adversarialResponses), + }; + } + + private formatCandidateOptions(options: OptionItem[]): string { + return options + .map( + (option, index) => + `${index + 1}. ${option.description}\n优点:${option.pros.join(',') || '无'}\n缺点:${option.cons.join(',') || '无'}\n风险:${option.risk}\n可能受影响文件:${option.affectedFiles?.join(',') || '未说明'}\n验证步骤:${option.verificationSteps?.join(';') || '未说明'}` + ) + .join('\n\n'); + } + + private formatAdversarialReview(review: AdversarialReview): string { + return [ + `是否通过:${review.approve ? '是' : '否/需修订'}`, + `理由:${review.reason || '未说明'}`, + `关键疑虑:${review.concerns.join(';') || '无'}`, + `必须改变:${review.requiredChanges.join(';') || '无'}`, + ].join('\n'); + } + + private buildAdversarialCodeHint(context: CognitiveContext): string { + return [context.fileContent, this.formatExtraFileContexts(context.extraFileContexts)] + .filter(Boolean) + .join('\n\n') + .slice(0, 30_000); + } + + private async reviewFinalDecision( + context: CognitiveContext, + options: OptionItem[], + optionReview: AdversarialReview, + decision: CognitiveDecision, + priorDecisionReviews: AdversarialReview[] = [] + ): Promise { + const judge = this.options.localJudge; + if (!judge || typeof judge.adversarialDecisionReview !== 'function') { + return { status: 'skipped' }; + } + if (!judge.isAvailable()) { + return { status: 'failed', reason: '最终决策红队服务当前不可用' }; + } + + const reviewHistory = [ + `方案红队评审:\n${this.formatAdversarialReview(optionReview)}`, + ...priorDecisionReviews.map( + (review, index) => + `第 ${index + 1} 轮最终决策红队复核:\n${this.formatAdversarialReview(review)}` + ), + ].join('\n\n'); + const finalDecision = JSON.stringify( + { + action: decision.action, + reason: decision.reason, + fixDescription: decision.fixDescription, + scope: decision.scope, + analysis: decision.analysis, + reasoning: decision.reasoning, + affectedFiles: decision.affectedFiles, + verificationPlan: decision.verificationPlan, + risks: decision.risks, + adversarialResponses: decision.adversarialResponses, + }, + null, + 2 + ); + + try { + const result = await judge.adversarialDecisionReview( + `${context.finding.file}:${context.finding.line}\n${context.finding.message}\n${context.finding.suggestion ?? ''}`, + `${this.formatCandidateOptions(options)}\n\n${reviewHistory}`, + finalDecision, + this.buildAdversarialCodeHint(context) + ); + if ('kind' in result && result.kind === 'reliable') { + return { + status: 'reviewed', + review: { + approve: result.approve, + concerns: this.normalizeStringList(result.concerns), + requiredChanges: this.normalizeStringList(result.requiredChanges), + reason: result.reason || '最终决策红队复核完成', + }, + }; + } + console.warn(`[CognitiveEngine] 最终决策红队复核不可用: ${result.reason}`); + return { status: 'failed', reason: result.reason }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[CognitiveEngine] 最终决策红队复核失败: ${message}`); + return { status: 'failed', reason: message }; + } + } + + private async reviewOptions( + context: CognitiveContext, + options: OptionItem[], + rounds: number + ): Promise { + const judge = this.options.localJudge; + if (!judge || typeof judge.adversarialReview !== 'function' || !judge.isAvailable()) { + return { + approve: true, + concerns: [], + requiredChanges: [], + reason: '方案红队辅助不可用,由最终决策模型直接结合方案与代码判断', + }; + } + + const candidateOptions = this.formatCandidateOptions(options); + const codeHint = this.buildAdversarialCodeHint(context); + + let previousConcerns: string[] = []; + const reviews: AdversarialReview[] = []; + for (let round = 0; round < rounds; round++) { + const prior = previousConcerns.length + ? `\n\n上一轮红队意见(请从不同角度继续检查,不要机械重复):\n${previousConcerns.map(item => `- ${item}`).join('\n')}` + : ''; + try { + const result = await judge.adversarialReview( + `${context.finding.file}:${context.finding.line}\n${context.finding.message}\n${context.finding.suggestion ?? ''}${prior}`, + candidateOptions, + codeHint + ); + if ('kind' in result && result.kind === 'reliable') { + const review: AdversarialReview = { + approve: result.approve, + concerns: this.normalizeStringList(result.concerns), + requiredChanges: this.normalizeStringList(result.requiredChanges), + reason: result.reason || '红队评审完成', + }; + reviews.push(review); + previousConcerns = this.collectAdversarialConcerns(reviews); + } else { + console.warn(`[CognitiveEngine] 第 ${round + 1} 轮方案红队评审不可用: ${result.reason}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[CognitiveEngine] 第 ${round + 1} 轮方案红队评审失败: ${message}`); + } + } + if (reviews.length === 0) { + return { + approve: true, + concerns: [], + requiredChanges: [], + reason: '方案红队辅助未返回可靠结果,由最终决策模型结合代码判断', + }; + } + return { + approve: reviews.every(review => review.approve), + concerns: this.normalizeStringList(reviews.flatMap(review => review.concerns)), + requiredChanges: this.normalizeStringList(reviews.flatMap(review => review.requiredChanges)), + reason: this.normalizeStringList(reviews.map(review => review.reason)).join(';'), + }; + } } diff --git a/src/advance/classic/fix/cognitive-types.ts b/src/advance/classic/fix/cognitive-types.ts index 371e29b..8947b88 100755 --- a/src/advance/classic/fix/cognitive-types.ts +++ b/src/advance/classic/fix/cognitive-types.ts @@ -69,4 +69,14 @@ export interface CognitiveDecision extends MaintainerDecision { reasoning: string; /** 决策置信度 */ confidence: 'high' | 'medium' | 'low'; + /** 认知阶段推断出的可能受影响文件,供修复执行阶段做受控审计 */ + affectedFiles?: string[]; + /** 修复完成后必须检查的语义验证目标 */ + verificationPlan?: string[]; + /** 方案评审阶段识别出的剩余风险或控制措施 */ + risks?: string[]; + /** 对候选方案进行红队挑战后保留的关键意见 */ + adversarialConcerns?: string[]; + /** 最终决策对红队意见的逐项回应 */ + adversarialResponses?: string[]; } diff --git a/src/advance/classic/fix/fix-tool-loop.ts b/src/advance/classic/fix/fix-tool-loop.ts index caf1403..aa3dc2c 100755 --- a/src/advance/classic/fix/fix-tool-loop.ts +++ b/src/advance/classic/fix/fix-tool-loop.ts @@ -130,12 +130,11 @@ export class FixToolLoop { this.maxTruncationRetries = options.maxTruncationRetries ?? 3; this.maxNoToolCallRetries = options.maxNoToolCallRetries ?? 2; this.maxUnchangedFinishRetries = options.maxUnchangedFinishRetries ?? 2; - this.maxStepsWithoutProgress = options.maxStepsWithoutProgress ?? 5; + this.maxStepsWithoutProgress = options.maxStepsWithoutProgress ?? Number.POSITIVE_INFINITY; this.staleReminderStep = options.staleReminderStep ?? Math.max(1, this.maxStepsWithoutProgress - 2); this.extraSystemPrompt = options.extraSystemPrompt ?? ''; - this.maxReadOnlySteps = - options.maxReadOnlySteps ?? Math.max(8, this.maxStepsWithoutProgress + 3); + this.maxReadOnlySteps = options.maxReadOnlySteps ?? Number.POSITIVE_INFINITY; this.readOnlyReminderStep = options.readOnlyReminderStep ?? Math.max(1, this.maxReadOnlySteps - 2); this.finalActingSteps = Math.max(1, options.finalActingSteps ?? 3); diff --git a/src/advance/classic/fix/maintainer-actor.ts b/src/advance/classic/fix/maintainer-actor.ts index 1abf103..c8d4e89 100755 --- a/src/advance/classic/fix/maintainer-actor.ts +++ b/src/advance/classic/fix/maintainer-actor.ts @@ -7,7 +7,12 @@ import type { } from '../provider/types.js'; import type { LlmClient } from '../../llm/client.js'; import type { WorktreeChangedFile, WorktreeManager } from '../worktree/worktree-manager.js'; -import type { MaintainerBrain, MaintainerDecision } from './maintainer-brain.js'; +import type { FixAttemptResult } from './fix-result.js'; +import type { + MaintainerBrain, + MaintainerDecision, + SemanticFixVerification, +} from './maintainer-brain.js'; import type { IssueScope } from './issue-scope.js'; import type { CognitiveDecision } from './cognitive-types.js'; import type { MrAgentState } from '../runners/shared/state-utils.js'; @@ -88,6 +93,18 @@ export interface BatchFixResult { itemResults: BatchFixItemResult[]; } +const MAX_VERIFICATION_CONTEXT_CHARS = 48_000; +const MAX_VERIFICATION_FILE_CHARS = 12_000; + +interface HookReflowState { + changed: boolean; + loop?: FixToolLoop; + result?: FixAttemptResult; + failure?: string; +} + +type HookReflowResult = boolean | HookReflowState; + export interface ApplyDecisionOptions { /** 单次修复失败后是否立即向 Reviewer 求助;Runner 默认自行管理重试次数。 */ askOnFixFailure?: boolean; @@ -192,6 +209,214 @@ export class MaintainerActor { return targets; } + /** 将认知阶段批准的文件转换为可用于实际工作区审计的路径集合。 */ + private async resolveApprovedPaths( + finding: ReviewFinding, + affectedFiles: string[] = [] + ): Promise> { + const approvedPaths = await this.resolveTargetPaths(finding.file); + for (const filePath of affectedFiles) { + const normalized = filePath.trim(); + if (!normalized) continue; + const resolvedPaths = await this.resolveTargetPaths(normalized); + for (const path of resolvedPaths) approvedPaths.add(path); + } + return approvedPaths; + } + + /** 读取提交前的实际代码,为独立语义验收提供当前状态而非工具调用记录。 */ + private async buildVerificationCodeContext( + changes: WorktreeChangedFile[], + fallbackContexts: Array<{ path: string; content: string }> = [] + ): Promise { + const fallbackByPath = new Map( + fallbackContexts.map(context => [this.normalizeRepoPath(context.path), context.content]) + ); + const sections: string[] = []; + let totalChars = 0; + + for (const change of changes) { + const path = this.normalizeRepoPath(change.path); + if (change.deleted) { + sections.push(`## ${path}(已删除)\n该文件已从当前工作区删除。`); + continue; + } + + let content: string | undefined; + try { + const resolved = await this.options.worktreeManager.resolveFilePath(path); + if (resolved) content = this.options.worktreeManager.readFile(resolved); + } catch (error) { + console.warn( + `[MaintainerActor] 读取语义验收文件 ${path} 失败: ${error instanceof Error ? error.message : String(error)}` + ); + } + content ??= fallbackByPath.get(path); + if (content === undefined) { + sections.push(`## ${path}\n当前文件内容无法读取。`); + continue; + } + + const remaining = MAX_VERIFICATION_CONTEXT_CHARS - totalChars; + if (remaining <= 0) break; + const excerpt = content.slice(0, Math.min(MAX_VERIFICATION_FILE_CHARS, remaining)); + sections.push(`## ${path}\n${excerpt}`); + totalChars += excerpt.length; + } + + for (const fallback of fallbackContexts) { + const path = this.normalizeRepoPath(fallback.path); + if (sections.some(section => section.startsWith(`## ${path}`))) continue; + const remaining = MAX_VERIFICATION_CONTEXT_CHARS - totalChars; + if (remaining <= 0) break; + const excerpt = fallback.content.slice(0, Math.min(MAX_VERIFICATION_FILE_CHARS, remaining)); + sections.push(`## ${path}(验收备用上下文)\n${excerpt}`); + totalChars += excerpt.length; + } + + return sections.join('\n\n') || '当前工作区没有可读取的代码变更。'; + } + + private async collectValidationSummary(): Promise { + try { + const result = await this.options.worktreeManager.validate(); + return JSON.stringify(result); + } catch (error) { + return `静态验证调用失败:${error instanceof Error ? error.message : String(error)}`; + } + } + + private buildDecisionRiskPrompt(decision: MaintainerDecision): string { + const sections = [ + decision.risks?.length ? `风险与控制措施:\n- ${decision.risks.join('\n- ')}` : '', + decision.adversarialConcerns?.length + ? `独立红队关键意见:\n- ${decision.adversarialConcerns.join('\n- ')}` + : '', + decision.adversarialResponses?.length + ? `主决策逐项回应:\n- ${decision.adversarialResponses.join('\n- ')}` + : '', + ].filter(Boolean); + if (sections.length === 0) return ''; + return [ + '认知阶段已经记录以下风险闭环。实现时必须用当前代码核对,不要把主决策自述当作已完成事实:', + ...sections, + ].join('\n\n'); + } + + private isSemanticVerification(value: unknown): value is SemanticFixVerification { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const result = value as Partial; + return ( + result.verdictSource === 'llm' && + typeof result.verdictId === 'string' && + typeof result.passed === 'boolean' && + typeof result.issueResolved === 'boolean' && + typeof result.evidence === 'string' && + Array.isArray(result.remainingIssues) && + result.remainingIssues.every(item => typeof item === 'string') && + typeof result.verificationSummary === 'string' && + (result.nextAction === 'commit' || + result.nextAction === 'revise' || + result.nextAction === 'ask') + ); + } + + private isSemanticVerificationApproved(verification: SemanticFixVerification): boolean { + return ( + this.hasLlmSemanticVerdict(verification) && + verification.passed && + verification.issueResolved && + verification.nextAction === 'commit' && + verification.evidence.trim().length > 0 && + verification.verificationSummary.trim().length > 0 && + verification.remainingIssues.length === 0 + ); + } + + private hasLlmSemanticVerdict(verification: SemanticFixVerification): boolean { + return verification.verdictSource === 'llm' && verification.verdictId.trim().length > 0; + } + + /** 调用大模型语义校准器;缺失、异常或非法结果一律关闭提交门禁。 */ + private async verifyCurrentFix(params: { + finding: ReviewFinding; + decision: MaintainerDecision; + changes: WorktreeChangedFile[]; + fallbackContexts?: Array<{ path: string; content: string }>; + previousFailure?: string; + }): Promise { + const brain = this.options.brain; + if (typeof brain.verifyFix !== 'function') { + throw new Error('当前 MaintainerBrain 未提供 verifyFix(),无法取得大模型语义裁决,禁止提交'); + } + + const changedFiles = params.changes.map(change => this.normalizeRepoPath(change.path)); + const deletedFiles = params.changes + .filter(change => change.deleted) + .map(change => this.normalizeRepoPath(change.path)); + const validationSummary = await this.collectValidationSummary(); + const codeContext = await this.buildVerificationCodeContext( + params.changes, + params.fallbackContexts + ); + + try { + const verification = await brain.verifyFix({ + finding: params.finding, + fixDescription: params.decision.fixDescription, + verificationPlan: params.decision.verificationPlan, + risks: params.decision.risks, + adversarialConcerns: params.decision.adversarialConcerns, + adversarialResponses: params.decision.adversarialResponses, + changedFiles, + deletedFiles, + codeContext, + validationSummary, + previousFailure: params.previousFailure, + }); + if (!this.isSemanticVerification(verification)) { + throw new Error('verifyFix() 未返回合法的大模型语义裁决,禁止提交'); + } + if (verification.verdictSource !== 'llm' || verification.verdictId.trim().length === 0) { + throw new Error('verifyFix() 未提供可追溯的大模型定论,禁止使用非 LLM 或无标识结果提交'); + } + return verification; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`大模型语义验收不可用,禁止提交:${message}`); + } + } + + private buildSemanticFailureReason(verification: SemanticFixVerification): string { + return compactDiscussionReason( + [ + `裁决来源:大模型(${verification.verdictId})`, + `独立语义验收未通过:${verification.verificationSummary}`, + verification.evidence ? `验收证据:${verification.evidence}` : '', + verification.remainingIssues.length > 0 + ? `剩余问题:${verification.remainingIssues.join(';')}` + : '', + `下一步:${verification.nextAction}`, + ] + .filter(Boolean) + .join('\n') + ); + } + + private buildSemanticReflowPrompt(verification: SemanticFixVerification): string { + return [ + '上一轮修改已经完成工具循环,但没有通过独立语义验收。请不要直接 finish;基于当前工作区重新检查根因并修复。', + `验收摘要:${verification.verificationSummary}`, + verification.evidence ? `验收证据:${verification.evidence}` : '', + verification.remainingIssues.length > 0 + ? `验收指出的剩余问题:\n- ${verification.remainingIssues.join('\n- ')}` + : '', + '完成必要修改后必须重新运行相关验证,再调用 finish。仍无法证明问题已解决时应明确失败,不要声称已修复。', + ] + .filter(Boolean) + .join('\n\n'); + } + private assertWriteScope( changes: WorktreeChangedFile[], allowedPaths: Set, @@ -213,8 +438,52 @@ export class MaintainerActor { appliedFiles.clear(); deletedFiles.clear(); for (const change of changes) { - if (change.deleted) deletedFiles.add(change.path); - else appliedFiles.add(change.path); + const path = this.normalizeRepoPath(change.path); + if (change.deleted) deletedFiles.add(path); + else appliedFiles.add(path); + } + } + + private mergeLoopChangeSets( + loop: FixToolLoop | undefined, + appliedFiles: Set, + deletedFiles: Set + ): void { + if (!loop) return; + for (const filePath of loop.getAppliedFiles()) { + const path = this.normalizeRepoPath(filePath); + appliedFiles.add(path); + deletedFiles.delete(path); + } + for (const filePath of loop.getDeletedFiles()) { + const path = this.normalizeRepoPath(filePath); + deletedFiles.add(path); + appliedFiles.delete(path); + } + } + + private async refreshChangedFilesAfterReflow( + appliedFiles: Set, + deletedFiles: Set, + reflowState?: HookReflowState + ): Promise { + this.mergeLoopChangeSets(reflowState?.loop, appliedFiles, deletedFiles); + const changes = await this.listChangedFiles(Array.from(appliedFiles), Array.from(deletedFiles)); + this.syncChangedFileSets(changes, appliedFiles, deletedFiles); + return changes; + } + + private async requireSemanticVerification(params: { + finding: ReviewFinding; + decision: MaintainerDecision; + changes: WorktreeChangedFile[]; + fallbackContexts?: Array<{ path: string; content: string }>; + previousFailure?: string; + failurePrefix: string; + }): Promise { + const verification = await this.verifyCurrentFix(params); + if (!this.isSemanticVerificationApproved(verification)) { + throw new Error(`${params.failurePrefix}:${this.buildSemanticFailureReason(verification)}`); } } @@ -445,6 +714,12 @@ export class MaintainerActor { fileContent: string; scope?: IssueScope; deleteFile?: boolean; + fixDescription?: string; + affectedFiles?: string[]; + verificationPlan?: string[]; + risks?: string[]; + adversarialConcerns?: string[]; + adversarialResponses?: string[]; }>, _originalComment: string ): Promise { @@ -456,6 +731,7 @@ export class MaintainerActor { const appliedFiles = new Set(); const deletedFiles = new Set(); const approvedChangedPaths = new Set(); + const allApprovedPaths = new Set(); const alreadyFixedItems: Array<{ file: string; line: number; reason: string }> = []; const itemResults: PendingBatchFixItemResult[] = []; let currentIndex = 0; @@ -516,6 +792,25 @@ export class MaintainerActor { for (currentIndex = 0; currentIndex < fixableItems.length; currentIndex++) { const item = fixableItems[currentIndex]; const { finding } = item; + const itemDecision: MaintainerDecision = { + action: 'fix', + reason: '批量修复中的认知决策', + scope: item.scope, + fixDescription: item.fixDescription, + affectedFiles: item.affectedFiles, + verificationPlan: item.verificationPlan, + risks: item.risks, + adversarialConcerns: item.adversarialConcerns, + adversarialResponses: item.adversarialResponses, + }; + const itemApprovedPaths = await this.resolveApprovedPaths( + finding, + item.scope === 'cross-file' ? item.affectedFiles : [] + ); + for (const path of itemApprovedPaths) allApprovedPaths.add(path); + const itemApprovedPathText = Array.from(itemApprovedPaths).join(', '); + const fallbackContexts = [{ path: finding.file, content: item.fileContent }]; + if (item.deleteFile) { console.log(`[MaintainerActor] 批量修复中删除文件: ${finding.file}`); const resolved = await this.options.worktreeManager.resolveFilePath(finding.file); @@ -532,16 +827,116 @@ export class MaintainerActor { return buildResult(false, reason); } await this.options.worktreeManager.removeFile(resolved); - const targetPaths = new Set([ - this.normalizeRepoPath(finding.file), - this.normalizeRepoPath(resolved), - ]); - const changes = await this.listChangedFiles(Array.from(appliedFiles), [ + let changes = await this.listChangedFiles(Array.from(appliedFiles), [ ...Array.from(deletedFiles), finding.file, ]); - const allowedPaths = new Set([...approvedChangedPaths, ...targetPaths]); + const allowedPaths = new Set([...approvedChangedPaths, ...itemApprovedPaths]); this.assertWriteScope(changes, allowedPaths, `finding ${finding.file}:${finding.line}`); + let itemChanges = changes.filter(change => itemApprovedPaths.has(change.path)); + let verification = await this.verifyCurrentFix({ + finding, + decision: itemDecision, + changes: itemChanges, + fallbackContexts, + }); + if (!this.isSemanticVerificationApproved(verification)) { + const firstFailure = this.buildSemanticFailureReason(verification); + const reflowLoop = new FixToolLoop({ + llmClient: this.options.llmClient, + worktreeManager: this.options.worktreeManager, + finding: { ...finding, autoFixable: true }, + mr, + memoryClient: this.options.memoryClient, + recallPlanner: this.options.recallPlanner, + extraSystemPrompt: [ + '上一轮删除文件后没有通过独立语义验收。文件已经删除,不要恢复该文件;如剩余问题涉及已批准的关联文件,只修改认知阶段批准的路径。', + item.fixDescription ? `认知阶段选择的修复方向:${item.fixDescription}` : '', + this.buildDecisionRiskPrompt(itemDecision), + item.scope === 'cross-file' + ? `该 finding 的批准路径为:${Array.from(itemApprovedPaths).join(', ')}` + : `该 finding 是局部删除问题,只允许保留删除目标文件(批准路径:${Array.from(itemApprovedPaths).join(', ')})`, + this.buildSemanticReflowPrompt(verification), + baselineFailurePrompt, + ] + .filter(Boolean) + .join('\n\n'), + recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), + }); + const reflowResult = await reflowLoop.run(); + this.trackFinalActingRound(reflowLoop); + if (!reflowResult.success && !reflowResult.alreadyFixed) { + const reason = `${firstFailure}\n回流修复失败:${reflowResult.reason}`; + itemResults.push({ + file: finding.file, + line: finding.line, + status: 'failed', + reason, + }); + addDeferredItems(currentIndex + 1, '前序 finding 未完成,本轮尚未执行'); + await this.recordFixOutcome(mr.iid, finding, false, reason); + return buildResult(false, reason); + } + + const reflowTouchedPaths = new Set( + [...reflowLoop.getAppliedFiles(), ...reflowLoop.getDeletedFiles()].map(path => + this.normalizeRepoPath(path) + ) + ); + if ( + !reflowResult.alreadyFixed && + !Array.from(reflowTouchedPaths).some(path => itemApprovedPaths.has(path)) + ) { + const reason = `${firstFailure}\n回流修复未修改认知阶段批准的文件`; + itemResults.push({ + file: finding.file, + line: finding.line, + status: 'failed', + reason, + }); + addDeferredItems(currentIndex + 1, '前序 finding 未完成,本轮尚未执行'); + await this.recordFixOutcome(mr.iid, finding, false, reason); + return buildResult(false, reason); + } + + changes = await this.listChangedFiles( + [ + ...Array.from(appliedFiles), + ...changes.filter(change => !change.deleted).map(change => change.path), + ...reflowLoop.getAppliedFiles(), + ], + [ + ...Array.from(deletedFiles), + ...changes.filter(change => change.deleted).map(change => change.path), + ...reflowLoop.getDeletedFiles(), + ] + ); + this.assertWriteScope( + changes, + new Set([...approvedChangedPaths, ...itemApprovedPaths]), + `finding ${finding.file}:${finding.line}` + ); + itemChanges = changes.filter(change => itemApprovedPaths.has(change.path)); + verification = await this.verifyCurrentFix({ + finding, + decision: itemDecision, + changes: itemChanges, + fallbackContexts, + previousFailure: firstFailure, + }); + if (!this.isSemanticVerificationApproved(verification)) { + const reason = `${firstFailure}\n第二次独立语义验收仍未通过:${this.buildSemanticFailureReason(verification)}`; + itemResults.push({ + file: finding.file, + line: finding.line, + status: 'failed', + reason, + }); + addDeferredItems(currentIndex + 1, '前序 finding 未通过语义验收,本轮尚未执行'); + await this.recordFixOutcome(mr.iid, finding, false, reason); + return buildResult(false, reason); + } + } this.syncChangedFileSets(changes, appliedFiles, deletedFiles); for (const change of changes) approvedChangedPaths.add(change.path); itemResults.push({ @@ -552,27 +947,39 @@ export class MaintainerActor { continue; } - const loop = new FixToolLoop({ - llmClient: this.options.llmClient, - worktreeManager: this.options.worktreeManager, - finding, - mr, - memoryClient: this.options.memoryClient, - recallPlanner: this.options.recallPlanner, - extraSystemPrompt: [ - `这是同一条 discussion 中的批量修复任务之一。当前只处理 ${finding.file}:${finding.line};严禁引用、判断或复用同一 discussion 中其他 finding 的文件、函数和证据。`, - item.scope === 'cross-file' - ? '该 finding 被识别为跨文件问题;仅在 Reviewer 问题确实要求时修改必要调用点。' - : '该 finding 是局部问题,只允许修改目标文件。', - baselineFailurePrompt, - ] - .filter(Boolean) - .join('\n\n'), - recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), - }); + const runBatchFixLoop = async (feedback?: string) => { + const loop = new FixToolLoop({ + llmClient: this.options.llmClient, + worktreeManager: this.options.worktreeManager, + finding, + mr, + memoryClient: this.options.memoryClient, + recallPlanner: this.options.recallPlanner, + extraSystemPrompt: [ + `这是同一条 discussion 中的批量修复任务之一。当前只处理 ${finding.file}:${finding.line};严禁引用、判断或复用同一 discussion 中其他 finding 的文件、函数和证据。`, + item.fixDescription + ? `认知阶段选择的修复方向:${item.fixDescription}。该方向只是执行起点,必须结合当前代码验证其完整性。` + : '', + item.scope === 'cross-file' + ? `该 finding 被识别为跨文件问题;认知阶段批准的文件集合为:${itemApprovedPathText || '仅目标文件'}。只修改解决问题所必需且位于该集合内的文件。` + : `该 finding 是局部问题,只允许修改目标文件或认知阶段明确批准的路径(批准路径:${itemApprovedPathText})。`, + item.verificationPlan?.length + ? `完成修改后必须满足以下语义验收计划:\n- ${item.verificationPlan.join('\n- ')}` + : '', + this.buildDecisionRiskPrompt(itemDecision), + baselineFailurePrompt, + feedback, + ] + .filter(Boolean) + .join('\n\n'), + recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), + }); + const result = await loop.run(); + this.trackFinalActingRound(loop); + return { loop, result }; + }; - const result = await loop.run(); - this.trackFinalActingRound(loop); + let { loop, result } = await runBatchFixLoop(); console.log( `[MaintainerActor] finding ${finding.file}:${finding.line} 修复结果: success=${result.success}, reason=${result.reason}` ); @@ -609,23 +1016,28 @@ export class MaintainerActor { const loopAppliedFiles = loop.getAppliedFiles(); const loopDeletedFiles = loop.getDeletedFiles(); - const changes = await this.listChangedFiles( + let changes = await this.listChangedFiles( [...Array.from(appliedFiles), ...loopAppliedFiles], [...Array.from(deletedFiles), ...loopDeletedFiles] ); const targetPaths = await this.resolveTargetPaths(finding.file); const changedPaths = new Set(changes.map(change => change.path)); const targetChanged = Array.from(targetPaths).some(target => changedPaths.has(target)); - const introducedChange = changes.some(change => !approvedChangedPaths.has(change.path)); - if (item.scope !== 'cross-file') { - const allowedPaths = new Set([...approvedChangedPaths, ...targetPaths]); - this.assertWriteScope(changes, allowedPaths, `finding ${finding.file}:${finding.line}`); - } - if ( - (item.scope === 'cross-file' && !introducedChange) || - (!targetChanged && item.scope !== 'cross-file') - ) { - const reason = '修复循环未修改 finding 指向的目标文件'; + const loopTouchedPaths = new Set( + [...loop.getAppliedFiles(), ...loop.getDeletedFiles()].map(path => + this.normalizeRepoPath(path) + ) + ); + const allowedPaths = new Set([...approvedChangedPaths, ...itemApprovedPaths]); + this.assertWriteScope(changes, allowedPaths, `finding ${finding.file}:${finding.line}`); + const itemTouchedPaths = Array.from(loopTouchedPaths).filter(path => + itemApprovedPaths.has(path) + ); + if (itemTouchedPaths.length === 0 || (!targetChanged && item.scope !== 'cross-file')) { + const reason = + item.scope === 'cross-file' + ? '修复循环未修改认知阶段批准的文件' + : '修复循环未修改 finding 指向的目标文件'; itemResults.push({ file: finding.file, line: finding.line, @@ -636,6 +1048,103 @@ export class MaintainerActor { await this.recordFixOutcome(mr.iid, finding, false, reason); return buildResult(false, reason); } + + let itemChanges = changes.filter(change => itemApprovedPaths.has(change.path)); + let semanticVerification = await this.verifyCurrentFix({ + finding, + decision: itemDecision, + changes: itemChanges, + fallbackContexts, + }); + if (!this.isSemanticVerificationApproved(semanticVerification)) { + const firstFailure = this.buildSemanticFailureReason(semanticVerification); + console.warn( + `[MaintainerActor] 批量 finding ${finding.file}:${finding.line} 语义验收未通过,回流一次: ${firstFailure}` + ); + ({ loop, result } = await runBatchFixLoop( + this.buildSemanticReflowPrompt(semanticVerification) + )); + if (result.alreadyFixed) { + const reason = compactDiscussionReason(result.evidence || result.reason); + alreadyFixedItems.push({ + file: finding.file, + line: finding.line, + reason, + }); + itemResults.push({ + file: finding.file, + line: finding.line, + status: 'already-fixed', + reason, + }); + await this.recordFixOutcome(mr.iid, finding, true, `already-fixed: ${reason}`); + continue; + } + if (!result.success) { + const reason = `${firstFailure}\n回流修复失败:${result.reason}`; + itemResults.push({ + file: finding.file, + line: finding.line, + status: 'failed', + reason, + }); + addDeferredItems(currentIndex + 1, '前序 finding 未完成,本轮尚未执行'); + await this.recordFixOutcome(mr.iid, finding, false, reason); + return buildResult(false, reason); + } + + const reflowTouchedPaths = new Set( + [...loop.getAppliedFiles(), ...loop.getDeletedFiles()].map(path => + this.normalizeRepoPath(path) + ) + ); + if (!Array.from(reflowTouchedPaths).some(path => itemApprovedPaths.has(path))) { + const reason = `${firstFailure}\n回流修复未修改认知阶段批准的文件`; + itemResults.push({ + file: finding.file, + line: finding.line, + status: 'failed', + reason, + }); + addDeferredItems(currentIndex + 1, '前序 finding 未完成,本轮尚未执行'); + await this.recordFixOutcome(mr.iid, finding, false, reason); + return buildResult(false, reason); + } + changes = await this.listChangedFiles( + [ + ...Array.from(appliedFiles), + ...changes.filter(change => !change.deleted).map(change => change.path), + ...loop.getAppliedFiles(), + ], + [ + ...Array.from(deletedFiles), + ...changes.filter(change => change.deleted).map(change => change.path), + ...loop.getDeletedFiles(), + ] + ); + this.assertWriteScope(changes, allowedPaths, `finding ${finding.file}:${finding.line}`); + itemChanges = changes.filter(change => itemApprovedPaths.has(change.path)); + semanticVerification = await this.verifyCurrentFix({ + finding, + decision: itemDecision, + changes: itemChanges, + fallbackContexts, + previousFailure: firstFailure, + }); + if (!this.isSemanticVerificationApproved(semanticVerification)) { + const reason = `${firstFailure}\n第二次独立语义验收仍未通过:${this.buildSemanticFailureReason(semanticVerification)}`; + itemResults.push({ + file: finding.file, + line: finding.line, + status: 'failed', + reason, + }); + addDeferredItems(currentIndex + 1, '前序 finding 未通过语义验收,本轮尚未执行'); + await this.recordFixOutcome(mr.iid, finding, false, reason); + return buildResult(false, reason); + } + } + this.syncChangedFileSets(changes, appliedFiles, deletedFiles); for (const change of changes) approvedChangedPaths.add(change.path); itemResults.push({ @@ -683,12 +1192,43 @@ export class MaintainerActor { changeDescription, () => buildDefaultBatchMessage(Array.from(appliedFiles), Array.from(deletedFiles)), distilledFailure => this.reflowAfterHookFailure(mr, baseFinding, distilledFailure), - async () => { - const changes = await this.listChangedFiles( - Array.from(appliedFiles), - Array.from(deletedFiles) + async (afterHookReflow, reflowState) => { + const changes = await this.refreshChangedFilesAfterReflow( + appliedFiles, + deletedFiles, + reflowState ); - this.assertWriteScope(changes, approvedChangedPaths, '批量修复提交前校验'); + this.assertWriteScope(changes, allApprovedPaths, '批量修复提交前校验'); + if (!afterHookReflow) return; + + for (const itemResult of itemResults) { + if (itemResult.status !== 'pending-commit') continue; + const item = fixableItems.find( + candidate => + candidate.finding.file === itemResult.file && + candidate.finding.line === itemResult.line + ); + if (!item) continue; + const itemApprovedPaths = await this.resolveApprovedPaths( + item.finding, + item.scope === 'cross-file' ? item.affectedFiles : [] + ); + const itemChanges = changes.filter(change => itemApprovedPaths.has(change.path)); + await this.requireSemanticVerification({ + finding: item.finding, + decision: { + action: 'fix', + reason: '批量修复中的认知决策', + scope: item.scope, + affectedFiles: item.affectedFiles, + verificationPlan: item.verificationPlan, + }, + changes: itemChanges, + fallbackContexts: [{ path: item.finding.file, content: item.fileContent }], + previousFailure: reflowState?.failure, + failurePrefix: `hook 回流后 finding ${item.finding.file}:${item.finding.line} 语义验收`, + }); + } } ); } @@ -748,13 +1288,29 @@ export class MaintainerActor { autoFixable: true, }; const fixGuidance = decision.fixDescription?.trim(); - const extraSystemPrompt = fixGuidance - ? [ - 'MaintainerBrain 提供了以下补充修复方向。它只是实现提示,不能替代或覆盖 Reviewer 的原始 finding:', - fixGuidance, - '请始终以 Reviewer 原始问题、目标文件和建议为准,结合当前代码验证该方向是否完整。', - ].join('\n') - : undefined; + const approvedPaths = await this.resolveApprovedPaths( + finding, + decision.scope === 'cross-file' ? decision.affectedFiles : [] + ); + const approvedPathText = Array.from(approvedPaths).join(', '); + const extraSystemPrompt = [ + fixGuidance + ? [ + 'MaintainerBrain 提供了以下补充修复方向。它只是实现提示,不能替代或覆盖 Reviewer 的原始 finding:', + fixGuidance, + '请始终以 Reviewer 原始问题、目标文件和建议为准,结合当前代码验证该方向是否完整。', + ].join('\n') + : '', + decision.scope === 'cross-file' + ? `该 finding 被识别为跨文件问题;认知阶段批准的文件集合为:${approvedPathText || '仅目标文件'}。只修改解决问题所必需且位于该集合内的文件。` + : `该 finding 是局部问题,只允许修改目标文件(批准路径:${approvedPathText})。`, + decision.verificationPlan?.length + ? `完成修改后必须满足以下语义验收计划:\n- ${decision.verificationPlan.join('\n- ')}` + : '', + this.buildDecisionRiskPrompt(decision), + ] + .filter(Boolean) + .join('\n\n'); console.log(`[MaintainerActor] 执行修复: ${finding.file}:${finding.line}`); @@ -769,27 +1325,25 @@ export class MaintainerActor { const baselineFailure = await this.prepareRepairEnvironment(); const baselineFailurePrompt = this.buildBaselineFailurePrompt(baselineFailure); - const loop = new FixToolLoop({ - llmClient: this.options.llmClient, - worktreeManager: this.options.worktreeManager, - finding: syntheticFinding, - mr, - memoryClient: this.options.memoryClient, - recallPlanner: this.options.recallPlanner, - extraSystemPrompt: [ - extraSystemPrompt, - decision.scope === 'cross-file' - ? '该 finding 被识别为跨文件问题;仅修改解决 Reviewer 问题所必需的文件。' - : '该 finding 是局部问题,只允许修改目标文件。', - baselineFailurePrompt, - ] - .filter(Boolean) - .join('\n\n'), - recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), - }); + const runFixLoop = async (feedback?: string) => { + const loop = new FixToolLoop({ + llmClient: this.options.llmClient, + worktreeManager: this.options.worktreeManager, + finding: syntheticFinding, + mr, + memoryClient: this.options.memoryClient, + recallPlanner: this.options.recallPlanner, + extraSystemPrompt: [extraSystemPrompt, baselineFailurePrompt, feedback] + .filter(Boolean) + .join('\n\n'), + recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(finding), + }); + const result = await loop.run(); + this.trackFinalActingRound(loop); + return { loop, result }; + }; - const fixResult = await loop.run(); - this.trackFinalActingRound(loop); + let { loop, result: fixResult } = await runFixLoop(); console.log( `[MaintainerActor] 修复结果: success=${fixResult.success}, reason=${fixResult.reason}` ); @@ -809,19 +1363,95 @@ export class MaintainerActor { return this.emptyActionResult(false, fixResult.reason); } - const changes = await this.listChangedFiles(loop.getAppliedFiles(), loop.getDeletedFiles()); + let changes = await this.listChangedFiles(loop.getAppliedFiles(), loop.getDeletedFiles()); if (changes.length === 0) { return this.emptyActionResult(false, '修复循环结束后 git 工作区没有实际变更'); } const targetPaths = await this.resolveTargetPaths(finding.file); - if (decision.scope !== 'cross-file') { - this.assertWriteScope(changes, targetPaths, `finding ${finding.file}:${finding.line}`); - const targetChanged = changes.some(change => targetPaths.has(change.path)); - if (!targetChanged) { - return this.emptyActionResult(false, '修复循环未修改 finding 指向的目标文件'); + const validateChangedPaths = (currentChanges: WorktreeChangedFile[]): void => { + this.assertWriteScope( + currentChanges, + approvedPaths, + `finding ${finding.file}:${finding.line}` + ); + const changedPaths = new Set(currentChanges.map(change => change.path)); + const targetChanged = Array.from(targetPaths).some(target => changedPaths.has(target)); + const approvedChanged = currentChanges.some(change => approvedPaths.has(change.path)); + if ((decision.scope !== 'cross-file' && !targetChanged) || !approvedChanged) { + throw new Error( + decision.scope === 'cross-file' + ? '修复循环未修改认知阶段批准的文件' + : '修复循环未修改 finding 指向的目标文件' + ); + } + }; + validateChangedPaths(changes); + + let semanticVerification = await this.verifyCurrentFix({ + finding, + decision, + changes, + }); + if (!this.isSemanticVerificationApproved(semanticVerification)) { + const firstFailure = this.buildSemanticFailureReason(semanticVerification); + console.warn(`[MaintainerActor] 单条修复语义验收未通过,回流一次: ${firstFailure}`); + ({ loop, result: fixResult } = await runFixLoop( + this.buildSemanticReflowPrompt(semanticVerification) + )); + console.log( + `[MaintainerActor] 语义验收回流结果: success=${fixResult.success}, reason=${fixResult.reason}` + ); + + if (fixResult.alreadyFixed) { + await this.recordFixOutcome( + mr.iid, + finding, + true, + `already-fixed after semantic reflow: ${fixResult.reason}` + ); + decision.action = 'ignore'; + decision.alreadyFixed = true; + decision.reason = fixResult.reason; + decision.replyBody = fixResult.evidence || fixResult.reason; + const delivery = await this.ignore(mr, discussion, decision.reason, decision, state); + return this.withDeliveryResult(true, delivery); + } + if (!fixResult.success) { + const reason = `${firstFailure}\n回流修复失败:${fixResult.reason}`; + await this.recordFixOutcome(mr.iid, finding, false, reason); + return this.emptyActionResult(false, reason); + } + + changes = await this.listChangedFiles(loop.getAppliedFiles(), loop.getDeletedFiles()); + if (changes.length === 0) { + const reason = `${firstFailure}\n回流修复未产生实际文件变更`; + await this.recordFixOutcome(mr.iid, finding, false, reason); + return this.emptyActionResult(false, reason); + } + validateChangedPaths(changes); + semanticVerification = await this.verifyCurrentFix({ + finding, + decision, + changes, + previousFailure: firstFailure, + }); + if (!this.isSemanticVerificationApproved(semanticVerification)) { + const reason = `${firstFailure}\n第二次独立语义验收仍未通过:${this.buildSemanticFailureReason(semanticVerification)}`; + await this.recordFixOutcome(mr.iid, finding, false, reason); + return this.emptyActionResult(false, reason); } } - const approvedChangedPaths = new Set(changes.map(change => change.path)); + + if (decision.scope !== 'cross-file') { + this.assertWriteScope(changes, approvedPaths, `finding ${finding.file}:${finding.line}`); + } + + const trackedAppliedFiles = new Set( + changes.filter(change => !change.deleted).map(change => this.normalizeRepoPath(change.path)) + ); + const trackedDeletedFiles = new Set( + changes.filter(change => change.deleted).map(change => this.normalizeRepoPath(change.path)) + ); console.log(`[MaintainerActor] 阶段=commit-push 提交并推送修复到分支: ${mr.sourceBranch}`); await this.commitWithConventionRetry( @@ -830,12 +1460,23 @@ export class MaintainerActor { () => buildDefaultFixMessage(finding), distilledFailure => this.reflowAfterHookFailure(mr, syntheticFinding, distilledFailure, extraSystemPrompt), - async () => { - const currentChanges = await this.listChangedFiles( - loop.getAppliedFiles(), - loop.getDeletedFiles() + async (afterHookReflow, reflowState) => { + if (reflowState?.loop) loop = reflowState.loop; + changes = await this.refreshChangedFilesAfterReflow( + trackedAppliedFiles, + trackedDeletedFiles, + reflowState ); - this.assertWriteScope(currentChanges, approvedChangedPaths, '单条修复提交前校验'); + validateChangedPaths(changes); + if (afterHookReflow) { + await this.requireSemanticVerification({ + finding, + decision, + changes, + previousFailure: reflowState?.failure, + failurePrefix: 'hook 回流后单条修复语义验收', + }); + } } ); @@ -894,13 +1535,26 @@ export class MaintainerActor { console.log(`[MaintainerActor] 阶段=delete 删除文件: ${resolvedPath}`); await this.options.worktreeManager.removeFile(resolvedPath); - const changes = await this.listChangedFiles([], [finding.file]); - const targetPaths = new Set([ - this.normalizeRepoPath(finding.file), - this.normalizeRepoPath(resolvedPath), - ]); - this.assertWriteScope(changes, targetPaths, `删除 finding ${finding.file}:${finding.line}`); - const approvedChangedPaths = new Set(changes.map(change => change.path)); + let changes = await this.listChangedFiles([], [finding.file]); + const approvedPaths = await this.resolveApprovedPaths(finding, decision.affectedFiles); + this.assertWriteScope(changes, approvedPaths, `删除 finding ${finding.file}:${finding.line}`); + const verification = await this.verifyCurrentFix({ + finding, + decision, + changes, + fallbackContexts: [{ path: finding.file, content: `文件 ${finding.file} 已删除。` }], + }); + if (!this.isSemanticVerificationApproved(verification)) { + const reason = this.buildSemanticFailureReason(verification); + await this.recordFixOutcome(mr.iid, finding, false, reason); + return this.emptyActionResult(false, reason); + } + const trackedAppliedFiles = new Set( + changes.filter(change => !change.deleted).map(change => this.normalizeRepoPath(change.path)) + ); + const trackedDeletedFiles = new Set( + changes.filter(change => change.deleted).map(change => this.normalizeRepoPath(change.path)) + ); const changeDescription = `Reviewer 指出文件 ${finding.file} 不应上传,已从 MR 中删除。`; console.log(`[MaintainerActor] 阶段=commit-push 提交删除到分支: ${mr.sourceBranch}`); @@ -909,9 +1563,23 @@ export class MaintainerActor { changeDescription, () => buildDefaultDeleteMessage(basename(finding.file)), distilledFailure => this.reflowAfterHookFailure(mr, finding, distilledFailure), - async () => { - const currentChanges = await this.listChangedFiles([], [finding.file]); - this.assertWriteScope(currentChanges, approvedChangedPaths, '删除修复提交前校验'); + async (afterHookReflow, reflowState) => { + changes = await this.refreshChangedFilesAfterReflow( + trackedAppliedFiles, + trackedDeletedFiles, + reflowState + ); + this.assertWriteScope(changes, approvedPaths, '删除修复提交前校验'); + if (afterHookReflow) { + await this.requireSemanticVerification({ + finding, + decision, + changes, + fallbackContexts: [{ path: finding.file, content: `文件 ${finding.file} 已删除。` }], + previousFailure: reflowState?.failure, + failurePrefix: 'hook 回流后删除文件语义验收', + }); + } } ); @@ -1062,18 +1730,25 @@ export class MaintainerActor { const baselineFailure = await this.prepareRepairEnvironment(); const baselineFailurePrompt = this.buildBaselineFailurePrompt(baselineFailure); - const loop = new FixToolLoop({ - llmClient: this.options.llmClient, - worktreeManager: this.options.worktreeManager, - finding: syntheticFinding, - mr, - memoryClient: this.options.memoryClient, - recallPlanner: this.options.recallPlanner, - extraSystemPrompt: [extraSystemPrompt, baselineFailurePrompt].filter(Boolean).join('\n\n'), - }); + const runCiFixLoop = async (feedback?: string) => { + const loop = new FixToolLoop({ + llmClient: this.options.llmClient, + worktreeManager: this.options.worktreeManager, + finding: syntheticFinding, + mr, + memoryClient: this.options.memoryClient, + recallPlanner: this.options.recallPlanner, + extraSystemPrompt: [extraSystemPrompt, baselineFailurePrompt, feedback] + .filter(Boolean) + .join('\n\n'), + recheckAlreadyFixed: () => this.options.brain.recheckAlreadyFixed(syntheticFinding), + }); + const result = await loop.run(); + this.trackFinalActingRound(loop); + return { loop, result }; + }; - const fixResult = await loop.run(); - this.trackFinalActingRound(loop); + let { loop, result: fixResult } = await runCiFixLoop(); console.log( `[MaintainerActor] CI 修复结果: success=${fixResult.success}, reason=${fixResult.reason}` ); @@ -1082,12 +1757,68 @@ export class MaintainerActor { return { codeApplied: false, reason: fixResult.reason, appliedFiles: [] }; } - const changes = await this.listChangedFiles(loop.getAppliedFiles(), loop.getDeletedFiles()); - const appliedFiles = changes.map(change => change.path); + let changes = await this.listChangedFiles(loop.getAppliedFiles(), loop.getDeletedFiles()); if (changes.length === 0) { return { codeApplied: false, reason: 'CI 修复未产生任何文件变更', appliedFiles: [] }; } - const approvedChangedPaths = new Set(appliedFiles); + const approvedChangedPaths = new Set(changes.map(change => change.path)); + const ciDecision: MaintainerDecision = { + action: 'fix', + reason: 'CI 失败修复中的语义决策', + fixDescription: '根据 CI 失败日志定位并修复根因', + scope: 'cross-file', + affectedFiles: Array.from(approvedChangedPaths), + verificationPlan: ['CI 日志对应的根因已消除', '相关本地验证通过'], + }; + const ciFallbackContexts = [{ path: 'ci-failure.log', content: failureDigest }]; + let semanticVerification = await this.verifyCurrentFix({ + finding: syntheticFinding, + decision: ciDecision, + changes, + fallbackContexts: ciFallbackContexts, + }); + if (!this.isSemanticVerificationApproved(semanticVerification)) { + const firstFailure = this.buildSemanticFailureReason(semanticVerification); + ({ loop, result: fixResult } = await runCiFixLoop( + this.buildSemanticReflowPrompt(semanticVerification) + )); + if (fixResult.alreadyFixed || !fixResult.success) { + const reason = fixResult.alreadyFixed + ? `${firstFailure}\nCI 语义回流判定当前状态无需继续修改,但未形成可提交的修复结果` + : `${firstFailure}\nCI 语义回流失败:${fixResult.reason}`; + return { codeApplied: false, reason, appliedFiles: [] }; + } + changes = await this.listChangedFiles(loop.getAppliedFiles(), loop.getDeletedFiles()); + if (changes.length === 0) { + return { + codeApplied: false, + reason: `${firstFailure}\nCI 语义回流未产生实际文件变更`, + appliedFiles: [], + }; + } + this.assertWriteScope(changes, approvedChangedPaths, 'CI 语义回流提交前校验'); + semanticVerification = await this.verifyCurrentFix({ + finding: syntheticFinding, + decision: ciDecision, + changes, + fallbackContexts: ciFallbackContexts, + previousFailure: firstFailure, + }); + if (!this.isSemanticVerificationApproved(semanticVerification)) { + return { + codeApplied: false, + reason: `${firstFailure}\n第二次独立语义验收仍未通过:${this.buildSemanticFailureReason(semanticVerification)}`, + appliedFiles: [], + }; + } + } + const trackedAppliedFiles = new Set( + changes.filter(change => !change.deleted).map(change => this.normalizeRepoPath(change.path)) + ); + const trackedDeletedFiles = new Set( + changes.filter(change => change.deleted).map(change => this.normalizeRepoPath(change.path)) + ); + let appliedFiles = changes.map(change => change.path); console.log(`[MaintainerActor] 阶段=commit-push 提交 CI 修复到分支: ${mr.sourceBranch}`); await this.commitWithConventionRetry( @@ -1101,12 +1832,24 @@ export class MaintainerActor { ...appliedFiles.map(f => `- ${f}`), ].join('\n'), distilledFailure => this.reflowAfterHookFailure(mr, syntheticFinding, distilledFailure), - async () => { - const currentChanges = await this.listChangedFiles( - loop.getAppliedFiles(), - loop.getDeletedFiles() + async (afterHookReflow, reflowState) => { + changes = await this.refreshChangedFilesAfterReflow( + trackedAppliedFiles, + trackedDeletedFiles, + reflowState ); - this.assertWriteScope(currentChanges, approvedChangedPaths, 'CI 修复提交前校验'); + this.assertWriteScope(changes, approvedChangedPaths, 'CI 修复提交前校验'); + appliedFiles = changes.map(change => change.path); + if (afterHookReflow) { + await this.requireSemanticVerification({ + finding: syntheticFinding, + decision: ciDecision, + changes, + fallbackContexts: ciFallbackContexts, + previousFailure: reflowState?.failure, + failurePrefix: 'hook 回流后 CI 修复语义验收', + }); + } } ); @@ -1222,17 +1965,19 @@ export class MaintainerActor { branch: string, changeDescription: string, buildDefaultMessage: () => string, - reflow?: (distilledFailure: string) => Promise, - verifyChanges?: () => Promise + reflow?: (distilledFailure: string) => Promise, + verifyChanges?: (afterHookReflow?: boolean, reflowState?: HookReflowState) => Promise ): Promise { const wm = this.options.worktreeManager; let message = await this.buildCommitMessage(changeDescription, buildDefaultMessage); let recoveredConvention: string | undefined; let commitMessageRecoveryAttempted = false; let hookReflowAttempted = false; + let afterHookReflow = false; + let reflowState: HookReflowState | undefined; for (let attempt = 0; attempt < 3; attempt++) { - await verifyChanges?.(); + await verifyChanges?.(afterHookReflow, reflowState); try { await wm.commitAndPush(branch, message, { setUpstream: false }); if (attempt === 0) this.incrMetric('commitFirstTryPasses'); @@ -1273,8 +2018,14 @@ export class MaintainerActor { ) { hookReflowAttempted = true; console.log(`[MaintainerActor] ${kind} 类 hook 失败回流修复循环`); - const changed = await reflow(distilled); - if (changed) continue; + const result = await reflow(distilled); + const normalized: HookReflowState = + typeof result === 'boolean' ? { changed: result } : result; + if (normalized.changed) { + reflowState = { ...normalized, failure: distilled }; + afterHookReflow = true; + continue; + } console.warn(`[MaintainerActor] 回流未产生新文件变更,不再重试提交`); } @@ -1323,7 +2074,7 @@ export class MaintainerActor { baseFinding: ReviewFinding, distilledFailure: string, extraSystemPrompt?: string - ): Promise { + ): Promise<{ changed: boolean; loop: FixToolLoop; result: FixAttemptResult }> { this.incrMetric('hookFailureReflows'); const reflowFinding: ReviewFinding = { ...baseFinding, @@ -1353,7 +2104,12 @@ export class MaintainerActor { console.log( `[MaintainerActor] hook 失败回流结果: success=${result.success}, reason=${result.reason}` ); - return loop.getAppliedFiles().length > 0 || loop.getDeletedFiles().length > 0; + return { + changed: + result.success && (loop.getAppliedFiles().length > 0 || loop.getDeletedFiles().length > 0), + loop, + result, + }; } /** 按已记忆的项目规范生成提交信息;无规范时使用朴素默认 */ diff --git a/src/advance/classic/fix/maintainer-brain.ts b/src/advance/classic/fix/maintainer-brain.ts index 5596c7e..da91e84 100755 --- a/src/advance/classic/fix/maintainer-brain.ts +++ b/src/advance/classic/fix/maintainer-brain.ts @@ -119,6 +119,31 @@ const STATISTICAL_REPORT_TOOL: ToolDefinition = { }, }; +const VERIFY_FIX_TOOL: ToolDefinition = { + name: 'verify_fix', + description: '根据当前代码和验证证据判断 finding 是否已经被真正解决,决定是否允许提交', + input_schema: { + type: 'object', + properties: { + passed: { type: 'boolean' }, + issueResolved: { type: 'boolean' }, + evidence: { type: 'string' }, + remainingIssues: { type: 'array', items: { type: 'string' } }, + verificationSummary: { type: 'string' }, + nextAction: { type: 'string', enum: ['commit', 'revise', 'ask'] }, + }, + required: [ + 'passed', + 'issueResolved', + 'evidence', + 'remainingIssues', + 'verificationSummary', + 'nextAction', + ], + additionalProperties: false, + }, +}; + /** * Maintainer 对单条 finding/discussion 可执行的最终动作 */ @@ -144,6 +169,18 @@ export interface MaintainerDecision { alreadyFixed?: boolean; /** 当 alreadyFixed=true 时,向 Reviewer 解释问题已修复的回复正文 */ replyBody?: string; + /** finding 是误报、重复项或按项目约定无需改动时标记为 true */ + notActionable?: boolean; + /** 认知阶段推断出的可能受影响文件,供执行阶段做受控审计 */ + affectedFiles?: string[]; + /** 修复完成后必须检查的语义验证目标 */ + verificationPlan?: string[]; + /** 方案评审阶段识别出的风险或控制措施 */ + risks?: string[]; + /** 红队评审发现的关键风险 */ + adversarialConcerns?: string[]; + /** 最终决策对红队意见的逐项回应 */ + adversarialResponses?: string[]; } /** @@ -164,6 +201,25 @@ export interface NonFindingDecision { memoryContent?: string; } +export interface SemanticFixVerification { + /** 裁决来源。语义验收结果只能来自大模型的结构化输出。 */ + verdictSource: 'llm'; + /** 大模型 verify_fix 工具调用 ID,用于追溯本次定论。 */ + verdictId: string; + /** 是否满足 finding 和验证计划,可以进入提交阶段 */ + passed: boolean; + /** finding 描述的问题是否已经消失 */ + issueResolved: boolean; + /** 当前代码中的具体证据 */ + evidence: string; + /** 尚未解决的问题或验证缺口 */ + remainingIssues: string[]; + /** 面向维护流程的验证摘要 */ + verificationSummary: string; + /** 下一步建议 */ + nextAction: 'commit' | 'revise' | 'ask'; +} + export interface MaintainerBrainOptions { /** LLM 客户端 */ llmClient: LlmClient; @@ -264,6 +320,7 @@ export class MaintainerBrain { typeof fileContent === 'string' ? buildFocusedContext(fileContent, finding) : fileContent; const classification = await new IssueScopeClassifier({ llmClient: this.options.llmClient, + localJudge: this.options.localJudge, }).classify(finding, focusedContext); logMemorySnapshot('MaintainerBrain.decide 范围分类后'); @@ -290,6 +347,7 @@ export class MaintainerBrain { recallPlanner: this.options.recallPlanner, memoryClient: this.options.memoryClient, worktreeManager: this.options.worktreeManager, + localJudge: this.options.localJudge, }); logMemorySnapshot('MaintainerBrain.decide 调用认知引擎前'); @@ -391,6 +449,7 @@ export class MaintainerBrain { recallPlanner: this.options.recallPlanner, memoryClient: this.options.memoryClient, worktreeManager: manager, + localJudge: this.options.localJudge, }); return engine.checkAlreadyFixed({ finding: { ...finding, file: resolved }, @@ -419,6 +478,108 @@ export class MaintainerBrain { } } + /** + * 在工具循环完成后独立验证 finding 是否真正消失。 + * lint/typecheck 只能说明工程仍可检查,不能证明 Reviewer 指出的问题已被解决。 + */ + async verifyFix(params: { + finding: ReviewFinding; + fixDescription?: string; + verificationPlan?: string[]; + risks?: string[]; + adversarialConcerns?: string[]; + adversarialResponses?: string[]; + changedFiles: string[]; + deletedFiles?: string[]; + codeContext: string; + validationSummary?: string; + previousFailure?: string; + }): Promise { + const prompt = this.promptLoader.load('maintainer-verify-fix-task', { + findingFile: params.finding.file, + findingLine: String(params.finding.line), + findingMessage: params.finding.message, + findingSuggestion: params.finding.suggestion ?? '', + fixDescription: params.fixDescription ?? '', + verificationPlan: params.verificationPlan?.join('\n- ') || '未提供,必须自行建立完成标准', + risks: params.risks?.join('\n- ') || '无显式风险记录', + adversarialConcerns: params.adversarialConcerns?.join('\n- ') || '无红队意见记录', + adversarialResponses: params.adversarialResponses?.join('\n- ') || '无主决策回应记录', + changedFiles: params.changedFiles.join(', ') || '无', + deletedFiles: params.deletedFiles?.join(', ') || '无', + codeContext: params.codeContext, + validationSummary: params.validationSummary ?? '未提供静态验证摘要', + previousFailure: params.previousFailure ?? '无', + }); + + try { + const toolCall = await this.options.llmClient.completeDecision( + [VERIFY_FIX_TOOL], + prompt, + this.systemPrompt() + ); + const input = toolCall.input as { + passed?: boolean; + issueResolved?: boolean; + evidence?: string; + remainingIssues?: unknown; + verificationSummary?: string; + nextAction?: string; + }; + const nextAction = + input.nextAction === 'commit' || input.nextAction === 'revise' || input.nextAction === 'ask' + ? input.nextAction + : undefined; + if ( + typeof input.passed !== 'boolean' || + typeof input.issueResolved !== 'boolean' || + typeof input.evidence !== 'string' || + !Array.isArray(input.remainingIssues) || + input.remainingIssues.some(item => typeof item !== 'string') || + typeof input.verificationSummary !== 'string' || + !nextAction + ) { + throw new LlmDecisionError( + '大模型未返回完整、合法的 verify_fix 结构化裁决', + 'invalid_input' + ); + } + + const remainingIssues = input.remainingIssues.map(item => item.trim()).filter(Boolean); + const issueResolved = input.issueResolved; + const evidence = input.evidence.trim(); + const verificationSummary = input.verificationSummary.trim(); + const verdictId = toolCall.id.trim(); + if (!verdictId) { + throw new LlmDecisionError( + '大模型 verify_fix 裁决缺少可追溯的工具调用标识', + 'invalid_input' + ); + } + return { + verdictSource: 'llm', + verdictId, + passed: + input.passed && + issueResolved && + nextAction === 'commit' && + evidence.length > 0 && + verificationSummary.length > 0 && + remainingIssues.length === 0 && + verdictId.length > 0, + issueResolved, + evidence, + remainingIssues, + verificationSummary: verificationSummary || '模型未提供验证摘要', + nextAction, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[MaintainerBrain] 语义修复验证失败,禁止直接提交: ${message}`); + throw new Error(`无法取得大模型语义裁决,禁止提交:${message}`); + } + } + /** * 当 discussion 无法解析出具体 finding 时,由 LLM 决定如何处理 */ @@ -434,7 +595,7 @@ export class MaintainerBrain { ) { const verdict = await this.options.localJudge.preFilterNonFindingDiscussion( params.body, - undefined, + undefined ); if ('kind' in verdict && verdict.kind === 'reliable') { if (verdict.isProbablyNonFinding) { diff --git a/src/advance/classic/fix/maintainer-llm-judge.ts b/src/advance/classic/fix/maintainer-llm-judge.ts index 81a27ca..c6f8499 100644 --- a/src/advance/classic/fix/maintainer-llm-judge.ts +++ b/src/advance/classic/fix/maintainer-llm-judge.ts @@ -11,6 +11,7 @@ import { LlmClient } from '../../llm/client.js'; import type { MaintainerLocalJudge, LocalJudgeVerdict, + AdversarialReviewResult, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, @@ -46,6 +47,16 @@ interface PreFilterNonFindingPromptPayload { discussionNoteCount?: number; } +interface AdversarialReviewPromptPayload { + findingDescription: string; + candidateOptions: string; + currentCodeContextHint?: string; +} + +interface AdversarialDecisionReviewPromptPayload extends AdversarialReviewPromptPayload { + finalDecision: string; +} + /** * 基于 LlmClient 的 Maintainer 判别辅助实现 */ @@ -59,7 +70,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { async reassessSemanticIdentity( currentFindingDescription: string, previousDecisionSummary: string, - fileContextHint?: string, + fileContextHint?: string ): Promise { const payload: ReidentifyPromptPayload = { currentDescription: currentFindingDescription, @@ -81,7 +92,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { reason: { type: 'string' }, }, required: ['likelySame', 'confidence', 'reason'], - }, + } ); const body = this.parseSimpleJson(json); if (!body || typeof body.likelySame !== 'boolean') { @@ -107,7 +118,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { async adviseOnStuckProgress( findingDescription: string, recentProgressSummary: string, - attemptedDirectionsSummary?: string, + attemptedDirectionsSummary?: string ): Promise { const payload: StuckPromptPayload = { findingDescription, @@ -129,10 +140,14 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { reason: { type: 'string' }, }, required: ['suggestion', 'suggestStop', 'reason'], - }, + } ); const body = this.parseSimpleJson(json); - if (!body || typeof body.suggestion !== 'string' || !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion)) { + if ( + !body || + typeof body.suggestion !== 'string' || + !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion) + ) { return { kind: 'unreliable', reason: 'LLM 返回了不可解析的卡点校正结果', @@ -153,8 +168,8 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { async assistAlreadyFixedCheck( findingDescription: string, - currentCodeContextHint?: string, - ): Promise { + currentCodeContextHint?: string + ): Promise { const payload: AlreadyFixedPromptPayload = { findingDescription, currentCodeContextHint, @@ -171,7 +186,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { evidence: { type: 'string' }, }, required: ['likelyAlreadyFixed', 'reason'], - }, + } ); const body = this.parseSimpleJson(json); if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { @@ -181,6 +196,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { }; } return { + kind: 'reliable', likelyAlreadyFixed: body.likelyAlreadyFixed, reason: typeof body.reason === 'string' ? body.reason : '', evidence: typeof body.evidence === 'string' ? body.evidence : undefined, @@ -196,7 +212,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { async preFilterScope( findingDescription: string, findingFile?: string, - findingLine?: number, + findingLine?: number ): Promise { const payload: PreFilterScopePromptPayload = { findingDescription, @@ -217,7 +233,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { reason: { type: 'string' }, }, required: ['scope', 'reason'], - }, + } ); const body = this.parseSimpleJson(json); if (!body || typeof body.scope !== 'string') { @@ -247,7 +263,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { async preFilterNonFindingDiscussion( discussionBody: string, - discussionNoteCount?: number, + discussionNoteCount?: number ): Promise { const payload: PreFilterNonFindingPromptPayload = { discussionBody, @@ -264,7 +280,7 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { reason: { type: 'string' }, }, required: ['isProbablyNonFinding', 'reason'], - }, + } ); const body = this.parseSimpleJson(json); if (!body || typeof body.isProbablyNonFinding !== 'boolean') { @@ -286,6 +302,39 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { } } + async adversarialReview( + findingDescription: string, + candidateOptions: string, + currentCodeContextHint?: string + ): Promise { + const payload: AdversarialReviewPromptPayload = { + findingDescription, + candidateOptions, + currentCodeContextHint, + }; + return this.completeAdversarialReview( + this.buildAdversarialReviewPrompt(payload), + '你是一个保守但有洞察力的代码修复方案红队评审。必须指出候选方案可能遗漏的根因、回归风险和验证缺口。' + ); + } + + async adversarialDecisionReview( + findingDescription: string, + candidateOptions: string, + finalDecision: string, + currentCodeContextHint?: string + ): Promise { + return this.completeAdversarialReview( + this.buildAdversarialDecisionReviewPrompt({ + findingDescription, + candidateOptions, + finalDecision, + currentCodeContextHint, + }), + '你是最终修复决策的独立红队验收员。不要重复主决策的自我评价,必须检查它是否用可执行方案真正回应了既有风险。' + ); + } + // ---------- 提示构造 ---------- private buildPreFilterScopePrompt(payload: PreFilterScopePromptPayload): string { @@ -317,12 +366,11 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { ].join('\\n'); } - private buildPreFilterNonFindingPrompt( - payload: PreFilterNonFindingPromptPayload, - ): string { - const noteCount = payload.discussionNoteCount != null - ? `(讨论 note 数量:${payload.discussionNoteCount})` - : ''; + private buildPreFilterNonFindingPrompt(payload: PreFilterNonFindingPromptPayload): string { + const noteCount = + payload.discussionNoteCount != null + ? `(讨论 note 数量:${payload.discussionNoteCount})` + : ''; return [ '你正在帮助维护者判断:一条 MR discussion 是否很可能不是待逐条修复的代码问题。', '', @@ -348,7 +396,9 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { } private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { - const ctx = payload.fileContextHint ? `\n\n当前文件上下文提示:\n${payload.fileContextHint}` : ''; + const ctx = payload.fileContextHint + ? `\n\n当前文件上下文提示:\n${payload.fileContextHint}` + : ''; return [ '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', @@ -415,6 +465,89 @@ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { ].join('\\n'); } + private buildAdversarialReviewPrompt(payload: AdversarialReviewPromptPayload): string { + const context = payload.currentCodeContextHint + ? `\n\n当前代码上下文:\n${payload.currentCodeContextHint}` + : ''; + return [ + '请对以下代码修复候选方案进行独立红队评审。', + '', + '原始问题:', + payload.findingDescription, + '', + '候选方案:', + payload.candidateOptions, + context, + '', + '请重点检查:是否真正解决根因、是否遗漏相关调用点、是否引入行为回归、是否有可验证的完成标准。', + '只有没有关键阻断风险时 approve 才能为 true;不要因为方案看起来简单就放过未验证的假设。', + '请返回 JSON:approve(boolean)、concerns(string[])、requiredChanges(string[])、reason(string)。', + ].join('\n'); + } + + private buildAdversarialDecisionReviewPrompt( + payload: AdversarialDecisionReviewPromptPayload + ): string { + const context = payload.currentCodeContextHint + ? `\n\n当前代码上下文:\n${payload.currentCodeContextHint}` + : ''; + return [ + '请独立复核以下最终修复决策是否能够安全执行。', + '', + '原始问题:', + payload.findingDescription, + '', + '候选方案与此前红队意见:', + payload.candidateOptions, + '', + '主模型最终决策:', + payload.finalDecision, + context, + '', + '检查重点:最终决策是否解决根因、覆盖必要影响范围、逐项回应关键疑虑,并给出可执行的验证标准。不要因为它自称已回应就批准。', + '只有不存在关键阻断风险时 approve 才能为 true;否则把仍需补齐的内容写入 requiredChanges。', + '请返回 JSON:approve(boolean)、concerns(string[])、requiredChanges(string[])、reason(string)。', + ].join('\n'); + } + + private async completeAdversarialReview( + prompt: string, + system: string + ): Promise { + try { + const json = await this.llmClient.completeJson(prompt, system, { + type: 'object', + properties: { + approve: { type: 'boolean' }, + concerns: { type: 'array', items: { type: 'string' } }, + requiredChanges: { type: 'array', items: { type: 'string' } }, + reason: { type: 'string' }, + }, + required: ['approve', 'concerns', 'requiredChanges', 'reason'], + }); + const body = this.parseSimpleJson(json); + if ( + !body || + typeof body.approve !== 'boolean' || + !Array.isArray(body.concerns) || + !Array.isArray(body.requiredChanges) + ) { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的红队评审结果' }; + } + return { + kind: 'reliable', + approve: body.approve, + concerns: body.concerns.filter((item): item is string => typeof item === 'string'), + requiredChanges: body.requiredChanges.filter( + (item): item is string => typeof item === 'string' + ), + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } + // ---------- system / helper ---------- private reidentifySystem(): string { diff --git a/src/advance/classic/fix/maintainer-local-judge-stub.ts b/src/advance/classic/fix/maintainer-local-judge-stub.ts index 0021de3..9919bbb 100644 --- a/src/advance/classic/fix/maintainer-local-judge-stub.ts +++ b/src/advance/classic/fix/maintainer-local-judge-stub.ts @@ -6,7 +6,16 @@ * - 后续可替换为本地轻量模型实现,且无需修改调用方 */ -import type { MaintainerLocalJudge, LocalJudgeVerdict, SemanticReidentificationResult, StuckCorrectionResult, AlreadyFixedAssistanceResult, PreFilterScopeVerdict, PreFilterNonFindingVerdict } from './maintainer-local-judge.js'; +import type { + AdversarialReviewResult, + MaintainerLocalJudge, + LocalJudgeVerdict, + SemanticReidentificationResult, + StuckCorrectionResult, + AlreadyFixedAssistanceResult, + PreFilterScopeVerdict, + PreFilterNonFindingVerdict, +} from './maintainer-local-judge.js'; export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { /** 当前桩始终可用(避免调用方因“不可用”而改变流程),但判定均不可靠 */ @@ -17,7 +26,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { reassessSemanticIdentity( _currentFindingDescription: string, _previousDecisionSummary: string, - _fileContextHint?: string, + _fileContextHint?: string ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -28,7 +37,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { adviseOnStuckProgress( _findingDescription: string, _recentProgressSummary: string, - _attemptedDirectionsSummary?: string, + _attemptedDirectionsSummary?: string ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -38,8 +47,8 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { assistAlreadyFixedCheck( _findingDescription: string, - _currentCodeContextHint?: string, - ): Promise { + _currentCodeContextHint?: string + ): Promise { return Promise.resolve({ kind: 'unreliable', reason: '本地判别辅助尚未启用,already-fixed 判定由现有机制处理', @@ -49,7 +58,7 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { preFilterScope( _findingDescription: string, _findingFile?: string, - _findingLine?: number, + _findingLine?: number ): Promise { return Promise.resolve({ kind: 'unreliable', @@ -59,11 +68,18 @@ export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { preFilterNonFindingDiscussion( _discussionBody: string, - _discussionNoteCount?: number, + _discussionNoteCount?: number ): Promise { return Promise.resolve({ kind: 'unreliable', reason: '本地判别辅助尚未启用,非 finding 过滤由现有机制处理', }); } + + adversarialReview(): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,跳过方案红队评审', + }); + } } diff --git a/src/advance/classic/fix/maintainer-local-judge.ts b/src/advance/classic/fix/maintainer-local-judge.ts index 62fa6b0..7c0c08e 100644 --- a/src/advance/classic/fix/maintainer-local-judge.ts +++ b/src/advance/classic/fix/maintainer-local-judge.ts @@ -11,8 +11,20 @@ export type LocalJudgeVerdict = | { kind: 'reliable'; value: boolean; reason: string } | { kind: 'unreliable'; reason: string }; +export interface AdversarialReviewResult { + kind: 'reliable'; + approve: boolean; + concerns: string[]; + requiredChanges: string[]; + reason: string; +} + export type PreFilterScopeVerdict = - | { kind: 'reliable'; scope: 'trivial' | 'local' | 'cross-file' | 'needs-clarification'; reason: string } + | { + kind: 'reliable'; + scope: 'trivial' | 'local' | 'cross-file' | 'needs-clarification'; + reason: string; + } | { kind: 'unreliable'; reason: string }; export type PreFilterNonFindingVerdict = @@ -47,6 +59,7 @@ export interface StuckCorrectionResult { * 已修复辅助的结果(可选的增强点) */ export interface AlreadyFixedAssistanceResult { + kind: 'reliable'; /** 问题是否可能已经在当前代码中不存在 */ likelyAlreadyFixed: boolean; /** 判定依据 */ @@ -76,7 +89,7 @@ export interface MaintainerLocalJudge { reassessSemanticIdentity( currentFindingDescription: string, previousDecisionSummary: string, - fileContextHint?: string, + fileContextHint?: string ): Promise; /** @@ -88,7 +101,7 @@ export interface MaintainerLocalJudge { adviseOnStuckProgress( findingDescription: string, recentProgressSummary: string, - attemptedDirectionsSummary?: string, + attemptedDirectionsSummary?: string ): Promise; /** @@ -99,8 +112,8 @@ export interface MaintainerLocalJudge { */ assistAlreadyFixedCheck( findingDescription: string, - currentCodeContextHint?: string, - ): Promise; + currentCodeContextHint?: string + ): Promise; /** * Scope 初筛辅助(中优先级位置) @@ -112,7 +125,7 @@ export interface MaintainerLocalJudge { preFilterScope( findingDescription: string, findingFile?: string, - findingLine?: number, + findingLine?: number ): Promise; /** @@ -124,6 +137,21 @@ export interface MaintainerLocalJudge { */ preFilterNonFindingDiscussion( discussionBody: string, - discussionNoteCount?: number, + discussionNoteCount?: number ): Promise; + + /** 对候选修复方案进行独立的风险挑战,失败时调用方必须保守降级。 */ + adversarialReview?( + findingDescription: string, + candidateOptions: string, + currentCodeContextHint?: string + ): Promise; + + /** 独立复核最终修复决策是否真正回应了方案红队意见。 */ + adversarialDecisionReview?( + findingDescription: string, + candidateOptions: string, + finalDecision: string, + currentCodeContextHint?: string + ): Promise; } diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index 6962a60..09ee951 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -8,7 +8,7 @@ import { LlmClient } from '../../llm/client.js'; import { LlmMaintainerLocalJudge } from '../fix/maintainer-llm-judge.js'; -import { ConservativeLocalJudgeStub, type MaintainerLocalJudge } from '../fix/maintainer-local-judge.js'; +import type { MaintainerLocalJudge } from '../fix/maintainer-local-judge.js'; import { GitLabProvider } from '../provider/gitlab-provider.js'; import { WorktreeManager } from '../worktree/worktree-manager.js'; import { MaintainerBrain } from '../fix/maintainer-brain.js'; @@ -573,6 +573,7 @@ export class MaintainerRunner extends BaseRoleRunner { projectContext, cognitiveDepth, worktreeManager, + localJudge: this.localJudge, }; const state = loadState(project); @@ -2017,11 +2018,18 @@ export class MaintainerRunner extends BaseRoleRunner { threadState.decisions[key] = { action: decision.action, alreadyFixed: decision.alreadyFixed, + notActionable: decision.notActionable, reason: decision.reason, replyBody: decision.replyBody, question: decision.question, deleteFile: decision.deleteFile, + fixDescription: decision.fixDescription, scope: decision.scope, + affectedFiles: decision.affectedFiles, + verificationPlan: decision.verificationPlan, + risks: decision.risks, + adversarialConcerns: decision.adversarialConcerns, + adversarialResponses: decision.adversarialResponses, failedAttempts: decision.action === 'fix' && !codeApplied ? (staleFinding ? 0 : (existing?.failedAttempts ?? 0)) + 1 @@ -2114,6 +2122,12 @@ export class MaintainerRunner extends BaseRoleRunner { fileContent: string; scope?: import('../fix/maintainer-brain.js').MaintainerDecision['scope']; deleteFile?: boolean; + fixDescription?: string; + affectedFiles?: string[]; + verificationPlan?: string[]; + risks?: string[]; + adversarialConcerns?: string[]; + adversarialResponses?: string[]; }> = []; // 只有「人工」新回复才触发逐条重评估;Agent 自动重扫不清空决策、不重跑 LLM。 @@ -2211,11 +2225,18 @@ export class MaintainerRunner extends BaseRoleRunner { threadState.decisions[key] = { action: decision.action, alreadyFixed: decision.alreadyFixed, + notActionable: decision.notActionable, reason: decision.reason, replyBody: decision.replyBody, question: decision.question, deleteFile: decision.deleteFile, + fixDescription: decision.fixDescription, scope: decision.scope, + affectedFiles: decision.affectedFiles, + verificationPlan: decision.verificationPlan, + risks: decision.risks, + adversarialConcerns: decision.adversarialConcerns, + adversarialResponses: decision.adversarialResponses, failedAttempts: staleFinding || existing?.action !== 'fix' ? 0 : (existing.failedAttempts ?? 0), fixSucceeded: staleFinding ? undefined : existing?.fixSucceeded, @@ -2259,6 +2280,12 @@ export class MaintainerRunner extends BaseRoleRunner { fileContent: focusedContextToString(focusedContent), scope: decision.scope, deleteFile: decision.deleteFile, + fixDescription: decision.fixDescription, + affectedFiles: decision.affectedFiles, + verificationPlan: decision.verificationPlan, + risks: decision.risks, + adversarialConcerns: decision.adversarialConcerns, + adversarialResponses: decision.adversarialResponses, }); continue; } @@ -2280,6 +2307,12 @@ export class MaintainerRunner extends BaseRoleRunner { fileContent: focusedContextToString(focusedContent), scope: decision.scope, deleteFile: decision.deleteFile, + fixDescription: decision.fixDescription, + affectedFiles: decision.affectedFiles, + verificationPlan: decision.verificationPlan, + risks: decision.risks, + adversarialConcerns: decision.adversarialConcerns, + adversarialResponses: decision.adversarialResponses, }); } @@ -2295,6 +2328,12 @@ export class MaintainerRunner extends BaseRoleRunner { fileContent: item.fileContent, scope: item.scope, deleteFile: item.deleteFile, + fixDescription: item.fixDescription, + affectedFiles: item.affectedFiles, + verificationPlan: item.verificationPlan, + risks: item.risks, + adversarialConcerns: item.adversarialConcerns, + adversarialResponses: item.adversarialResponses, })), firstNote.body ); @@ -2864,8 +2903,8 @@ export class MaintainerRunner extends BaseRoleRunner { const baseResult = await brain.recheckAlreadyFixed(finding); const assist = await this.localJudge.assistAlreadyFixedCheck( - finding.description, - focusedContent + `${finding.message}\n${finding.suggestion}`, + focusedContextToString(focusedContent) ); if (assist.kind === 'reliable' && assist.likelyAlreadyFixed) { return { @@ -3020,6 +3059,12 @@ export class MaintainerRunner extends BaseRoleRunner { fileContent: string; scope?: import('../fix/maintainer-brain.js').MaintainerDecision['scope']; deleteFile?: boolean; + fixDescription?: string; + affectedFiles?: string[]; + verificationPlan?: string[]; + risks?: string[]; + adversarialConcerns?: string[]; + adversarialResponses?: string[]; }>; }, suppressAsk = false @@ -3066,6 +3111,12 @@ export class MaintainerRunner extends BaseRoleRunner { fileContent: '', scope: decision.scope, deleteFile: decision.deleteFile, + fixDescription: decision.fixDescription, + affectedFiles: decision.affectedFiles, + verificationPlan: decision.verificationPlan, + risks: decision.risks, + adversarialConcerns: decision.adversarialConcerns, + adversarialResponses: decision.adversarialResponses, }); } break; diff --git a/src/advance/classic/runners/shared/state-utils.ts b/src/advance/classic/runners/shared/state-utils.ts index e7f0750..26d4665 100755 --- a/src/advance/classic/runners/shared/state-utils.ts +++ b/src/advance/classic/runners/shared/state-utils.ts @@ -277,14 +277,28 @@ function acquireStateLock(lockPath: string): void { export interface MaintainerFindingDecision { action: 'fix' | 'ask' | 'ignore'; alreadyFixed?: boolean; + /** finding 是误报、重复项或按项目约定无需改动时标记为 true。 */ + notActionable?: boolean; reason: string; replyBody?: string; /** ask 时的问题 */ question?: string; /** fix 时是否标记为删除文件 */ deleteFile?: boolean; + /** 认知阶段选择的修复方向,供批量执行和后续重试恢复。 */ + fixDescription?: string; /** finding 的改动范围,用于重试时继续执行写入边界校验。 */ scope?: 'trivial' | 'local' | 'cross-file' | 'needs-clarification'; + /** 认知阶段批准的可能受影响文件,用于重试时恢复受控写入审计。 */ + affectedFiles?: string[]; + /** 修复完成后的语义验证目标。 */ + verificationPlan?: string[]; + /** 认知阶段识别出的风险与控制措施。 */ + risks?: string[]; + /** 方案红队评审提出的关键意见。 */ + adversarialConcerns?: string[]; + /** 最终决策对红队意见的逐项回应。 */ + adversarialResponses?: string[]; /** fix 失败时的累计重试次数 */ failedAttempts: number; /** fix 是否已经成功 */ diff --git a/src/assets/prompts/cognitive-already-fixed-task.md b/src/assets/prompts/cognitive-already-fixed-task.md index d178111..9aac461 100755 --- a/src/assets/prompts/cognitive-already-fixed-task.md +++ b/src/assets/prompts/cognitive-already-fixed-task.md @@ -31,6 +31,7 @@ - `alreadyFixed=false` - `needsMoreContext=true` - `reason` 中简要说明「缺少哪部分上下文导致无法判断」。 +6. 如果 finding 是误报、重复项、仅供记录的建议,或按项目约定不需要代码修改,返回 `notActionable=true`。这不是 `alreadyFixed=true`,必须在 `reason` 中说明为什么无需行动,并保持 `alreadyFixed=false`。 ## 示例 diff --git a/src/assets/prompts/cognitive-fast-task.md b/src/assets/prompts/cognitive-fast-task.md index cdac150..308e7f4 100755 --- a/src/assets/prompts/cognitive-fast-task.md +++ b/src/assets/prompts/cognitive-fast-task.md @@ -1,6 +1,8 @@ ## 文件路径 {{findingFile}} +先判断问题是否已经修复或无需处理;只有确认仍需修改时才选择 `fix`。如果选择 `fix`,必须说明根因、最小影响文件集合和可验证的完成标准。 + {{fileOverview}} ## 相关代码 ``` @@ -40,5 +42,10 @@ "reasoning": "最终选择该方案的原因", "confidence": "high|medium|low", "alreadyFixed": "如果问题已被修复,填 true", - "replyBody": "ignore 且 alreadyFixed=true 时,向 Reviewer 说明已修复的具体证据" + "notActionable": "如果问题是误报、重复项或按约定无需修改,填 true", + "replyBody": "ignore 时,向 Reviewer 说明已修复或无需处理的具体证据", + "affectedFiles": ["fix 时最终需要修改的文件路径"], + "verificationPlan": ["fix 后必须执行的验证步骤"], + "risks": ["风险或控制措施"], + "adversarialConcerns": ["已识别的关键疑虑"] } diff --git a/src/assets/prompts/cognitive-final-task.md b/src/assets/prompts/cognitive-final-task.md index 9470d20..2dff6f4 100755 --- a/src/assets/prompts/cognitive-final-task.md +++ b/src/assets/prompts/cognitive-final-task.md @@ -15,11 +15,19 @@ {{extraFileContexts}} {{relatedMemories}} +## 红队评审 +{{adversarialReview}} + +## 红队修订要求 +{{adversarialFollowUp}} + {{include:shared/action-descriptions}} 决策原则: {{include:shared/maintainer-decision-principles}} +必须回应红队提出的每个关键疑虑。若根因、影响范围或验证标准仍不确定,应选择 `ask`,不要用一个看似合理的局部修改掩盖不确定性。`fix` 时只能批准确实需要修改的最小文件集合,并给出提交前必须完成的验证步骤;`ignore` 时必须明确是已经修复还是无需处理。 + {{include:shared/json-only-constraint}} 请输出 JSON: @@ -35,5 +43,12 @@ "reasoning": "选择最优方案的原因", "confidence": "high|medium|low", "alreadyFixed": true|false, - "replyBody": "ignore 且 alreadyFixed=true 时,向 Reviewer 说明问题已修复的回复正文" + "notActionable": true|false, + "replyBody": "ignore 时向 Reviewer 说明已修复或无需处理的具体理由", + "affectedFiles": ["最终批准修改的文件路径"], + "verificationPlan": ["提交前必须完成的验证步骤"], + "risks": ["仍存在的风险或控制措施"], + "adversarialResponses": ["逐项回应一条红队意见:说明意见、处理方式和用于证明已处理的验证;不要只写‘已处理’"] } + +当红队提出关键意见时,`adversarialResponses` 中必须逐项回应;红队原始意见由框架单独保存,不能把主模型回应冒充为 `adversarialConcerns`。`verificationPlan` 必须包含可执行的提交前验证步骤。若无法完成回应,应选择 `ask`,不要声称可以安全修复。 diff --git a/src/assets/prompts/cognitive-inquiry-task.md b/src/assets/prompts/cognitive-inquiry-task.md index 3b25783..7317650 100755 --- a/src/assets/prompts/cognitive-inquiry-task.md +++ b/src/assets/prompts/cognitive-inquiry-task.md @@ -1,5 +1,7 @@ 请根据当前问题判断还需要补充哪些上下文信息。 +先区分当前 finding 是可行动、需要更多上下文、已经修复,还是无需处理。不要因为 Reviewer 提供了建议就默认必须修改;如果问题可能是误报、重复项或按项目约定无需修改,应在后续检查中明确标记。 + ## 当前问题 - 文件:{{findingFile}}:{{findingLine}} - 描述:{{findingMessage}} @@ -8,6 +10,7 @@ ## 已掌握上下文 {{relatedFindings}} {{recalledMemories}} +{{alreadyFixedAssessment}} ## 文件概览 {{fileOverview}} diff --git a/src/assets/prompts/cognitive-options-task.md b/src/assets/prompts/cognitive-options-task.md index 5a2b619..716eb7a 100755 --- a/src/assets/prompts/cognitive-options-task.md +++ b/src/assets/prompts/cognitive-options-task.md @@ -1,5 +1,7 @@ 请根据以下上下文生成 2~3 个候选修复方案,并列出各自优缺点和风险。 +先理解 Reviewer 真正指出的根因,再生成方案。方案可以否定 Reviewer 的具体实现建议,但必须解决原始问题;不要为了凑数量生成没有意义的方案。每个方案必须明确最小影响文件集合和可观察的验证步骤。 + ## 问题 - 文件:{{findingFile}}:{{findingLine}} - 描述:{{findingMessage}} @@ -21,7 +23,9 @@ "description": "方案描述", "pros": ["优点1"], "cons": ["缺点1"], - "risk": "low|medium|high" + "risk": "low|medium|high", + "affectedFiles": ["src/foo.ts"], + "verificationSteps": ["运行测试并确认错误路径不再出现"] } ] } diff --git a/src/assets/prompts/maintainer-verify-fix-task.md b/src/assets/prompts/maintainer-verify-fix-task.md new file mode 100644 index 0000000..0efe380 --- /dev/null +++ b/src/assets/prompts/maintainer-verify-fix-task.md @@ -0,0 +1,59 @@ +你是 Maintainer 不可绕过的提交前语义校准员。是否允许提交必须由你根据 Reviewer 的原始 finding、修复方向、当前代码、实际变更文件和静态验证结果给出明确裁决;框架不会在缺少你的有效裁决时降级放行。 + +## 原始 finding + +- 文件:{{findingFile}}:{{findingLine}} +- 问题:{{findingMessage}} +- Reviewer 建议:{{findingSuggestion}} +- 认知阶段修复方向:{{fixDescription}} + +## 认知阶段批准的验证计划 + +- {{verificationPlan}} + +## 认知阶段风险与红队闭环 + +- 风险与控制措施: + - {{risks}} +- 红队关键意见: + - {{adversarialConcerns}} +- 主决策逐项回应: + - {{adversarialResponses}} + +## 实际变更 + +- 修改文件:{{changedFiles}} +- 删除文件:{{deletedFiles}} + +## 当前代码上下文 + +{{codeContext}} + +## 静态验证摘要 + +{{validationSummary}} + +## 上一次验收失败反馈 + +{{previousFailure}} + +判断要求: + +1. `passed=true` 只能在 finding 描述的问题已经消失、有当前代码证据、验证计划已满足且没有关键遗留问题时返回。 +2. lint/typecheck 通过只是辅助证据,不能替代对原始 finding 的语义判断。 +3. 必须用当前代码证据逐项核对红队意见及主决策回应,不能因为主决策声称“已处理”就直接相信。如果修改没有解决根因、遗漏必要调用点、引入回归,或证据不足,必须返回 `passed=false`,并在 `remainingIssues` 中给出下一轮修复可直接使用的具体反馈。 +4. 如果问题本来就是误报或无需处理,应说明这一点,但不要把一次修改伪装成成功修复;只有当前任务确实不需要提交时才允许 `nextAction=ask` 或 `revise`。 +5. 只根据提供的当前代码和验证结果判断,不要假设未展示的代码已经正确。 +6. 必须给出完整结构化定论。缺少任一字段、证据为空、结论相互矛盾或无法判断时,必须返回 `passed=false`,并选择 `revise` 或 `ask`;绝不能默认通过。 + +请输出 JSON: +{ +"passed": true|false, +"issueResolved": true|false, +"evidence": "当前代码中的具体证据", +"remainingIssues": ["尚未解决的问题或验证缺口"], +"verificationSummary": "已完成和未完成的验证", +"nextAction": "commit|revise|ask" +} + +{{include:shared/json-only-constraint}} diff --git a/tests/advance/classic/fix/cognitive-engine.test.ts b/tests/advance/classic/fix/cognitive-engine.test.ts index 630d1dc..163ef97 100755 --- a/tests/advance/classic/fix/cognitive-engine.test.ts +++ b/tests/advance/classic/fix/cognitive-engine.test.ts @@ -8,6 +8,7 @@ import type { CognitiveContext } from '../../../../src/advance/classic/fix/cogni import type { IMemoryClient } from '../../../../src/advance/classic/memory/types.js'; import type { RecallPlanner } from '../../../../src/advance/classic/memory/recall-planner.js'; import type { WorktreeManager } from '../../../../src/advance/classic/worktree/worktree-manager.js'; +import type { MaintainerLocalJudge } from '../../../../src/advance/classic/fix/maintainer-local-judge.js'; import { mockOf } from '../../../helpers/mock-of.js'; function makeContext(): CognitiveContext { @@ -36,11 +37,20 @@ function makeContext(): CognitiveContext { }; } -function makeFastLlmClient(input: Record): LlmClient { +function makeFastLlmClient( + input: Record, + alreadyFixedInput: Record = { + alreadyFixed: false, + reason: '问题仍存在', + } +): LlmClient { return new LlmClient({ apiKey: 'test', mock: { - toolResponses: [{ toolCalls: [{ id: '1', name: 'fast_decision', input }] }], + toolResponses: [ + { toolCalls: [{ id: '0', name: 'already_fixed_check', input: alreadyFixedInput }] }, + { toolCalls: [{ id: '1', name: 'fast_decision', input }] }, + ], }, }); } @@ -54,15 +64,6 @@ function makeAlreadyFixedLlmClient(alreadyFixed: boolean): LlmClient { toolCalls: [ { id: '1', - name: 'inquiry_decision', - input: { needsMoreContext: false, queries: [], reason: '无需补充上下文' }, - }, - ], - }, - { - toolCalls: [ - { - id: '2', name: 'already_fixed_check', input: { alreadyFixed, @@ -151,7 +152,7 @@ describe('CognitiveEngine', () => { expect(decision.action).toBe('fix'); }); - it('standard 模式经过 Inquiry + already_fixed_check + Options + Decide 四步', async () => { + it('fast 模式先执行 already-fixed 复查,已修复时不再进入修复决策', async () => { const llmClient = new LlmClient({ apiKey: 'test', mock: { @@ -160,6 +161,57 @@ describe('CognitiveEngine', () => { toolCalls: [ { id: '1', + name: 'already_fixed_check', + input: { + alreadyFixed: true, + reason: '当前代码已经包含要求的字段', + evidence: '当前代码包含 error?: number', + evidenceSnippet: 'error?: number', + }, + }, + ], + }, + ], + }, + }); + const completeDecision = vi.spyOn(llmClient, 'completeDecision'); + const baseContext = makeContext(); + const context = { + ...baseContext, + finding: { + ...baseContext.finding, + message: '接口缺少 error 字段', + suggestion: '添加 error 字段', + }, + fileContent: 'interface Result { error?: number; }', + }; + + const decision = await new CognitiveEngine({ llmClient }).decide(context, 'fast'); + + expect(decision.action).toBe('ignore'); + expect(decision.alreadyFixed).toBe(true); + expect(completeDecision).toHaveBeenCalledTimes(1); + expect(completeDecision.mock.calls[0]?.[0][0]?.name).toBe('already_fixed_check'); + }); + + it('standard 模式经过 already-fixed 复查 + Inquiry + Options + Decide 四步', async () => { + const llmClient = new LlmClient({ + apiKey: 'test', + mock: { + toolResponses: [ + { + toolCalls: [ + { + id: '1', + name: 'already_fixed_check', + input: { alreadyFixed: false, reason: '变量 b 仍存在且未使用' }, + }, + ], + }, + { + toolCalls: [ + { + id: '2', name: 'inquiry_decision', input: { needsMoreContext: true, @@ -172,7 +224,7 @@ describe('CognitiveEngine', () => { { toolCalls: [ { - id: '2', + id: '3', name: 'already_fixed_check', input: { alreadyFixed: false, reason: '变量 b 仍存在且未使用' }, }, @@ -181,7 +233,7 @@ describe('CognitiveEngine', () => { { toolCalls: [ { - id: '3', + id: '4', name: 'options_decision', input: { options: [ @@ -195,7 +247,7 @@ describe('CognitiveEngine', () => { { toolCalls: [ { - id: '4', + id: '5', name: 'final_decision', input: { action: 'fix', @@ -227,7 +279,7 @@ describe('CognitiveEngine', () => { expect(decision.action).toBe('fix'); expect(decision.analysis).toBe('b 未使用'); - expect(completeDecision.mock.calls[3]?.[1]).toContain('const b = 2;'); + expect(completeDecision.mock.calls[4]?.[1]).toContain('const b = 2;'); }); it('fast 模式返回 alreadyFixed ignore 决策', async () => { @@ -272,8 +324,11 @@ describe('CognitiveEngine', () => { toolCalls: [ { id: '1', - name: 'inquiry_decision', - input: { needsMoreContext: false, queries: [], reason: '无需补充上下文' }, + name: 'already_fixed_check', + input: { + alreadyFixed: false, + reason: 'error 字段缺失', + }, }, ], }, @@ -281,8 +336,8 @@ describe('CognitiveEngine', () => { toolCalls: [ { id: '2', - name: 'already_fixed_check', - input: { alreadyFixed: false, reason: 'error 字段缺失' }, + name: 'inquiry_decision', + input: { needsMoreContext: false, queries: [], reason: '无需补充上下文' }, }, ], }, @@ -330,7 +385,7 @@ describe('CognitiveEngine', () => { expect(decision.action).toBe('fix'); }); - it('standard 模式聚焦窗口不足时会读取完整文件复核 alreadyFixed', async () => { + it('最终决策未通过红队复核时允许修订一次,复核通过后才允许 fix', async () => { const llmClient = new LlmClient({ apiKey: 'test', mock: { @@ -339,8 +394,148 @@ describe('CognitiveEngine', () => { toolCalls: [ { id: '1', + name: 'already_fixed_check', + input: { alreadyFixed: false, reason: '问题仍存在' }, + }, + ], + }, + { + toolCalls: [ + { + id: '2', name: 'inquiry_decision', - input: { needsMoreContext: false, queries: [], reason: '无需补充上下文' }, + input: { needsMoreContext: false, queries: [], reason: '当前上下文足够' }, + }, + ], + }, + { + toolCalls: [ + { + id: '3', + name: 'options_decision', + input: { + options: [ + { + description: '修复目标函数', + pros: ['改动集中'], + cons: ['需要确认调用点'], + risk: 'medium', + verificationSteps: ['运行目标模块测试'], + }, + ], + }, + }, + ], + }, + { + toolCalls: [ + { + id: '4', + name: 'final_decision', + input: { + action: 'fix', + reason: '可以修复', + fixDescription: '修复目标函数', + analysis: '根因已定位', + reasoning: '方案改动集中', + confidence: 'high', + }, + }, + ], + }, + { + toolCalls: [ + { + id: '5', + name: 'final_decision', + input: { + action: 'fix', + reason: '已逐项处理红队意见', + fixDescription: '修复目标函数并检查调用点', + analysis: '根因已定位且影响范围已核对', + reasoning: '保留最小改动,同时完成调用点审计', + confidence: 'high', + verificationPlan: ['运行目标模块测试', '检查所有调用点'], + adversarialResponses: [ + '可能遗漏调用点:已搜索并检查所有调用点', + '必须确认调用点:已完成调用点审计并纳入验证', + '验证计划未覆盖回归场景:已补充目标模块测试', + ], + }, + }, + ], + }, + ], + }, + }); + const localJudge = mockOf({ + isAvailable: vi.fn().mockReturnValue(true), + adversarialReview: vi + .fn() + .mockResolvedValueOnce({ + kind: 'reliable', + approve: false, + concerns: ['可能遗漏调用点'], + requiredChanges: ['必须确认调用点'], + reason: '候选方案尚未证明影响范围完整', + }) + .mockResolvedValueOnce({ + kind: 'reliable', + approve: false, + concerns: ['验证计划未覆盖回归场景'], + requiredChanges: [], + reason: '需要补充行为回归验证', + }), + adversarialDecisionReview: vi + .fn() + .mockResolvedValueOnce({ + kind: 'reliable', + approve: false, + concerns: ['最终决策没有说明如何验证所有调用点'], + requiredChanges: ['补充调用点审计的可执行验证'], + reason: '主决策尚未闭环影响范围', + }) + .mockResolvedValueOnce({ + kind: 'reliable', + approve: true, + concerns: [], + requiredChanges: [], + reason: '修订后的决策已形成可执行闭环', + }), + }); + const completeDecision = vi.spyOn(llmClient, 'completeDecision'); + + const decision = await new CognitiveEngine({ llmClient, localJudge }).decide( + makeContext(), + 'deep' + ); + + expect(decision.action).toBe('fix'); + expect(decision.verificationPlan).toEqual(['运行目标模块测试', '检查所有调用点']); + expect(decision.adversarialConcerns).toContain('最终决策没有说明如何验证所有调用点'); + expect(decision.adversarialConcerns).toContain('验证计划未覆盖回归场景'); + expect(decision.adversarialResponses).toEqual([ + '可能遗漏调用点:已搜索并检查所有调用点', + '必须确认调用点:已完成调用点审计并纳入验证', + '验证计划未覆盖回归场景:已补充目标模块测试', + ]); + expect(localJudge.adversarialReview).toHaveBeenCalledTimes(2); + expect(localJudge.adversarialDecisionReview).toHaveBeenCalledTimes(2); + expect(completeDecision).toHaveBeenCalledTimes(5); + expect(completeDecision.mock.calls[4]?.[1]).toContain('上一版最终决策未通过独立红队复核'); + }); + + it('红队意见经过一次修订仍未闭环时降级为 ask', async () => { + const llmClient = new LlmClient({ + apiKey: 'test', + mock: { + toolResponses: [ + { + toolCalls: [ + { + id: '1', + name: 'already_fixed_check', + input: { alreadyFixed: false, reason: '问题仍存在' }, }, ], }, @@ -348,6 +543,149 @@ describe('CognitiveEngine', () => { toolCalls: [ { id: '2', + name: 'inquiry_decision', + input: { needsMoreContext: false, queries: [], reason: '上下文足够' }, + }, + ], + }, + { + toolCalls: [ + { + id: '3', + name: 'options_decision', + input: { options: [{ description: '局部修改', pros: [], cons: [], risk: 'low' }] }, + }, + ], + }, + { + toolCalls: [ + { + id: '4', + name: 'final_decision', + input: { action: 'fix', reason: '直接修改', verificationPlan: [] }, + }, + ], + }, + { + toolCalls: [ + { + id: '5', + name: 'final_decision', + input: { action: 'fix', reason: '仍然认为可以修改', verificationPlan: [] }, + }, + ], + }, + ], + }, + }); + const localJudge = mockOf({ + isAvailable: vi.fn().mockReturnValue(true), + adversarialReview: vi.fn().mockResolvedValue({ + kind: 'reliable', + approve: false, + concerns: ['未证明根因已经解决'], + requiredChanges: [], + reason: '缺少根因证据', + }), + adversarialDecisionReview: vi.fn().mockResolvedValue({ + kind: 'reliable', + approve: false, + concerns: ['最终决策仍未给出根因证据'], + requiredChanges: ['提供当前代码证据和可执行验证'], + reason: '最终决策仍未闭环', + }), + }); + + const decision = await new CognitiveEngine({ llmClient, localJudge }).decide( + makeContext(), + 'standard' + ); + + expect(decision.action).toBe('ask'); + expect(decision.reason).toContain('独立红队复核'); + expect(localJudge.adversarialDecisionReview).toHaveBeenCalledTimes(2); + }); + + it('最终决策红队复核不可靠时直接 ask,不浪费一次主模型修订', async () => { + const llmClient = new LlmClient({ + apiKey: 'test', + mock: { + toolResponses: [ + { + toolCalls: [ + { + id: '1', + name: 'already_fixed_check', + input: { alreadyFixed: false, reason: '问题仍存在' }, + }, + ], + }, + { + toolCalls: [ + { + id: '2', + name: 'inquiry_decision', + input: { needsMoreContext: false, queries: [], reason: '上下文足够' }, + }, + ], + }, + { + toolCalls: [ + { + id: '3', + name: 'options_decision', + input: { options: [{ description: '局部修改', pros: [], cons: [], risk: 'low' }] }, + }, + ], + }, + { + toolCalls: [ + { + id: '4', + name: 'final_decision', + input: { action: 'fix', reason: '可以修改', verificationPlan: ['运行目标测试'] }, + }, + ], + }, + ], + }, + }); + const localJudge = mockOf({ + isAvailable: vi.fn().mockReturnValue(true), + adversarialReview: vi.fn().mockResolvedValue({ + kind: 'reliable', + approve: true, + concerns: [], + requiredChanges: [], + reason: '候选方案可进入最终决策', + }), + adversarialDecisionReview: vi.fn().mockResolvedValue({ + kind: 'unreliable', + reason: '红队模型暂时不可用', + }), + }); + const completeDecision = vi.spyOn(llmClient, 'completeDecision'); + + const decision = await new CognitiveEngine({ llmClient, localJudge }).decide( + makeContext(), + 'standard' + ); + + expect(decision.action).toBe('ask'); + expect(decision.reason).toContain('未能完成可靠的独立红队复核'); + expect(decision.adversarialConcerns).toContain('在执行修复前重新完成独立红队复核'); + expect(completeDecision).toHaveBeenCalledTimes(4); + }); + + it('standard 模式聚焦窗口不足时会读取完整文件复核 alreadyFixed', async () => { + const llmClient = new LlmClient({ + apiKey: 'test', + mock: { + toolResponses: [ + { + toolCalls: [ + { + id: '1', name: 'already_fixed_check', input: { alreadyFixed: false, @@ -366,6 +704,7 @@ describe('CognitiveEngine', () => { alreadyFixed: true, reason: '完整文件中第 10 行已定义 error 字段', evidence: '第 10 行已包含 error?: number', + evidenceSnippet: 'error?: number', }, }, ], @@ -376,9 +715,7 @@ describe('CognitiveEngine', () => { const worktreeManager = mockOf({ resolveFilePath: vi.fn().mockResolvedValue('src/a.ts'), - readFile: vi - .fn() - .mockResolvedValue('完整文件内容\nconst error: number | undefined = undefined;\n'), + readFile: vi.fn().mockResolvedValue('interface Result { error?: number; }\n'), }); const engine = new CognitiveEngine({ llmClient, worktreeManager }); @@ -398,15 +735,6 @@ describe('CognitiveEngine', () => { toolCalls: [ { id: '1', - name: 'inquiry_decision', - input: { needsMoreContext: false, queries: [], reason: '无需补充上下文' }, - }, - ], - }, - { - toolCalls: [ - { - id: '2', name: 'already_fixed_check', input: { alreadyFixed: false, @@ -419,7 +747,7 @@ describe('CognitiveEngine', () => { { toolCalls: [ { - id: '3', + id: '2', name: 'already_fixed_check', input: { alreadyFixed: true, @@ -458,6 +786,15 @@ describe('CognitiveEngine', () => { toolCalls: [ { id: '1', + name: 'already_fixed_check', + input: { alreadyFixed: false, reason: '需要检查关联清理路径' }, + }, + ], + }, + { + toolCalls: [ + { + id: '2', name: 'inquiry_decision', input: { needsMoreContext: true, @@ -470,7 +807,7 @@ describe('CognitiveEngine', () => { { toolCalls: [ { - id: '2', + id: '3', name: 'already_fixed_check', input: { alreadyFixed: true, diff --git a/tests/advance/classic/fix/fix-tool-loop.test.ts b/tests/advance/classic/fix/fix-tool-loop.test.ts index 9de8db5..90af030 100755 --- a/tests/advance/classic/fix/fix-tool-loop.test.ts +++ b/tests/advance/classic/fix/fix-tool-loop.test.ts @@ -149,6 +149,7 @@ describe('FixToolLoop', () => { finding: mockFinding, mr: mockMR, maxSteps: 20, + maxStepsWithoutProgress: 5, }); const result = await loop.run(); diff --git a/tests/advance/classic/fix/maintainer-actor.test.ts b/tests/advance/classic/fix/maintainer-actor.test.ts index 029d9b2..28c8fe7 100755 --- a/tests/advance/classic/fix/maintainer-actor.test.ts +++ b/tests/advance/classic/fix/maintainer-actor.test.ts @@ -22,6 +22,16 @@ import type { MrLifecycleMetrics } from '../../../../src/advance/classic/runners function createMockBrain(overrides: Partial = {}) { return { decideEnvironmentPrep: vi.fn().mockResolvedValue({ reason: '无需环境准备' }), + verifyFix: vi.fn().mockResolvedValue({ + verdictSource: 'llm', + verdictId: 'mock-verify-fix', + passed: true, + issueResolved: true, + evidence: '测试替身提供了当前代码证据', + remainingIssues: [], + verificationSummary: '测试替身模拟大模型语义验收通过', + nextAction: 'commit', + }), ...overrides, } as unknown as MaintainerBrain; } @@ -1156,6 +1166,16 @@ describe('提交管道(F3/L3)', () => { recheckAlreadyFixed: vi .fn() .mockResolvedValue({ alreadyFixed: false, reason: '校验错误仍存在' }), + verifyFix: vi.fn().mockResolvedValue({ + verdictSource: 'llm', + verdictId: 'mock-verify-fix-lint-reflow', + passed: true, + issueResolved: true, + evidence: '当前代码已消除未使用变量', + remainingIssues: [], + verificationSummary: 'finding 已解决', + nextAction: 'commit', + }), }); const llmClient = createMockLlmClient([ // 第一轮修复:改文件 + finish @@ -1177,6 +1197,7 @@ describe('提交管道(F3/L3)', () => { }, { toolCalls: [{ id: '4', name: 'finish', input: { success: true, reason: 'lint done' } }] }, ]); + const completeWithTools = vi.spyOn(llmClient, 'completeWithTools'); const actor = new MaintainerActor({ provider: createMockProvider(), llmClient, @@ -1187,12 +1208,111 @@ describe('提交管道(F3/L3)', () => { const result = await actor.executeBatchFix( mockMR, - [{ finding: mockFinding, fileContent: 'const unused = 1;' }], + [ + { + finding: mockFinding, + fileContent: 'const unused = 1;', + fixDescription: '删除未使用变量并核对引用', + risks: ['调用方可能仍引用该变量'], + adversarialConcerns: ['必须确认删除不会改变副作用'], + adversarialResponses: ['已核对初始化表达式没有副作用'], + }, + ], 'Reviewer 要求删除未使用变量' ); expect(result.success).toBe(true); expect(commitAndPush).toHaveBeenCalledTimes(2); + expect(brain.verifyFix).toHaveBeenCalledTimes(2); + expect(brain.verifyFix.mock.calls[0]?.[0]).toMatchObject({ + risks: ['调用方可能仍引用该变量'], + adversarialConcerns: ['必须确认删除不会改变副作用'], + adversarialResponses: ['已核对初始化表达式没有副作用'], + }); + expect(brain.verifyFix.mock.calls[1]?.[0].previousFailure).toContain('no-unused-vars'); + expect(completeWithTools.mock.calls[0]?.[2]?.system).toContain('删除未使用变量并核对引用'); + expect(completeWithTools.mock.calls[0]?.[2]?.system).toContain('调用方可能仍引用该变量'); + expect(completeWithTools.mock.calls[0]?.[2]?.system).toContain('必须确认删除不会改变副作用'); + }); + + it('verifyFix 缺失时 fail-closed,绝不提交删除修复', async () => { + const commitAndPush = vi.fn().mockResolvedValue(undefined); + const worktreeManager = createMockWorktreeManager({ commitAndPush }); + const brain = createMockBrain(); + delete (brain as unknown as { verifyFix?: unknown }).verifyFix; + const actor = new MaintainerActor({ + provider: createMockProvider(), + llmClient: createMockLlmClient([]), + worktreeManager, + brain, + maintainerName: 'Maintainer', + }); + const decision: CognitiveDecision = { + action: 'fix', + deleteFile: true, + reason: '文件不应进入 MR', + analysis: 'Reviewer 明确要求删除文件', + consideredOptions: ['删除文件'], + reasoning: '删除是唯一符合 finding 的处理方式', + confidence: 'high', + }; + + const result = await actor.applyDecision( + mockMR, + mockDiscussion, + mockFinding, + decision, + createState() + ); + + expect(result.codeApplied).toBe(false); + expect(result.error).toContain('未提供 verifyFix()'); + expect(commitAndPush).not.toHaveBeenCalled(); + }); + + it('非大模型来源的通过结论也不能打开提交门禁', async () => { + const commitAndPush = vi.fn().mockResolvedValue(undefined); + const worktreeManager = createMockWorktreeManager({ commitAndPush }); + const brain = createMockBrain({ + verifyFix: vi.fn().mockResolvedValue({ + verdictSource: 'unavailable', + verdictId: 'system-check', + passed: true, + issueResolved: true, + evidence: '静态检查通过', + remainingIssues: [], + verificationSummary: '系统检查通过', + nextAction: 'commit', + }), + }); + const actor = new MaintainerActor({ + provider: createMockProvider(), + llmClient: createMockLlmClient([]), + worktreeManager, + brain, + maintainerName: 'Maintainer', + }); + const decision: CognitiveDecision = { + action: 'fix', + deleteFile: true, + reason: '文件不应进入 MR', + analysis: 'Reviewer 明确要求删除文件', + consideredOptions: ['删除文件'], + reasoning: '删除是唯一符合 finding 的处理方式', + confidence: 'high', + }; + + const result = await actor.applyDecision( + mockMR, + mockDiscussion, + mockFinding, + decision, + createState() + ); + + expect(result.codeApplied).toBe(false); + expect(result.error).toContain('未返回合法的大模型语义裁决'); + expect(commitAndPush).not.toHaveBeenCalled(); }); it('permission 类失败不回流不重试,错误为 ≤10 行蒸馏诊断而非原文', async () => { diff --git a/tests/advance/classic/fix/maintainer-brain.test.ts b/tests/advance/classic/fix/maintainer-brain.test.ts index c901aeb..58ba307 100755 --- a/tests/advance/classic/fix/maintainer-brain.test.ts +++ b/tests/advance/classic/fix/maintainer-brain.test.ts @@ -28,7 +28,18 @@ function createFastDecisionLlmClient(input: Record): LlmClient return new LlmClient({ apiKey: 'test', mock: { - toolResponses: [{ toolCalls: [{ id: '1', name: 'fast_decision', input }] }], + toolResponses: [ + { + toolCalls: [ + { + id: '0', + name: 'already_fixed_check', + input: { alreadyFixed: false, reason: '问题仍存在' }, + }, + ], + }, + { toolCalls: [{ id: '1', name: 'fast_decision', input }] }, + ], }, }); } @@ -60,6 +71,15 @@ function createNonFindingDecisionLlmClient(input: Record): LlmC }); } +function createVerifyFixLlmClient(input: Record): LlmClient { + return new LlmClient({ + apiKey: 'test', + mock: { + toolResponses: [{ toolCalls: [{ id: '1', name: 'verify_fix', input }] }], + }, + }); +} + describe('MaintainerBrain', () => { it('LLM 决策为 fix 时返回 fix', async () => { const brain = new MaintainerBrain({ @@ -108,6 +128,76 @@ describe('MaintainerBrain', () => { expect(decision.action).toBe('ignore'); }); + it('语义验收只有在证据、摘要和空剩余问题清单齐全时才通过', async () => { + const llmClient = createVerifyFixLlmClient({ + passed: true, + issueResolved: true, + evidence: '第 5 行已经删除未使用变量', + remainingIssues: [], + verificationSummary: 'finding 已消失,静态验证通过', + nextAction: 'commit', + }); + const completeDecision = vi.spyOn(llmClient, 'completeDecision'); + const brain = new MaintainerBrain({ + llmClient, + }); + + const result = await brain.verifyFix({ + finding: makeFinding(), + risks: ['调用方可能仍依赖旧行为'], + adversarialConcerns: ['必须核对所有调用点'], + adversarialResponses: ['已搜索调用点,并将对应测试加入验证计划'], + changedFiles: ['src/index.ts'], + codeContext: 'const value = 1;', + }); + + expect(result.passed).toBe(true); + expect(result.verdictSource).toBe('llm'); + expect(result.verdictId).toBe('1'); + expect(completeDecision.mock.calls[0]?.[1]).toContain('调用方可能仍依赖旧行为'); + expect(completeDecision.mock.calls[0]?.[1]).toContain('必须核对所有调用点'); + expect(completeDecision.mock.calls[0]?.[1]).toContain('已搜索调用点'); + }); + + it('语义验收字段缺失或仍有剩余问题时禁止通过', async () => { + const brain = new MaintainerBrain({ + llmClient: createVerifyFixLlmClient({ + passed: true, + issueResolved: true, + evidence: ' ', + remainingIssues: ['仍需确认调用方'], + verificationSummary: ' ', + nextAction: 'commit', + }), + }); + + const result = await brain.verifyFix({ + finding: makeFinding(), + changedFiles: ['src/index.ts'], + codeContext: 'const value = 1;', + }); + + expect(result.passed).toBe(false); + expect(result.remainingIssues).toEqual(['仍需确认调用方']); + }); + + it('LLM 语义验收不可用时直接失败,不产生默认通过结论', async () => { + const brain = new MaintainerBrain({ + llmClient: new LlmClient({ + apiKey: 'test', + mock: { error: new Error('模拟 API 不可用') }, + }), + }); + + await expect( + brain.verifyFix({ + finding: makeFinding(), + changedFiles: ['src/index.ts'], + codeContext: 'const value = 1;', + }) + ).rejects.toThrow('禁止提交'); + }); + it('风险等级未开启时直接 ask,不调用 LLM', async () => { const brain = new MaintainerBrain({ llmClient: createMockLlmClient('{"action":"fix"}'), @@ -268,7 +358,7 @@ describe('MaintainerBrain', () => { }); expect(memoryClient.recallUserPreferences).toHaveBeenCalledWith('alice', expect.any(String)); - const prompt = completeDecision.mock.calls[0][1] as string; + const prompt = completeDecision.mock.calls[1][1] as string; expect(prompt).toContain('该用户偏好显式类型注解'); }); @@ -314,6 +404,11 @@ describe('MaintainerBrain', () => { }) .mockResolvedValueOnce({ id: '2', + name: 'already_fixed_check', + input: { alreadyFixed: false, reason: '问题仍存在' }, + }) + .mockResolvedValueOnce({ + id: '3', name: 'fast_decision', input: { action: 'fix', @@ -353,7 +448,7 @@ describe('MaintainerBrain', () => { expect(memoryClient.recallForMaintenance).toHaveBeenCalled(); expect(memoryClient.recallUserPreferences).not.toHaveBeenCalled(); - const prompt = completeDecision.mock.calls[1][1] as string; + const prompt = completeDecision.mock.calls[2][1] as string; expect(prompt).toContain('历史修复方式:显式类型注解'); }); @@ -510,7 +605,7 @@ describe('MaintainerBrain 聚焦上下文与范围分类', () => { userId: 'reviewer', }); - const prompt = completeDecision.mock.calls[0][1] as string; + const prompt = completeDecision.mock.calls[1][1] as string; expect(prompt).toContain('相关代码'); expect(prompt).toContain("import { foo } from './foo';"); expect(prompt).toContain('function target'); diff --git a/tests/advance/classic/fix/maintainer-llm-judge.test.ts b/tests/advance/classic/fix/maintainer-llm-judge.test.ts new file mode 100644 index 0000000..cb38b8a --- /dev/null +++ b/tests/advance/classic/fix/maintainer-llm-judge.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LlmClient } from '../../../../src/advance/llm/client.js'; +import { LlmMaintainerLocalJudge } from '../../../../src/advance/classic/fix/maintainer-llm-judge.js'; + +describe('LlmMaintainerLocalJudge', () => { + it('最终决策红队复核会同时检查候选方案、既有意见和主决策回应', async () => { + const llmClient = new LlmClient({ apiKey: 'test', mock: { response: '{}' } }); + const completeJson = vi.spyOn(llmClient, 'completeJson').mockResolvedValue( + JSON.stringify({ + approve: false, + concerns: ['仍未证明所有调用点都已覆盖'], + requiredChanges: ['补充调用点审计证据'], + reason: '最终决策仍有验证缺口', + }) + ); + const judge = new LlmMaintainerLocalJudge(llmClient); + + const result = await judge.adversarialDecisionReview( + 'src/service.ts:12\n返回值处理不完整', + '候选方案:补齐错误分支\n方案红队意见:必须核对所有调用点', + '{"action":"fix","adversarialResponses":["已检查调用点"]}', + 'function run() { return execute(); }' + ); + + expect(result).toEqual({ + kind: 'reliable', + approve: false, + concerns: ['仍未证明所有调用点都已覆盖'], + requiredChanges: ['补充调用点审计证据'], + reason: '最终决策仍有验证缺口', + }); + expect(completeJson.mock.calls[0]?.[0]).toContain('必须核对所有调用点'); + expect(completeJson.mock.calls[0]?.[0]).toContain('adversarialResponses'); + expect(completeJson.mock.calls[0]?.[1]).toContain('独立红队验收员'); + }); +}); diff --git a/tests/advance/classic/runners/maintainer-runner.test.ts b/tests/advance/classic/runners/maintainer-runner.test.ts index b41b300..bc634f2 100755 --- a/tests/advance/classic/runners/maintainer-runner.test.ts +++ b/tests/advance/classic/runners/maintainer-runner.test.ts @@ -1927,6 +1927,10 @@ describe('MaintainerRunner', () => { reason: '可以自动补充保护', fixDescription: '补充边界判断', scope: 'local', + verificationPlan: ['覆盖边界值测试'], + risks: ['旧调用方可能依赖宽松行为'], + adversarialConcerns: ['必须确认边界变化不会影响正常路径'], + adversarialResponses: ['已将正常路径回归测试加入验证计划'], }), }); const actor = mockOf({ @@ -2008,6 +2012,18 @@ describe('MaintainerRunner', () => { ); expect(actor.executeBatchFix).toHaveBeenCalledOnce(); + expect(vi.mocked(actor.executeBatchFix).mock.calls[0][1][0]).toMatchObject({ + fixDescription: '补充边界判断', + verificationPlan: ['覆盖边界值测试'], + risks: ['旧调用方可能依赖宽松行为'], + adversarialConcerns: ['必须确认边界变化不会影响正常路径'], + adversarialResponses: ['已将正常路径回归测试加入验证计划'], + }); + expect(state.maintainerThreadState?.[discussion.id]?.decisions['src/b.ts:20']).toMatchObject({ + fixDescription: '补充边界判断', + risks: ['旧调用方可能依赖宽松行为'], + adversarialResponses: ['已将正常路径回归测试加入验证计划'], + }); expect(actor.postSummary).toHaveBeenCalledOnce(); expect(vi.mocked(actor.postSummary).mock.calls[0][2]).toEqual(['src/b.ts:20']); expect(vi.mocked(actor.postSummary).mock.calls[0][4]).toEqual([]);